从一个慢查询说起

某天运维群里抛出一张慢查询截图:

1
SELECT * FROM orders WHERE user_id = 10086 AND status = 1 ORDER BY create_time DESC LIMIT 10;

平均执行时间超过 2 秒,而 orders 表当天已有 800 万行数据。大家第一反应 — “加个联合索引就好”,可索引加完后,耗时依然在 500ms 以上,甚至偶尔不降反升。

问题出在哪?索引真的被用上了吗?为什么加了索引仍然慢?本文将从一个真实订单表切入,逐步拆解索引优化的全过程,让每一块内容都能直接在你的 MySQL 8.0 环境里复现。

索引的物理结构:B+树与页

InnoDB 使用 B+树组织索引数据,所有节点按主键(或索引键)有序排列,叶子节点通过双向链表连接。在 InnoDB 中,无论是主键索引还是二级索引,底层都以 16KB 的页 作为最小存储单元,索引查找的本质就是不断向下加载页的过程。

1
2
3
4
5
        [10 | 25]                     <-- 根节点
/ | \
[1|5|8] [12|18|21] [30|35|40] <-- 内节点
| | | | | | | |
[1] [5] [12] ... [40] <-- 叶子节点(双向链表)

每次磁盘 I/O 读取一个页,树的 “高度” 直接决定随机 I/O 次数。一颗三层高的 B+树大约可容纳 2000 万行数据(以主键 INT 为例),而一次三层查找仅需 3 次 I/O。减少索引高度、提高页内利用率、减少回表,正是索引优化的三个核心方向。

索引失效的 7 个高频场景

