MySQL 索引优化实战
从一个慢查询说起
某天运维群里抛出一张慢查询截图:
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 | [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 |
1 | key | key_len | Extra |
type = ALL,全表扫描。因为跳过了最左列 user_id,整个索引无法使用。
优化:将查询条件调整为包含 user_id,或为高频的 status 单列查询单独建索引。
1 | -- 补一个包含 status 和 create_time 的索引以应对此类查询 |
场景二:在索引列上使用函数或运算
1 | EXPLAIN SELECT * FROM orders WHERE DATE(create_time) = '2025-06-01'\G |
1 | key | key_len | Extra |
type = ALL,全表扫描。虽然 create_time 是索引列,但 DATE() 函数包裹后 MySQL 无法利用 B+树的有序性进行范围定位,只能逐行计算函数值。注意:即使表上有 idx_status_time (status, create_time),由于 WHERE 子句没有约束 status(最左列),该索引也无法使用——这再次印证了场景一的最左前缀原则。
优化:改为范围写法,直接命中索引边界。
1 | EXPLAIN SELECT * FROM orders |
1 | key | key_len | Extra |
此时 type = range,虽然 status 列未被约束导致只能做全索引扫描,但 ICP(索引条件下推)特性可以在索引层面过滤 create_time 范围,性能远优于全表扫描。
场景三:隐式类型转换
很多文章会说「传入的字符串和列类型不一致导致索引失效」,但不是所有类型不一致都会出问题。关键在于 MySQL 的隐式转换方向:
1 | -- ❌ 真正危险的:VARCHAR 列与不引号的数字比较 |
1 | key | key_len | Extra |
type = ALL,全表扫描。因为 phone 是 VARCHAR,而 13800138000 是没有引号的整数。MySQL 的隐式转换规则是:当字符串列与数字比较时,将字符串列的值转为数字(而非将数字转为字符串)。这意味着每行的 phone 都要被 CAST 为数值再比较,索引完全无法使用。
1 | -- ✅ 安全的情况:INT 列与带引号的数字字符串比较 |
1 | key | key_len | Extra |
这里 key_len = 4,索引被正常使用(仅用到 user_id 部分)。Extra 中的 Using where 只是表示有额外的过滤条件,不代表索引失效。
核心规则:
VARCHAR列 = 数字→ 🔴 索引杀手(列值被逐行转换)INT列 = '数字字符串'→ 🟢 安全(字符串常量被转换一次)- 防止这类问题的最佳实践:始终给字符串值加引号
场景四:LIKE 以通配符开头
1 | EXPLAIN SELECT * FROM orders WHERE remark LIKE '%退货%'\G |
1 | key | key_len | Extra |
索引只能利用前缀匹配,% 在开头时无法定位起点,只能全表扫描。
优化:对于中缀模糊搜索,应使用 Elasticsearch 或 MySQL 8.0 的全文索引。
场景五:OR 条件两端不全有索引
1 | EXPLAIN SELECT * FROM orders WHERE user_id = 10086 OR amount > 500\G |
1 | key | key_len | Extra |
虽然 user_id 有索引,但 amount 无索引,最终需要扫描索引后再回表过滤 amount,仍有大量回表。
优化:将 OR 改写为 UNION 两条独立索引查询。
1 | (SELECT * FROM orders WHERE user_id = 10086) |
场景六:不等于(!= 或 <>)
1 | EXPLAIN SELECT * FROM orders WHERE status != 0\G |
1 | key | key_len | Extra |
索引可以定位 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 |
1 | key | key_len | Extra |
索引用到了 user_id 和 status(key_len=8 = INT 4 + INT 4),但 ORDER BY create_time DESC 依然触发 filesort。同时发生了回表(因为 amount 不在索引中)。
Step 2 — 消除回表与 filesort
将查询字段改为仅包含索引中的列,先验证覆盖索引效果:
1 | EXPLAIN SELECT user_id, status, create_time |
1 | key | key_len | Extra |
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 |
1 | key | key_len | Extra |
此时 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 | 索引使用长度,可用于判断联合索引中被利用的列数。INT NOT NULL = 4,INT+INT NOT NULL = 8 |
| rows | 优化器估算的需要扫描的行数,越少越好 |
| Extra | Using index:覆盖索引,不回表。Using index condition:ICP。Using filesort:需要额外排序。Using temporary:使用临时表 |
每次优化建议对比 EXPLAIN 输出,量化你的改动效果。
核心要点
- 最左前缀是底线 — 联合索引必须按序使用,否则自动失效
- 避免在索引列上做运算或类型转换 — 用范围替代函数包裹;注意隐式转换方向:
VARCHAR列 = 数字(不引号)才是真正的索引杀手 - 重视覆盖索引 — 让查询的列刚好被索引包含,避免回表;高频查询可适当多列冗余
- 小心范围条件断后路 — 联合索引中,一旦出现范围查询,后续列无法用于排序或过滤
- 延迟关联是利器 — 当必须回表时,先用覆盖索引定位主键,再关联回表,将随机 I/O 降到最低
- EXPLAIN 是显微镜 — 每次改动后立即验证 type、key_len、Extra 的变化,数据说话
索引优化不是一次性的动作,而是随业务增长不断调整的工程实践。掌握上述原则,你就能把大部分慢查询转化为毫秒级响应。
本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.35 中验证通过。验证日期:2026-08-08。
