MySQL 索引优化实战
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 | [10 | 25] <-- 根节点 |
每次磁盘 I/O 读取一个页,树的 “高度” 直接决定随机 I/O 次数。一颗三层高的 B+树大约可容纳 2000 万行数据(以主键 INT 为例),而一次三层查找仅需 3 次 I/O。减少索引高度、提高页内利用率、减少回表,正是索引优化的三个核心方向。
索引失效的 7 个高频场景
下面所有示例均基于以下表结构和数据环境,你可以在自己本地复现。
1 | CREATE TABLE orders ( |
验证环境: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 | -- 补一个包含 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 | EXPLAIN SELECT * FROM orders WHERE create_time >= '2025-06-01 00:00:00' |
| 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_id 是 INT,传入了字符串 '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 | (SELECT * FROM orders WHERE user_id = 10086) |
场景六:不等于(!= 或 <>)
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 | EXPLAIN SELECT user_id, status, create_time |
Extra 中出现 Using filesort,说明 ORDER BY create_time 没能用到索引。
优化:将等值查询列前置,范围查询列尽量放在最后。若业务必须如此,可考虑调整索引列为 (user_id, create_time)。
实战:优化一个订单查询
回到最初的慢查询:
1 | SELECT id, user_id, status, amount, create_time |
Step 1 — 看看现状
1 | EXPLAIN SELECT id, user_id, status, amount, create_time |
| key | key_len | Extra |
|---|---|---|
| idx_user_status_time | 8 | Using index condition; Using filesort |
索引用到了 user_id 和 status,但 ORDER BY create_time DESC 依然触发 filesort。同时发生了 回表(因为 amount 不在索引中)。
Step 2 — 消除回表与 filesort
将查询字段改为仅包含索引中的列,先验证覆盖索引效果:
1 | EXPLAIN SELECT user_id, status, create_time |
| 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 | ALTER TABLE orders DROP KEY idx_user_status_time; |
此时索引能完美覆盖 WHERE user_id = ? ORDER BY create_time DESC。再执行 EXPLAIN:
1 | EXPLAIN SELECT user_id, status, create_time |
| 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 | SELECT o.id, o.user_id, o.status, o.amount, o.create_time |
内层子查询使用了覆盖索引,外层只回表取 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 输出,量化你的改动效果。
核心要点
- 最左前缀是底线 — 联合索引必须按序使用,否则自动失效。
- 避免在索引列上做运算或类型转换 — 用范围替代函数,保持类型严格一致。
- 重视覆盖索引 — 让查询的列刚好被索引包含,避免回表;高频查询可适当多列冗余。
- 小心范围条件断后路 — 联合索引中,一旦出现范围查询,后续列无法用于排序或过滤。
- 延迟关联是利器 — 当必须回表时,先用覆盖索引定位主键,再关联回表,将随机 I/O 降到最低。
- EXPLAIN 是显微镜 — 每次改动后立即验证 type、key_len、Extra 的变化,数据说话。
索引优化不是一次性的动作,而是随业务增长不断调整的工程实践。掌握上述原则,你就能把大部分慢查询转化为毫秒级响应。
本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.35 中验证通过。验证日期:2026-08-08。
