从一个「加了索引反而更慢」的工单说起
去年冬天一个跑电商小程序的客户找我,说他们给订单表加了一个联合索引之后,列表页反而从 300 毫秒涨到了 1.2 秒。我看了一眼他的索引:(status, shop_id, created_at),SQL 是 SELECT id, order_no, total FROM orders WHERE status = 1 AND shop_id = 88 AND remark LIKE '%退款%' ORDER BY created_at DESC LIMIT 20。索引建得挺规范,前两列等值、第三列排序,理论上应该很漂亮。但 remark LIKE '%退款%' 这个前导通配符把所有努力都废掉了——优化器只能用索引做过滤,每一条候选行还得回表取 remark 再判断,回表次数从原来的几十次暴涨到几万次。
这条工单最后是用索引下推(Index Condition Pushdown,ICP)配合一个改写解决的。但排查过程里我发现,很多人对「执行计划到底怎么跑的」只有模糊概念,看到 Using index condition 就以为是好事,看到 Using filesort 就以为是坏事,其实都不一定。这篇把我这两年关于执行计划读法的经验梳理一遍,重点是怎么用 EXPLAIN 和 EXPLAIN ANALYZE 证伪自己的猜测,而不是背结论。
先搞清楚:索引下推到底下推了什么
MySQL 5.6 之前,存储引擎层只负责按索引条件定位行、然后把整行返回给 Server 层,Server 层再做剩余条件的过滤。也就是说,如果索引里有 status、shop_id、created_at,但过滤条件还涉及索引外的 remark,那每一条命中索引的行都必须完整读出来。
ICP 做的事很朴素:把能用在索引列上、但不在最左前缀范围里的那部分条件交给存储引擎层提前判断,命中不了就根本不回表。以 INDEX (shop_id, status, created_at) 为例,SQL 是 WHERE shop_id = 88 AND status = 1 AND created_at > '2026-01-01':前两列等值定位后,第三列的 created_at > ... 虽然不能用来缩小扫描区间(因为前两列已经是等值,第三列本身是可以缩小范围的,这里只是举例),但对范围条件之后的列,ICP 会尽力而为。
关键是要理解 ICP 的收益边界:
- 省掉的是回表(secondary index → clustered index 的随机 I/O),不是索引扫描本身的成本;
- 只有当「索引过滤后的候选行数」远大于「最终结果集」时,收益才明显;
- 如果条件列根本不在索引里,ICP 无从下手,
Using index condition不会出现; - ICP 不适用于聚簇索引(主键)扫描,也不适用于子查询物化等场景。
验证方式很直接:EXPLAIN 输出里 Extra 出现 Using index condition 说明 ICP 生效了;想让更明确一点,可以用 EXPLAIN FORMAT=JSON,在 attached_condition 字段里能看到被下推的条件原文。很多人误以为 attached_condition 就是「索引里能用的条件」,其实它恰恰是「索引列上、但不构成 range 边界的条件」。
覆盖索引:最被低估的性能手段
覆盖索引(covering index)指查询需要的所有列都在索引里,优化器可以完全跳过回表,Extra 里显示 Using index。这里有个很容易犯的错误:以为 Using index 是「用到了索引」,其实它的准确含义是「只用到了索引,不需要回表」。真正的「用到索引但还要回表」是 type: ref/range 但 Extra 里没有 Using index,或者干脆只有 Using where。
我见过最典型的浪费:文章列表页 SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 20,表上只有一个主键索引和 created_at 的单列索引。走 created_at 索引能按顺序取 20 条,但每条都要回表取 title,20 次随机 I/O。如果建 INDEX (created_at, title),是不是就覆盖了?不是——因为 id 也在 SELECT 里。InnoDB 的二级索引叶子节点天然带主键值,所以 INDEX (created_at, title) 实际上包含了 created_at、title、id 三列,这个查询是可以被完全覆盖的。这一点很多人不知道,白白多回表了几百万次。
但覆盖索引不是免费的,代价在写入侧。每一个二级索引都是三倍以上的写放大:一次 INSERT 要写聚簇索引、写所有二级索引、写 undo、写 redo。前面 1766 那篇里实测过,联合索引体积能到主键索引的 2.4 倍。所以判断标准应该是:这个查询的读频次 × 单次回表成本 > 索引带来的写入成本 + 内存占用,才值得加。博客后台一天看不了几次的报表页,别为它加覆盖索引。
用 EXPLAIN ANALYZE 证伪猜测(8.0.18+)
EXPLAIN 给的是优化器的估算,rows 是抽样统计推出来的数字,可能离谱到差三个数量级。MySQL 8.0.18 引入的 EXPLAIN ANALYZE 会真正执行语句,输出实际的行数、实际耗时、循环次数。它就是用来打脸的。
用法:
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;输出形如:
-> Limit: 20 row(s) (cost=1.05 rows=20) (actual time=0.09..4.62 rows=20 loops=1)
-> Index scan on idx_created (reverse) (cost=1.05 rows=20) (actual time=0.08..4.55 rows=20 loops=1)
-> Filter: (status = 1) (actual time=4.5e-4..0.03 rows=12 loops=2000)注意最后那行 loops=2000——索引倒序扫了 2000 条才凑够 20 条 status = 1。这就是典型的「LIMIT 20 看起来很快,实际扫了 2000 行」的假象。EXPLAIN 的 rows=20 骗了你,`EXPLAIN ANALYZE` 把真相说出来了。修法是把 status 并进索引:INDEX (status, created_at),或者如果 status = 1 占 99%,就用 INDEX (status, created_at, title) 做覆盖。
三个真实的排查案例
案例一:rows 估算差 400 倍导致选错索引
一张 60 万行的商品表,有两个索引 (category_id) 和 (updated_at),SQL 是 WHERE category_id = 12 AND updated_at > '2026-09-01'。优化器选了 updated_at,实际扫了 58 万行;如果选 category_id 只扫 1200 行。原因:category_id 的基数统计过期了,优化器以为它区分度低。ANALYZE TABLE products; 之后立刻恢复正常。定期 ANALYZE 是廉价且高收益的运维动作,尤其是大批量导入或删除之后。8.0 默认 innodb_stats_auto_recalc=ON,但触发条件是变更行数超过表大小的 10%,大表上很多天都不会自动更新。
案例二:索引下推被函数表达式废掉
原始的工单就是这样:WHERE DATE(created_at) = '2026-10-01'。索引列上套函数,range 用不了,ICP 也用不了(因为条件不是「列 运算符 常量」的形式)。改成 created_at >= '2026-10-01' AND created_at < '2026-10-02',扫描行数从 42 万降到 1800。同类的还有 WHERE LEFT(title, 4) = '测试'、WHERE order_no + 0 = 123,以及隐式字符集转换——表是 utf8mb4,应用传参是 utf8mb3 的连接字符集,索引直接失效。
案例三:ORDER BY 与索引顺序反向
ORDER BY created_at DESC 在 8.0 之前对联合索引 (a, created_at) 是做不到的——只能倒序扫整段,或者 filesort。8.0 支持降序索引 INDEX (a, created_at DESC),可以让 WHERE a = 1 ORDER BY created_at DESC 直接顺着索引读。但要注意:一个索引里混着升降序时,key_part 的 Collation 列会显示 D,用 SHOW INDEX FROM t 能看到。另外 8.0 之前的 DESC 关键字是被静默忽略的,老库迁移过来容易踩这个坑。
容易被忽略的两个细节:索引基数与索引合并
除了 ICP 与覆盖索引,还有两个字段值得每篇都看一眼。第一个是 key_len。它告诉你索引实际用了多少字节,是判断「索引到底用到第几列」最直接的手段。比如 INDEX (a, b, c),a 是 int(4 字节,可空则 5)、b 是 varchar(50) utf8mb4(50×4+2+1=203 字节)、c 是 datetime(5 字节加空标记)。如果 SQL 只用到 a,key_len 应是 4;用到 a 和 b,应是 4+203=207。很多人以为「WHERE 里写了 a 和 b 就用到两列」,但在 b 上写 b LIKE '%xx' 或 b = 数字常量(隐式转换)时,key_len 会诚实地告诉你只用到 4。我排查问题时习惯把 key_len 当红绿灯:预期用几列,就算出对应字节数,对不上就是条件写法有问题。
第二个是 filtered 列。它表示这一层过滤后预计剩余的百分比,虚高的 filtered 加上虚低的 rows,就是「优化器以为很快、实际很慢」的典型组合。遇到这种情况别急着加索引,先 ANALYZE TABLE,再 EXPLAIN ANALYZE 对比,绝大多数是统计信息的问题而不是索引缺失。还有一个反直觉的现象叫索引合并(index merge):type 显示 index_merge,Extra 里是 Using intersect(idx_a, idx_b)。它把两个单列索引的结果求交,看起来聪明,实际常常比走一个联合索引还慢,因为要读两棵 B+ 树的全部候选主键再比。看到 index_merge 就该考虑是不是该建一个联合索引把它替掉。
顺带说一个实操技巧:把可疑 SQL 的 EXPLAIN 输出前后对比着存。我建了个 ~/.mysql_plans/ 目录,每次改索引前后各存一份 JSON,文件名带日期和 SQL 指纹。改完之后跑 diff,type、key、rows、filtered 四个字段的变化一目了然。这比事后凭记忆争论「到底改没改好」有用得多,也方便在下一次统计信息变化导致计划回退时,翻出「当年好的样子」做对比。
一条可落地的诊断流程
我把实际排查固定成五步,基本能覆盖九成问题:
- 先看慢日志定位具体 SQL,不要凭感觉猜。开
slow_query_log,阈值先设 0.5 秒,跑一周再调。 EXPLAIN看 type、key、rows、Extra 四个字段。type 出现ALL或index且行数大,基本就是问题所在。- 拿真实数据量验证一条 SQL:
SELECT COUNT(*) FROM t WHERE <同样的条件>,和 EXPLAIN 的 rows 对比。差 10 倍以上就该 ANALYZE。 EXPLAIN ANALYZE看实际耗时分布,重点看loops和每层的实际 rows。`LIMIT` 场景下尤其要看最后层的 loops。- 改完在从库上验证一遍执行计划,确认没有因为统计信息差异跑到别的索引。上线前用
pt-index-usage或sys.schema_unused_indexes复核,别建完就忘。
还有一条原则值得反复强调:索引不是越多越好,也不是越宽越好。一个联合索引能同时服务「等值 + 范围 + 排序 + 覆盖」四类需求时才有价值;为了某个低频报表加一个五列宽的索引,长期看是净亏损。每次加索引前先问自己一句「这个查询一天跑几次」,答案通常是「不到三次」,那答案也就出来了。
小结
索引下推和覆盖索引这两个词被讲烂了,但真正去验证「优化器到底有没有按我想的跑」的人不多。工具已经很成熟:EXPLAIN FORMAT=JSON 看下推条件,EXPLAIN ANALYZE 看真实行数和耗时,SHOW INDEX 看索引基数与升降序,INFORMATION_SCHEMA.INNODB_INDEX_STATS 看索引体积。把这四个用熟,比背十篇「索引优化十大法则」有用得多。做站这几年,我越来越确信一件事:数据库层面的性能问题,八成不是缺索引,而是对实际执行路径的错误想象。