凌晨两点被电话叫起来,一条订单查询接口的 P99 从 80ms 直接冲到 4.2s,业务方在群里刷屏。我做的第一件事不是翻代码,而是连上库,把那条 SQL 原封不动复制出来,前面加上 EXPLAIN 敲回车。两秒钟后我看到 type=ALL、rows=230万、Extra 里光秃秃只有 Using where,心里就有数了。MySQL 执行计划 Explain 这个东西,说它是 DBA 和後端工程师的听诊器一点不夸张——它不会直接告诉你"加个索引就好了",但它会把优化器打算怎么读表、打算读多少行、用哪个索引、在哪一步排序,全都摊在你面前。
这篇内容我打算把 Explain 从头到尾讲一遍:每一列到底在说什么、哪些数字会骗人、为什么同一个 SQL 换个参数写法执行计划就完全变了、以及我在真实排障里遇到的四类典型劣化现场。适合正在被慢查询折磨的後端同学、刚接手线上库的运维、以及准备面试但只会背"type 要至少 range"的朋友。不需要你懂优化器源码,但需要你愿意动手在自己的库上敲几遍。
1. 慢查询排查第一刀:先把 EXPLAIN 敲出来
1.1 EXPLAIN 到底在回答什么问题
很多人对 Explain 的理解停留在"看看有没有走索引",这个认知太窄了。它实际上在回答三个层次的问题。第一层是访问路径:这张表是全表扫、走索引范围扫、还是走唯一索引精确命中。第二层是代价估算:优化器预计要读多少行、过滤后剩多少行、每个候选索引的代价是多少。第三层是执行细节:需不需要额外排序、需不需要建临时表、索引条件下推能不能生效、连接时用哪种算法。
这三层信息是逐层递进的。你看到 type=ALL 只是症状,真正的原因可能藏在 possible_keys 为空(压根没可用索引)、也可能是 key 有值但 rows 估算巨大(优化器觉得走索引不划算),还可能是 Extra 里的 Using filesort 才是真正的耗时大头。只盯 type 一个字段去调优,很容易把一条本来没问题的 SQL 改得更慢。
我自己的习惯是:拿到一条慢 SQL,先看 Extra 和 rows,再看 type 和 key,最后回头核对 key_len 有没有被"截断"。这个顺序和很多教程相反,但实测下来最能快速定位大头——因为线上大部分慢查询不是"扫了全表"这么极端,而是"走了索引但排序在磁盘上做了几十万行"。
还有一个必须提前说清楚的点:Explain 输出的 rows 是估算值,不是真实值。它来自 InnoDB 的统计信息(索引基数 cardinality + 采样页),统计信息过期、数据倾斜严重、或者用了非索引列做条件,估算都可能差出几个数量级。所以看到 rows=1 千万别高兴太早,看到 rows=1000000 也别急着否定,真正的对账手段在后面的 EXPLAIN ANALYZE。
1.2 优化器是怎么"挑"执行计划的
要读懂 Explain,得先知道优化器在纠结什么。MySQL 的优化器(8.0 之后是基于代价的 CBO,并且引入了 Hypergraph 优化器处理多表连接)会枚举若干候选执行计划,给每个计划算一个代价值,挑最小的。代价主要来自两块:IO 代价(读多少页)和CPU 代价(比较多少行、排序多少行)。
这里有个关键概念叫"回表代价"。假设表上有二级索引 idx_name,你要查 name 和 age,而 age 不在索引里。走 idx_name 找到主键再回主键索引取 age,这叫回表。如果 name='张三' 匹配了 10 万行,优化器就会算一笔账:走索引要 10 万次随机回表,还不如直接全表扫一遍按顺序读。于是你看到的现象就是——明明有索引,Explain 里 key 却是 NULL。这不是 MySQL "抽风",是它算过觉得不划算。
理解这一点之后,很多"玄学"现象就解释得通了。为什么加了 LIMIT 10 之后突然走索引了?因为优化器知道只需要 10 行,回表 10 次很划算。为什么换成 SELECT * 就不走索引了?因为需要回表的列变多了,覆盖索引的优势没了。为什么同样是范围查询,值域宽的走全表、值域窄的走索引?也是这笔账。
提示:与其反复猜优化器的心思,不如学会看它的账本——
optimizer_trace会把每个候选计划的代价列出来,这在第 4 节会详细讲。
2. 输出列逐项拆解:哪些列必须看,哪些列容易骗人
2.1 id、select_type、table:先弄清谁在查谁
id 这一列的本质是执行顺序的编号,不是"第几个查询"。规则很简单:id 相同的行,从上往下依次执行;id 越大,优先级越高,越先执行;id 为 NULL 的行通常是 UNION RESULT,表示最后合并结果。很多人在复杂 SQL 里看到 id 出现 1、1、2、3 就懵了,其实只是说明子查询(id=2、3)先跑,外层主查询(id=1)后跑。
select_type 描述的是这一行在整体结构里扮演的角色,常见取值我整理成下面这张表,配合实例看会更清楚:
| select_type 取值 | 含义 | 典型场景 |
|---|---|---|
| SIMPLE | 简单查询,没有子查询和 UNION | 普通单表/多表 JOIN |
| PRIMARY | 最外层查询 | 含子查询时的外层语句 |
| SUBQUERY | 不在 FROM 中的、不相关的子查询 | where id in (select ...) 且不依赖外层 |
| DEPENDENT SUBQUERY | 相关子查询,依赖外层字段 | where exists (select ... where a.x=outer.y) |
| DERIVED | FROM 子句里的派生表 | from (select ...) t |
| MATERIALIZED | 被物化的子查询 | 8.0 中 IN 子查询物化后 |
| UNION | UNION 中的第二个及之后的 SELECT | union all 的後半段 |
| UNION RESULT | UNION 结果合并 | 合并临时表 |
看到 DEPENDENT SUBQUERY 就要警惕,通常意味着子查询会被外层每一行驱动执行一次,也就是常说的"相关子查询陷阱"。2000 万行的外层表 × 每次子查询 1ms,那就是 20000 秒。这类 SQL 在业务代码里经常因为 ORM 自动生成而悄悄出现,我在排查时如果看到这个值,第一反应就是把它改写成 JOIN。
table 列则是参与这一行执行的表名或别名,派生表会显示成<derived2>这种形式,数字对应 id。当你在 Explain 里看到<derived2>并且 type=ALL、rows 很大,基本可以判断派生表被物化成了临时表且没走索引——这是个非常常见的性能黑洞。
2.2 type:从 system 到 ALL 的十一级台阶
type 是所有人最容易记住、也最容易误读的一列。它描述的是访问类型,从好到坏的常见排序是:
| type 值 | 含义 | 大致量级 |
|---|---|---|
| system | 表只有一行(系统表) | 极少见 |
| const | 通过主键或唯一索引等值命中 | 1 行 |
| eq_ref | JOIN 中被驱动表用唯一索引命中 | 每行 1 条 |
| ref | 非唯一索引等值匹配 | 几十到几千行 |
| fulltext | 全文索引 | 视匹配度 |
| ref_or_null | 类似 ref,额外包含 NULL | 一般 |
| index_merge | 多个索引合并使用 | 视情况 |
| range | 索引范围扫描 | 视范围 |
| index | 扫整棵索引树 | 通常很大 |
| ALL | 全表扫描 | 最大 |
这里有几个反直觉的点必须说清楚。第一,type=index 不一定比 range 快,它只是说明"扫的是索引而不是表",如果你的索引是 (id, name) 这种宽索引,扫整棵索引树的代价可能比扫表还高。第二,type=ALL 不一定就是灾难,如果表只有 200 行,全表扫的代价远小于走索引再回表,优化器选 ALL 是正确的决定,这种情况改它反而变慢。
第三,工程上最实用的判断标准不是 type 的绝对档次,而是type 与 rows 的组合。我一般这样分:type 在 ref/eq_ref/const 且 rows 在千级以内,基本健康;type=range 且 rows 在万级以内,可接受,但要关注 filtered;type=index 或 ALL 且 rows 超过十万,就必须动手了。这个经验阈值不是教科书结论,是我在几套不同规模业务库上总结出来的,你在自己库里跑一段时间后也会形成类似的直觉。
注意:type 只是访问方式的标签,真正决定耗时的是"这个访问方式要读多少页、这些页在不在内存里"。缓冲池命中率高的时候,ALL 扫一百万行可能只要几百毫秒;冷数据下走索引回表几十万次反而更慢。所以永远要结合线上实际情况判断。
2.3 key、key_len、possible_keys:索引到底用没用
possible_keys 是优化器认为可以用的索引候选集,key 是最终实际用了的索引。两者不一致时,才是有信息量的时候:如果 possible_keys 有值而 key 是 NULL,说明优化器评估后主动放弃了索引(通常是回表代价太高,或者统计信息不准);如果 possible_keys 本身就是 NULL,那就是索引缺失或者条件写法让索引失效了,这是纯粹的 SQL 改写问题。
真正的高手会盯的是key_len,因为这列能反推出联合索引用到了几个字段。它的计算规则是按字段的实际存储长度累加,MySQL 8.0 下常见字段的参考值如下:
| 字段类型 | 索引长度(字节) | 可空时额外 +1 |
|---|---|---|
| TINYINT | 1 | 2 |
| INT | 4 | 5 |
| BIGINT | 8 | 9 |
| DATETIME(0) | 5 | 6 |
| TIMESTAMP(0) | 4 | 5 |
| CHAR(n) utf8mb4 | 4n | 4n+1 |
| VARCHAR(n) utf8mb4 | 4n+2 | 4n+3 |
假设有联合索引idx_a_b_c (a int not null, b varchar(50) not null, c int not null),如果 Explain 里 key_len 显示 4,说明只用到 a;显示 4+202=206,说明用到 a 和 b;显示 210 才是三个字段全部用上。这个推导在排查"为什么联合索引只走了一半"的时候极其有用——你不需要去读 SQL,光看 key_len 就知道断在第几个字段上。
我遇到过一个典型案例:SQL 是where a=1 and b='xxx' and c=3,看着三个条件都命中了联合索引,但 key_len 只有 206。原因出在 b 字段上,代码传进来的参数类型是数字,虽然值能对上,但 MySQL 在做类型比较时做了转换判断,导致第三列 c 无法继续用索引下推。这类问题不打印 key_len 根本发现不了。
2.4 rows 与 filtered:最容易被误读的两个数字
rows 是优化器估算的需要扫描的行数,注意是"扫描"不是"返回"。filtered 是一个百分比,表示经过 where 条件过滤后还剩多少比例的行预计会进入下一步。这两个数字的正确用法是相乘:rows × filtered% 才是这一行最终向上层输出的估算行数。
举个实际的例子。假设 Explain 有一行 rows=10000、filtered=1.00,那么实际输出大约 100 行;另一行 rows=500、filtered=100.00,实际输出 500 行。单看 rows,后者好像更"快",但结合 filtered 一看,前者从 10000 行里过滤出 100 行,过滤成本是 9900 次比较,而后者没有过滤成本。在多表 JOIN 里,这个乘积直接决定了下一层被驱动表要被驱动多少次。
还有个历史坑必须提醒:MySQL 5.6 及更早版本,filtered 在没有条件下推时会被写成 100.00,这个 100 不代表真的没有过滤,而是根本没有估算。虽然现在主流已经是 8.0,但如果你在维护老库,别被这个数字误导。8.0 之后 filtered 的估算准确度好了不少,但仍然依赖统计信息质量。
rows 估算失真的头号原因是统计信息过期。InnoDB 的统计信息默认不会自动频繁刷新,尤其是大批量导入、批量删除之后。这时候执行ANALYZE TABLE 表名手动刷新一下,往往能让执行计划立刻回到正常轨道。8.0 还支持直方图统计,对于取值分布极度不均的列(比如状态字段、城市字段),建个直方图比建索引还管用:
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 64 BUCKETS;64 是桶数,一般用默认或 64 就够。这个技巧我在处理"某个状态值占了 95% 的订单表"时用得最多,效果比硬加索引好。
2.5 Extra:信息量最大的一栏
如果只能看 Explain 的一列,我选 Extra。它包含的信息比前面所有列加起来都多,也是最容易出现"看懂了但没重视"的地方。常见的值我按"加分项"和"减分项"分一下:
加分项(说明优化到位):
Using index:覆盖索引,查询所需列全在索引里,不需要回表。这是最理想的状态。Using index condition:索引条件下推(ICP),把 where 里能用索引判断的条件下推到存储引擎层过滤,减少回表次数。Using MRR:用了 Multi-Range Read,把随机回表转成近似顺序读。Using index for group-by:松散索引扫描,GROUP BY 直接利用索引有序性,不用建临时表。Using index for skip scan:8.0.13 之后的新特性,最左前缀缺失时也能部分利用索引。Select tables optimized away:优化器直接用索引统计信息得出结果,比如select min(id) from t走主键。
减分项(需要重点排查):
| Extra 值 | 问题 | 常见对策 |
|---|---|---|
| Using filesort | 需要额外排序 | 调整索引顺序与 ORDER BY 对齐,或减少结果集 |
| Using temporary | 需要建临时表 | GROUP BY / DISTINCT 列建立合适索引 |
| Using join buffer | 被驱动表没走索引 | 给关联列加索引 |
| Using where | 存储引擎返回后再过滤 | 属正常现象,但配合大 rows 就要警惕 |
| Range checked for each record | 每行都要重新评估用哪个索引 | 通常是关联列缺索引 |
重点说说Using filesort。很多人一看这个词就以为"排序落磁盘了",其实它既包括内存排序也包括磁盘排序,名字有误导性。真正判断是否落盘要看Sort_merge_passes状态变量和sort_buffer_size。小结果集(几千行以内)的内存排序非常快,完全不用管;但如果 rows 是几十万还带 filesort,那就必须处理。处理思路只有一个:让排序字段的顺序与某个索引的字段顺序完全一致,这样 MySQL 可以直接按索引顺序读出来,filesort 自然消失。
Using temporary也是同理。它常见于 GROUP BY 的字段没有索引、或者 DISTINCT 与 ORDER BY 用了不同字段、或者 UNION 去重。临时表如果在内存里(internal_tmp_mem_storage_engine相关配置)还好,一旦超过tmp_table_size和max_heap_table_size就落磁盘,性能会断崖式下跌。我在线上看到这个值时,处理优先级比 filesort 还高。
3. 动手实操:四类高频劣化现场
3.1 现场一:最左前缀被破坏
建张演示表,模拟订单场景:
CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `status` tinyint NOT NULL, `city` varchar(32) NOT NULL, `amount` decimal(10,2) NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_status_city` (`user_id`,`status`,`city`) ) ENGINE=InnoDB;写入 200 万行数据后,跑这条 SQL:
EXPLAIN SELECT id, amount FROM t_order WHERE status = 1 AND city = '杭州';预期会看到 type=ALL、possible_keys=NULL、key=NULL。原因很直白:联合索引的顺序是 user_id → status → city,跳过第一个字段直接用后面的,B+ 树没法定位。这里有个例外要说清楚:8.0.13 之后优化器在某些情况下会启用 skip scan,把 user_id 的每个不同值枚举一遍再走后续字段,但它的启用条件很苛刻——第一个字段的基数必须很低,而且优化器算了账觉得划算才行。指望它来救场是不现实的,正确的做法还是补一个(status, city)的索引,或者把 user_id 加上。
修完之后再 explain,看到 key_len 等于 status 的 1 字节加 city 的 4×32+2=130 字节,合计 131(status 是 NOT NULL tinyint,1 字节;city 不可空 varchar(32) utf8mb4,130 字节)。但注意,因为索引里没有 amount 和 id 之外的覆盖能力……实际上 id 是主键,InnoDB 二级索引叶子上自带主键值,所以这条 SQL 其实是覆盖索引扫描,Extra 里应该出现 Using index。这就是为什么我建索引时习惯把常用查询的返回列考虑进去——能覆盖就覆盖。
提示:最左前缀的判定不只看书写顺序,还要看等值条件和范围条件的分界。一旦某个字段用了范围(>、<、between、like 'x%'),它后面的字段就无法再用于索引定位,只能用于索引下推过滤。这一点在 key_len 上体现得非常直接。
3.2 现场二:隐式类型转换与函数包裹
这两类问题是 SQL 改写里性价比最高的优化,因为通常改一行代码就能让性能提升十倍以上,而且不需要动表结构。先看隐式转换:
-- user_id 是 bigint,参数传成了字符串 '10086' EXPLAIN SELECT * FROM t_order WHERE user_id = '10086';字段是数字、常量是字符串时,MySQL 会把字符串转成数字再比较,索引依然有效。但反过来,如果字段是 varchar,常量传的是数字,问题就大了:
-- city 是 varchar,参数传成了数字 EXPLAIN SELECT * FROM t_order WHERE city = 0;这时 MySQL 会把每一行的 city 值转成数字再和 0 比较,索引直接失效,type 退化成 ALL。这个坑在 Java 代码里特别隐蔽,因为 MyBatis 的#{}传参类型如果和方法参数声明不一致,就可能出现。我排查过的一个案例里,订单号是 varchar 存储的,前端传参时被某层框架自动转成了 Long,结果一条本来走唯一索引的查询变成了全表扫 800 万行。
函数包裹索引列是另一类经典问题:
-- 错误写法:索引列被函数包住 EXPLAIN SELECT * FROM t_order WHERE DATE(create_time) = '2025-01-15'; -- 正确写法:改成范围查询,索引可用 EXPLAIN SELECT * FROM t_order WHERE create_time >= '2025-01-15 00:00:00' AND create_time < '2025-01-16 00:00:00';原理很简单:B+ 树是按 create_time 的原始值排序的,一旦你套上 DATE(),每一行都要先算函数值才能比较,索引的有序性就没用了。等价改写的套路就是把函数操作搬到常量那一侧。同理,substr(order_no,1,4)='2025'可以改成order_no like '2025%',后者能走索引范围扫。
这里补一个 8.0 之后的小福利:函数索引。如果你的业务确实需要按 DATE(create_time) 查询,可以直接建一个函数索引:
ALTER TABLE t_order ADD INDEX idx_create_date ((DATE(create_time)));这个索引会按函数结果排序,原 SQL 不用改写就能命中。代价是索引维护成本,插入更新时都要多算一次函数值,所以只在你确实高频使用该函数查询时才考虑。
3.3 现场三:排序分组引发的 filesort 与 temporary
排序问题在 Explain 里的表现就是 Extra 出现 Using filesort,但真正要判断的是"这个排序能不能被索引消掉"。规则可以总结成三条。
第一条,ORDER BY 的字段顺序必须和索引字段顺序一致。索引是 (user_id, create_time),ORDER BY user_id, create_time可以直接利用索引;ORDER BY create_time, user_id就做不到,只能 filesort。很多人以为字段一样就行,其实顺序错了结果完全不同。
第二条,排序方向要一致,或者表上有降序索引。ORDER BY a ASC, b DESC这种混合方向,在 MySQL 8.0 之前是无法用索引的。8.0 支持真正的降序索引,可以这样建:
ALTER TABLE t_order ADD INDEX idx_user_time (user_id ASC, create_time DESC);建完之后,ORDER BY user_id ASC, create_time DESC就能直接走索引。这个特性在实际业务里很实用,因为"按用户分组、按时间倒序"是极常见的列表页需求。
第三条,WHERE 等值条件与 ORDER BY 的字段顺序要构成连续前缀。索引是 (user_id, status, create_time),SQL 是WHERE user_id=1 ORDER BY create_time,这里 status 是断点,排序无法直接利用索引。这也是为什么我在设计联合索引时,总是把等值条件字段放前面、范围或排序字段放后面。
再看 GROUP BY。它的原理是先把数据按分组字段排序,再聚合。所以如果分组字段上有索引,MySQL 可以直接顺序读;没有索引就必然产生临时表加排序,也就是 Using temporary; Using filesort 同时出现。有一次我优化一个报表 SQL,只把GROUP BY city改成走 (city) 索引,同时把SELECT *收窄成实际需要的四个字段,查询时间从 3.8 秒降到 210 毫秒。收窄 SELECT 列的收益经常被低估——它可能让原本要回表的查询变成覆盖索引。
3.4 现场四:JOIN 顺序与派生表物化
多表 JOIN 的执行计划里,表出现的顺序就是执行顺序,第一张是驱动表,后面是被驱动表。MySQL 8.0 用的是嵌套循环连接(Nested Loop Join),被驱动表会被驱动表的每一行调用一次。所以核心原则是:小表驱动大表,被驱动表的关联列必须有索引。
Explain 里怎么看出问题?看两张表的 rows:如果第一张表 rows=5,第二张 rows=800000 且 key=NULL,那就意味着 800000 行的表会被完整扫描 5 次,总共 400 万行。而如果第二张表关联列上有索引,Explain 里它会显示 type=ref、key=对应索引,每次只读几行。
优化器一般会自动选小表做驱动表,但在以下情况会选错:统计信息不准、表上有 WHERE 条件导致估算偏差、或者你用了 STRAIGHT_JOIN 强制顺序。我遇到过一次优化器选了估算 rows=1 但实际 30 万行的表做驱动,原因是那个字段的统计信息是几天前的。ANALYZE TABLE 之后优化器就自己纠正了。
派生表的问题更隐蔽:
EXPLAIN SELECT t.city, COUNT(*) FROM (SELECT city, amount FROM t_order WHERE status=1) t GROUP BY t.city;如果这个派生表被物化,Explain 里会看到 id 较大的一行 table=<derived2>,type=ALL,意味着先跑子查询把结果存进临时表(无索引),外层再从临时表里扫。8.0 有derived_merge优化,能把这个子查询直接合并进外层,避免物化,但合并有条件:子查询里有聚合、DISTINCT、LIMIT、UNION 时就不会合并。
想确认是否合并,可以查看 optimizer_switch:
SELECT @@optimizer_switch\G看到derived_merge=on表示优化开关是开的。如果确实无法合并,退而求其次的办法是把派生表改成 JOIN 或者给子查询加上 LIMIT 减少物化行数。我的经验是:能写成 JOIN 的尽量写 JOIN,派生表在 8.0 下虽然比 5.7 聪明很多,但仍然是最容易踩坑的写法之一。
4. 进阶玩法:EXPLAIN 之外的三件套
4.1 FORMAT=JSON / TREE 看代价
普通 EXPLAIN 是表格输出,方便但信息有限。加一个 FORMAT 参数就能看到优化器内部的代价账本:
EXPLAIN FORMAT=JSON SELECT id, amount FROM t_order WHERE user_id=10086 AND status=1\GJSON 输出里有两个字段特别值钱。一个是query_cost,这是优化器为整个查询估算的总代价,单位是"随机读页"的抽象值。另一个是每个步骤的read_cost和eval_cost,以及prefix_cost(累计到当前步骤的代价)。当你纠结"到底是走这个索引还是那个索引"时,把两种写法的 JSON 拉出来对比 query_cost,比凭直觉猜靠谱得多。
还有一个更直观的FORMAT=TREE,它以树形展示执行流程,嵌套关系一目了然:
EXPLAIN FORMAT=TREE SELECT o.id, u.name FROM t_order o JOIN t_user u ON o.user_id=u.id WHERE o.status=1;输出会是类似-> Nested loop inner join -> Filter ... -> Index lookup on u using PRIMARY (id=o.user_id)这样的结构,缩进层级直接告诉你哪个操作在里层。排查多表 JOIN 时,这个格式比表格清楚太多。我现在的习惯是:单表看表格,多表看 TREE。
4.2 EXPLAIN ANALYZE 对账估算与实际
8.0.18 引入的EXPLAIN ANALYZE是个分水岭功能,它会真正执行这条 SQL,然后输出每个节点的估算行数、实际行数、实际耗时。前面说过 rows 是估算的,这里终于有了对账手段:
EXPLAIN ANALYZE SELECT city, COUNT(*) FROM t_order WHERE status=1 GROUP BY city;输出里你会看到类似(actual time=0.35..1240 rows=8 loops=1)和(cost=... rows=100000),前者是实际值后者是估算值。如果两者相差十倍以上,基本可以断定统计信息有问题,直接ANALYZE TABLE往往就能修好。
但这里有个巨大的安全警告必须放在最前面:EXPLAIN ANALYZE 会真实执行语句,包括写操作。对 UPDATE、DELETE 使用它,数据是真会被改的。我的做法是两种:要么先把 DML 改写成等价的 SELECT 来看执行计划,要么把整个语句包在事务里执行完之后 ROLLBACK,且必须确认当前隔离级别和业务无冲突。
BEGIN; EXPLAIN ANALYZE UPDATE t_order SET amount=0 WHERE user_id=10086; ROLLBACK;另外,EXPLAIN ANALYZE 因为要真正跑完,对一条耗时 30 秒的 SQL 就得等 30 秒,所以只适合在测试库或者低峰期用,别在生产高峰随手敲。
4.3 optimizer_trace 追问优化器的心思
有些场景你就是想不通:明明有索引、条件也写得对,为什么优化器偏偏不用?这时候optimizer_trace是终极武器,它会把优化器考虑过的所有候选方案、代价计算过程、最终选择理由全部记录下来。
用法分三步:
SET optimizer_trace = 'enabled=on'; SET optimizer_trace_max_mem_size = 1048576; SELECT id, amount FROM t_order WHERE user_id=10086 AND status=1; SELECT * FROM information_schema.OPTIMIZER_TRACE\G SET optimizer_trace = 'enabled=off';输出是一个巨大的 JSON,重点看rows_estimation和considered_execution_plans两段。前者列出每个索引的估算行数和代价,后者列出所有被比较的计划及最终选择。我印象最深的一次排查:一条 SQL 死活不走 idx_user_status,trace 里显示该索引的估算代价是 4200,而全表扫是 3800,只差 400 就选了全表扫。在这种情况下,把索引改成覆盖索引(加上查询需要的列)之后,代价直接降到 900,优化器立刻改选索引。
这个工具的代价是输出噪音大、不容易读,所以别指望看完就懂,建议先从简单的单表 SQL 练手。另外optimizer_trace_max_mem_size如果设得太小,trace 会被截断,你看到的就是不完整信息,我第一次用就栽在这上面,排查了半天才发现是内存限制。
5. 排查清单与常见问题速查
5.1 高频疑问速查表
我把这几年前后被问得最多的 Explain 问题整理成一张表,方便你直接对照:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| possible_keys 有值但 key 为 NULL | 回表代价过高或统计信息过期 | ANALYZE TABLE;考虑覆盖索引;减少 SELECT 列 |
| type=ALL 且无可用索引 | 条件列缺索引或索引失效 | 补索引;检查函数包裹与隐式转换 |
| key_len 比预期短 | 联合索引只用到了前几个字段 | 核对条件字段顺序,补齐中间字段 |
| rows 估算与实际相差悬殊 | 统计信息陈旧或数据倾斜 | ANALYZE TABLE;建直方图 |
| Extra 出现 Using filesort | 排序字段与索引顺序不匹配 | 调整索引字段顺序或方向 |
| Extra 出现 Using temporary | GROUP BY / DISTINCT 无合适索引 | 为分组字段建索引;避免多字段混用 |
| 加了索引反而变慢 | 小表全扫比走索引更快 | 判断表规模,小表可不加索引 |
| 同一 SQL 不同参数计划不同 | 参数驱动的代价变化 | 属正常现象,用直方图或强制索引稳定计划 |
| 8.0 里 IN 子查询变慢 | 子查询物化策略变化 | 改写成 JOIN,或检查物化临时表大小 |
| JOIN 中某表重复全扫 | 被驱动表关联列无索引 | 给关联列加索引,注意字段类型要一致 |
关于"加了索引反而变慢",我再补一句。曾经有一个只有 300 行的配置表,同事给某个字段加了索引,结果查询反而从 0.2ms 变成 0.5ms。原因就是优化器在"走索引回表 300 次"和"顺序扫 300 行"之间选了前者,白白多了一次索引遍历。小表加索引不是必须的,评估标准是表规模和维护成本。
5.2 我自己的排查顺序和几条硬经验
最后把我实际的排查流程写下来。拿到一条慢 SQL,我按这个顺序走:第一步,跑 EXPLAIN 看 Extra 和 type,先判断是大头问题还是小毛病;第二步,看 key 和 key_len,确认索引使用情况是否与预期一致;第三步,看 rows 和 filtered 相乘,估算各步骤输出行数,找出放大最严重的那一步;第四步,如果还看不懂,上 EXPLAIN ANALYZE 对账估算与实际;第五步,仍然无解就开 optimizer_trace 看候选计划。
几条踩坑换来的经验,也一并分享。第一,不要在业务代码里依赖索引名做强制索引,USE INDEX和FORCE INDEX在表结构变化后可能直接报错,线上事故我见过两次。第二,上线前一定在接近生产数据量的环境验证执行计划,小数据量下所有 SQL 都很快,全是 const 和 ref,根本看不出问题。第三,统计信息刷新要纳入运维流程,大批量数据变更后手动 ANALYZE,比事后救火便宜得多。第四,索引不是越多越好,每个索引都会拖慢写入,我在一个高写入订单表上删掉了四个低效索引,写入 TPS 提升了 18%,查询几乎没有变化。
Explain 这东西,看一百篇文章不如在自己的库上敲一百次。找一张百万级的表,把各种条件写法都跑一遍,对比 Extra 和 key_len 的变化,那种"原来是这样"的感觉,比背任何结论都管用。我现在遇到新的慢查询,基本上看一眼 Extra 加 rows 就能猜个八九不离十,这个直觉没有捷径,全是从一次又一次的 EXPLAIN 里练出来的。