MySQL 索引优化实战

从一个慢查询说起

某天运维群里抛出一张慢查询截图: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
CREATE TABLE orders (
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
status TINYINT 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 万行测试数据(存储过程略,用以下方式快速生成)
INSERT INTO orders (user_id, status, amount, create_time)
SELECT
FLOOR(1 + RAND() * 50000),
FLOOR(RAND() * 4),
ROUND(RAND() * 1000, 2),
DATE_ADD('2025-01-01', INTERVAL FLOOR(RAND() * 365) DAY)
FROM
(SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t1,
(SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t2,
(SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t3,
(SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t4,
(SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t5,
(SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t6;
-- 以上会在内存表产生 4^6 ≈ 4096 行,重复执行 250 次左右即可生成百万级数据

验证环境: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
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
key key_len Extra
idx_status_time 4 Using where

虽然用到了索引,但实际只是二级索引全扫描,rows 接近全表。DATE() 函数导致无法利用 B+树的有序性进行范围定位。

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

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

此时 type = range,扫描行数大幅下降。


场景三:隐式类型转换

1
EXPLAIN SELECT * FROM orders WHERE user_id = '10086'\G
key key_len Extra
idx_user_status_time 4 Using where

看起来用了索引,但实际 key_len = 4(只有 user_id 部分),并且 Extra 中出现 Using where

发生什么了?user_idINT,传入了字符串 '10086',MySQL 会对每一行的 user_id 做类型转换再比较,索引序失效,等效于 CAST(user_id AS CHAR) = '10086'

优化:保持类型一致,直接用 user_id = 10086


场景四:LIKE 以通配符开头

1
EXPLAIN SELECT * FROM orders WHERE remark LIKE '%退货%'\G
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
key key_len Extra
idx_user_status_time 4 Using where

虽然 user_id 有索引,但 amount 无索引,最终选择全索引扫描后按条件过滤,仍有大量回表。

优化:将 OR 改写为 UNION 两条独立索引查询,或给 amount 加索引。

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
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
key key_len Extra
idx_user_status_time 8 Using index condition; Using filesort

索引用到了 user_idstatus,但 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
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
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:索引使用长度,可用于判断联合索引中被利用的列数。
  • rows:优化器估算的需要扫描的行数,越少越好。
  • Extra
    • Using index:覆盖索引,不回表。
    • Using index condition:索引条件下推(ICP)。
    • Using filesort:需要额外排序,尽量消除。
    • Using temporary:使用临时表,通常性能较差。

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


核心要点

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

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


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