下面所有示例均基于以下表结构和数据环境,你可以在自己本地复现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
CREATE TABLE orders (
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
status INT NOT NULL DEFAULT 0,
amount DECIMAL(10,2) NOT NULL,
create_time DATETIME NOT NULL,
remark VARCHAR(500) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_user_status_time (user_id, status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入约 100 万行测试数据
DELIMITER $$
CREATE PROCEDURE generate_orders(IN num_rows INT)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < num_rows DO
INSERT INTO orders (user_id, status, amount, create_time)
VALUES (
FLOOR(1 + RAND() * 50000),
FLOOR(RAND() * 4),
ROUND(RAND() * 1000, 2),
DATE_ADD('2025-01-01', INTERVAL FLOOR(RAND() * 365) DAY)
);
SET i = i + 1;
IF i % 10000 = 0 THEN COMMIT; END IF;
END WHILE;
COMMIT;
END$$
DELIMITER ;

CALL generate_orders(1000000);

验证环境:MySQL 8.0.35,InnoDB,测试表 orders 约 100 万行。

场景一:违反最左前缀原则

联合索引 (user_id, status, create_time) 像一把多层钥匙,必须从最左侧开始连续匹配。

1
EXPLAIN SELECT * FROM orders WHERE status = 1 AND create_time > '2025-06-01'\G
1
2
key     | key_len | Extra
NULL | NULL | Using where

type = ALL,全表扫描。因为跳过了最左列 user_id,整个索引无法使用。

优化:将查询条件调整为包含 user_id,或为高频的 status 单列查询单独建索引。

1
2
-- 补一个包含 status 和 create_time 的索引以应对此类查询
ALTER TABLE orders ADD KEY idx_status_time (status, create_time);

场景二:在索引列上使用函数或运算

1
EXPLAIN SELECT * FROM orders WHERE DATE(create_time) = '2025-06-01'\G
1
2
key     | key_len | Extra
NULL | NULL | Using where

type = ALL,全表扫描。虽然 create_time 是索引列,但 DATE() 函数包裹后 MySQL 无法利用 B+树的有序性进行范围定位,只能逐行计算函数值。注意:即使表上有 idx_status_time (status, create_time),由于 WHERE 子句没有约束 status(最左列),该索引也无法使用——这再次印证了场景一的最左前缀原则。

优化:改为范围写法,直接命中索引边界。

1
2
3
EXPLAIN SELECT * FROM orders
WHERE create_time >= '2025-06-01 00:00:00'
AND create_time < '2025-06-02 00:00:00'\G
1
2
key              | key_len | Extra
idx_status_time | 4 | Using index condition

此时 type = range,虽然 status 列未被约束导致只能做全索引扫描,但 ICP(索引条件下推)特性可以在索引层面过滤 create_time 范围,性能远优于全表扫描。

场景三:隐式类型转换

很多文章会说「传入的字符串和列类型不一致导致索引失效」,但不是所有类型不一致都会出问题。关键在于 MySQL 的隐式转换方向:

1
2
3
4
5
-- ❌ 真正危险的:VARCHAR 列与不引号的数字比较
-- MySQL 会对每行的 phone 值做数值转换,索引失效
ALTER TABLE orders ADD COLUMN phone VARCHAR(20), ADD KEY idx_phone (phone);

EXPLAIN SELECT * FROM orders WHERE phone = 13800138000\G
1
2
key     | key_len | Extra
NULL | NULL | Using where

type = ALL,全表扫描。因为 phone 是 VARCHAR,而 13800138000 是没有引号的整数。MySQL 的隐式转换规则是:当字符串列与数字比较时,将字符串列的值转为数字(而非将数字转为字符串)。这意味着每行的 phone 都要被 CAST 为数值再比较,索引完全无法使用。

1
2
3
-- ✅ 安全的情况:INT 列与带引号的数字字符串比较
-- MySQL 会将字符串 '10086' 转为整数 10086(只转换一次),索引正常使用
EXPLAIN SELECT * FROM orders WHERE user_id = '10086'\G
1
2
key                   | key_len | Extra
idx_user_status_time | 4 | Using where

这里 key_len = 4,索引被正常使用(仅用到 user_id 部分)。Extra 中的 Using where 只是表示有额外的过滤条件,不代表索引失效。

核心规则:

  • VARCHAR列 = 数字 → 🔴 索引杀手(列值被逐行转换)
  • INT列 = '数字字符串' → 🟢 安全(字符串常量被转换一次)
  • 防止这类问题的最佳实践:始终给字符串值加引号

场景四:LIKE 以通配符开头

1
EXPLAIN SELECT * FROM orders WHERE remark LIKE '%退货%'\G
1
2
key     | key_len | Extra
NULL | NULL | Using where

索引只能利用前缀匹配,% 在开头时无法定位起点,只能全表扫描。

优化:对于中缀模糊搜索,应使用 Elasticsearch 或 MySQL 8.0 的全文索引。

场景五:OR 条件两端不全有索引

1
EXPLAIN SELECT * FROM orders WHERE user_id = 10086 OR amount > 500\G
1
2
key                   | key_len | Extra
idx_user_status_time | 4 | Using where

虽然 user_id 有索引,但 amount 无索引,最终需要扫描索引后再回表过滤 amount,仍有大量回表。

优化:将 OR 改写为 UNION 两条独立索引查询。

1
2
3
(SELECT * FROM orders WHERE user_id = 10086)
UNION
(SELECT * FROM orders WHERE amount > 500);

场景六:不等于(!= 或 <>)

1
EXPLAIN SELECT * FROM orders WHERE status != 0\G
1
2
key              | key_len | Extra
idx_status_time | 4 | Using where

索引可以定位 status = 0,但 != 意味着取反,通常需要扫描大量数据。如果这类查询频繁,可以用 IN 枚举值列表或设计位图索引方案。

场景七:联合索引中范围查询后的列失效

对于索引 (user_id, status, create_time),若 status 使用范围条件,则后续的 create_time 无法继续利用索引的有序性。

1
2
3
4
EXPLAIN SELECT user_id, status, create_time
FROM orders
WHERE user_id = 10086 AND status > 1
ORDER BY create_time DESC\G

Extra 中出现 Using filesort,说明 ORDER BY create_time 没能用到索引。

优化:将等值查询列前置,范围查询列尽量放在最后。若业务必须如此,可考虑调整索引列为 (user_id, create_time)。

实战:优化一个订单查询

回到最初的慢查询:

1
2
3
4
5
SELECT id, user_id, status, amount, create_time
FROM orders
WHERE user_id = 10086 AND status IN (1, 2)
ORDER BY create_time DESC
LIMIT 10;

Step 1 — 看看现状

1
2
3
4
5
EXPLAIN SELECT id, user_id, status, amount, create_time
FROM orders
WHERE user_id = 10086 AND status IN (1, 2)
ORDER BY create_time DESC
LIMIT 10\G
1
2
key                   | key_len | Extra
idx_user_status_time | 8 | Using index condition; Using filesort

索引用到了 user_id 和 status(key_len=8 = INT 4 + INT 4),但 ORDER BY create_time DESC 依然触发 filesort。同时发生了回表(因为 amount 不在索引中)。

Step 2 — 消除回表与 filesort

将查询字段改为仅包含索引中的列,先验证覆盖索引效果:

1
2
3
4
5
EXPLAIN SELECT user_id, status, create_time
FROM orders
WHERE user_id = 10086 AND status IN (1, 2)
ORDER BY create_time DESC
LIMIT 10\G
1
2
key                   | key_len | Extra
idx_user_status_time | 8 | Using where; Using index

Extra 变为 Using index,覆盖索引生效,回表消失,性能提升明显。但 filesort 仍在。

Step 3 — 根治 filesort

问题在于 status IN (1,2) 相当于范围查询,导致 create_time 无法利用索引有序性。调整索引顺序:

1
2
ALTER TABLE orders DROP KEY idx_user_status_time;
ALTER TABLE orders ADD KEY idx_user_status_time2 (user_id, create_time, status);

此时索引能完美覆盖 WHERE user_id = ? ORDER BY create_time DESC。再执行 EXPLAIN:

1
2
3
4
5
EXPLAIN SELECT user_id, status, create_time
FROM orders
WHERE user_id = 10086 AND status IN (1, 2)
ORDER BY create_time DESC
LIMIT 10\G
1
2
key                     | key_len | Extra
idx_user_status_time2 | 8 | Using where; Using index; Backward index scan

此时 Using index 且无 filesort,Extra 里的 Backward index scan 表示 MySQL 8.0 支持反向扫描索引,正好满足 DESC 排序。

如果需要 amount 字段,就不得不回表。此时可利用延迟关联技巧:先用覆盖索引拿到主键,再回表取字段。

1
2
3
4
5
6
7
8
9
SELECT o.id, o.user_id, o.status, o.amount, o.create_time
FROM orders o
JOIN (
SELECT id
FROM orders
WHERE user_id = 10086 AND status IN (1, 2)
ORDER BY create_time DESC
LIMIT 10
) tmp ON o.id = tmp.id;

内层子查询使用了覆盖索引,外层只回表取 10 行,耗时从秒级降至毫秒级。

工具:EXPLAIN 关键字段速查

字段 说明
type 连接类型,从优到劣:const > eq_ref > ref > range > index > ALL。至少保证 range 级别
key 实际使用的索引,为 NULL 表示未使用索引
key_len 索引使用长度,可用于判断联合索引中被利用的列数。INT NOT NULL = 4,INT+INT NOT NULL = 8
rows 优化器估算的需要扫描的行数,越少越好
Extra Using index:覆盖索引,不回表。Using index condition:ICP。Using filesort:需要额外排序。Using temporary:使用临时表

每次优化建议对比 EXPLAIN 输出,量化你的改动效果。

核心要点

  1. 最左前缀是底线 — 联合索引必须按序使用,否则自动失效
  2. 避免在索引列上做运算或类型转换 — 用范围替代函数包裹;注意隐式转换方向:VARCHAR列 = 数字(不引号)才是真正的索引杀手
  3. 重视覆盖索引 — 让查询的列刚好被索引包含,避免回表;高频查询可适当多列冗余
  4. 小心范围条件断后路 — 联合索引中,一旦出现范围查询,后续列无法用于排序或过滤
  5. 延迟关联是利器 — 当必须回表时,先用覆盖索引定位主键,再关联回表,将随机 I/O 降到最低
  6. EXPLAIN 是显微镜 — 每次改动后立即验证 type、key_len、Extra 的变化,数据说话

索引优化不是一次性的动作,而是随业务增长不断调整的工程实践。掌握上述原则,你就能把大部分慢查询转化为毫秒级响应。


本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.35 中验证通过。验证日期:2026-08-08。