EXPLAIN 这命令,只要写过 MySQL 的应该都见过。但说句实在话,我看过太多人只是“用过”,不是“会用”。遇到慢查询就丢一条 EXPLAIN 出来,看着满屏的 possible_keys、rows、Extra 一脸懵,最后要么百度搜个答案照抄,要么干脆直接 FORCE INDEX 一把梭。
这篇文章我不想复制官方文档,就从一个干了很多年 MySQL 优化的角度,把 EXPLAIN 从原理到实战讲透。全程不搞玄学,尽量用真实业务的视角带你把它吃透。不说大话,认真看完这篇,以后排查慢 SQL 你至少会有一套完整的分析思路。
1. 先弄明白 EXPLAIN 到底是什么
EXPLAIN 说白了就是一条命令,用来查看 MySQL 优化器如何执行你的 SQL。它不是一个真的去执行并返回数据的过程,而是告诉你“我打算怎么做”的一纸计划书。加了 EXPLAIN 关键词之后,SELECT 查询不会返回真实数据,而是返回一张执行计划表。这张表里的每一行,都对应 SQL 执行过程中的某个步骤或某张表的访问方式。
可以把它理解成打仗之前的沙盘推演:地图上标注了部队往哪走、走多远、路上可能遇到什么阻力,但并没有真的开打。这样做的价值就在于,你可以在 SQL 真正跑起来之前,判断它会不会出问题,提前调整策略。
sql复制EXPLAIN SELECT * FROM user WHERE user_name = '张三';
就这一条命令,输出结果里会包含 id、select_type、table、type、possible_keys、key、key_len、ref、rows、filtered、Extra 这些列。每一列信息量都很大,但多数初学者只看 key 和 rows,这是一个很大的误区。
1.1 EXPLAIN 能做什么,不能做什么
很多人大脑里有一个错误印象:EXPLAIN 跑出来是说这个 SQL 有没有优化空间。对,但不全对。它更多地是反映“在当前的表结构、索引、数据分布下,优化器做了怎样的取舍”。
它不能做什么?它不能给你一个绝对的结论,比如“这条 SQL 超过了 100 毫秒”。EXPLAIN 只是预估,基于统计信息估算出的代价。真实的执行时间,受硬件负载、缓冲池命中率、并发情况等多方面影响。
所以,EXPLAIN 适合作为排查慢 SQL 的第一站,必须和实际业务场景、数据分布、真实执行时间结合起来看,不能只看这一张表。
1.2 执行计划的产生过程:优化器到底经历了什么
MySQL 拿到一条 SQL 后,先做词法、语法解析,然后经过预处理器、查询优化器、执行计划生成,最后才到执行引擎。EXPLAIN 展示的,正是优化器生成的那一棵“执行计划树”。
优化器的主要工作,就是选择访问路径。同样一条 SQL,可以通过全表扫描完成,也可以通过某个索引找到目标行,甚至可以在多个索引之间做选择。优化器会基于表的统计信息,比如行数、索引基数、字段分布,估算每种方案的代价,然后选择它认为代价最低的那一个。
这个环节容易出现一个看起来很怪的现象:明明有索引,优化器偏不走。这里的原因很多,可能是优化器认为索引回表成本太高,也可能统计信息不准确导致估算错误。这些在后面的案例里会详细展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手把手拆解 EXPLAIN 的每个关键列
EXPLAIN 的输出列,初学者觉得多,但真正要反复研究的其实就那么几个。这里我按优先级排序讲,你别试图一次背完所有列,抓住核心就好。
2.1 每一列到底在说什么
- id:SELECT 的编号,数字越大越先执行。如果完全相同,说明是同一组查询,按从上到下的顺序执行。
- select_type:显示这个查询的类型,常见值有 SIMPLE(普通查询)、PRIMARY(最外层)、SUBQUERY(子查询)、DERIVED(派生表)、UNION 等。看到 DEPENDENT SUBQUERY 时要注意,说明子查询依赖外层数据,很可能导致每行都执行一次子查询。
- table:这一行访问的是哪张表。如果显示 <derivedN> 或 <unionM,N>,说明数据来自临时结果集。
- type:访问类型,是判断性能好坏的第一个关键指标。
- possible_keys:优化器认为可能用到的索引,只是候选名单,不代表真的用了。
- key:优化器实际选用的索引。如果这里 NULL,说明没走任何索引。
- key_len:使用的索引字节长度。越长通常说明用到的索引列越多或字段越宽,可以用来判断是否用上了联合索引的一部分。
- ref:显示与索引比较的列或常量,比如 const、db.table.col。如果是 func,说明有函数参与,通常对索引不友好。
- rows:优化器估算的需要扫描的行数。这个值是估算值,不是精确值,但趋势能反映 SQL 的代价。
- filtered:经过条件过滤后剩余行数的百分比。rows 和 filtered 配合看,可以估算最终结果集量级。
- Extra:附加信息区,包含大量有价值的线索,后面单独讲。
2.2 type 的类型从好到坏排序
type 是第一个要看的重点。常见取值按性能从高到低大致排列如下:
- system:表中只有一行(系统表),这是特殊场景,几乎见不到。
- const:用主键或唯一索引等值匹配,最多返回一行。这种是高效率的极致表现。
- eq_ref:被驱动表通过主键或唯一索引等值匹配,通常出现在多表 JOIN 连接中,也是优秀表现。
- ref:通过普通索引等值匹配,可能返回多行。比 eq_ref 弱一档,但已经不错。
- range:索引范围扫描,比如 BETWEEN、>、<、IN 等,也是可接受的。
- index:全索引扫描,即遍历整个索引树,比全表扫描稍好,但数据量大时仍不理想。
- ALL:全表扫描。通常意味着大表 SQL 需要优化。
如果看到 ALL 或 index,先别急着下结论。如果表数据量很小,全表扫描成本可能反而更低,优化器未必选错。但如果一张百万级的大表经常出现 ALL,那基本可以确定查询条件没用上索引。
2.3 Extra 里最值得警惕的几类提示
Extra 的细节往往能揭示 SQL 的真正问题。常见的几类信息包括:
- Using index:覆盖索引,查询需要的字段都在索引里,不用回表。这是高效表现。
- Using where:存储引擎返回记录后,在 Server 层做了条件过滤。如果 type 是 ALL 且 Extra 里有 Using where,通常说明没用上索引。
- Using index condition:索引下推(ICP),部分过滤条件下推到存储引擎层处理,是 MySQL 5.6 及以上版本的优化手段,一般比 Using where 好一些。
- Using temporary:使用了临时表,常见于 GROUP BY、ORDER BY 或 DISTINCT。数据量大时会有额外的磁盘开销。
- Using filesort:需要额外的排序操作。排序无法利用索引时触发,文件排序并不代表真的用文件,但确实需要额外成本。若出现这个,通常意味着 ORDER BY 和 WHERE 条件中的索引列顺序设计不合理。
- Using join buffer:JOIN 时没有利用索引,使用了连接缓冲区,通常需要优化驱动表或关联字段的索引。
这三列看完,其实对一条 SQL 的整体健康程度已经有了判断。但 EXPLAIN 的结果还受统计信息影响,索引的选择不是固定的,所以每次分析都要结合具体表结构和数据分布。
3. 实际案例:这 5 种慢 SQL 的执行计划长什么样
结合案例去看 EXPLAIN 才会真正有感觉。下面用一个简化的电商订单场景来演示,表结构模拟实际业务中常见的设计。
3.1 先建两张表并造一点数据
用户表 user:
sql复制CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`user_name` varchar(50) NOT NULL,
`email` varchar(100) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_name` (`user_name`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单表 orders:
sql复制CREATE TABLE `orders` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`order_no` varchar(64) NOT NULL,
`status` tinyint NOT NULL DEFAULT 0,
`amount` decimal(12,2) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分别插入一点测试数据。user 表插入 5 万行,orders 表插入 50 万行,user_id 随机分布在 1 到 5 万之间。造数可以用存储过程,也可以直接程序灌入,这里不贴冗长脚本,理解查询行为才是重点。
3.2 案例一:等值查询没用上索引
一个看起来再正常不过的查询:
sql复制EXPLAIN SELECT * FROM user WHERE user_name = '张三';
正常情况下 user_name 有普通索引,type 应该是 ref。但如果某次出现 type 为 ALL、rows 为全表行数,就要检查是不是索引失效。常见原因是字符集不一致,比如 user 表是 utf8mb4,关联表的字段却是 latin1,或者查询列上隐式加了函数。另一个容易忽略的原因是字符串前缀匹配,比如等值查询中字段带有前导空格。
这里还可以顺便看一眼 key_len。idx_user_name 是 varchar(50)、utf8mb4 字符集,理论长度是 50 × 4 + 2 = 202 字节。如果 key_len 小于 202,说明没用满整个索引列;如果 key_len 是 NULL,说明索引根本没参与。
3.3 案例二:联合索引顺序错误导致只走了一半索引
假设要按用户和状态查订单,业务上经常用 user_id + status 查询,建了联合索引:
sql复制ALTER TABLE orders ADD KEY idx_user_status (user_id, status);
但执行下面这条 SQL 时发现问题:
sql复制EXPLAIN SELECT * FROM orders WHERE status = 1 AND user_id = 10086;
注意,WHERE 条件的顺序是 status 在前、user_id 在后,但优化器不会因为你的书写顺序就错乱。实际上,只要联合索引的左前缀列 user_id 出现在条件中,索引依然可以用。所以真正要看的不是 SQL 中条件的书写顺序,而是查询里是否出现了联合索引的首列。
更常见的问题是查询用 status 单独查:
sql复制EXPLAIN SELECT * FROM orders WHERE status = 1;
因为没有 status 开头的索引,type 会变成 ALL 或者如果用不上索引就直接全表扫描。要想优化,要么把联合索引顺序反过来,要么单独给 status 加索引。
3.4 案例三:ORDER BY 触发 filesort
这段查询看起来有索引可用:
sql复制EXPLAIN SELECT id, user_id, amount FROM orders WHERE user_id = 10086 ORDER BY created_at;
orders 表上有 idx_user_id 和 idx_created_at 两个单列索引。执行时,优化器通常会选择 idx_user_id 定位到该用户的订单,但排序字段 created_at 不在这棵索引树上,所以需要额外排序,Extra 里会出现 Using filesort。
如果业务上这个查询模式非常高频,更合理的做法是建联合索引 (user_id, created_at)。这样 user_id 等值定位后,created_at 在索引内部已经有序,直接用索引顺序读取即可,filesort 就会消失。
3.5 案例四:JOIN 没有走索引
订单和用户联查:
sql复制EXPLAIN SELECT u.user_name, o.order_no
FROM orders o
JOIN user u ON o.user_id = u.id
WHERE o.created_at >= '2024-01-01 00:00:00';
执行计划里,orders 表先走 idx_created_at 范围扫描,然后对每一行去 user 表根据主键查找,user 表的 type 应该是 eq_ref,这是比较理想的 JOIN 过程。但如果 user 表的连接列没有索引,type 就会变成 ALL,Extra 出现 Using join buffer (hash join),这种情况就要关注驱动表和被驱动表的索引。
JOIN 优化的核心原则:被驱动表的连接字段必须建索引。驱动表可以尽量小,减少扫描基数。
3.6 案例五:OR 条件导致索引失效
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 10086 OR status = 1;
OR 的经典问题是:如果两个条件中有一个无法使用索引,MySQL 可能会放弃索引,直接全表扫描。即使两边都有索引,优化器有时也会选择用索引合并(Index Merge),出现 type 为 index_merge。如果发现 SQL 含有 OR 且没有走理想索引,最稳妥的方案是改写为 UNION ALL:
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 10086
UNION ALL
SELECT * FROM orders WHERE status = 1;
但注意要留意重复数据,UNION 会去重,UNION ALL 不会,业务允许时再用 UNION ALL。
通过这些案例可以看出来,EXPLAIN 不是孤立地去查一张表,它反映的是索引结构、查询条件、数据分布三者的综合博弈。
4. EXPLAIN 在分页、子查询和分区表里有哪些特殊用法
业务里更复杂的场景集中在分页、子查询、分区表,这些地方 EXPLAIN 的表现也更容易让人困惑。
4.1 深分页 LIMIT 的执行计划
深分页是常见性能杀手:
sql复制EXPLAIN SELECT * FROM orders ORDER BY created_at DESC LIMIT 200000, 20;
在没有合适索引扫描的情况下,执行计划大概率显示使用 idx_created_at 做逆序扫描,然后通过回表读取前 200020 行,再丢掉前 200000 行。EXPLAIN 不会直接告诉你“浪费了 20 万次回表”,但 rows 会显示很大的扫描量。
优化的常规手段是延迟关联。先只查主键或索引列,完成分页后再回表取整行:
sql复制SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY created_at DESC LIMIT 200000, 20) t
ON o.id = t.id;
这种写法下,内层子查询用覆盖索引扫描,回表次数只有 20 次,性能差异在数据量大时非常明显。
4.2 子查询的派生表合并与物化
MySQL 5.7 及以上版本对子查询的处理比老版本聪明很多。比如:
sql复制EXPLAIN SELECT * FROM (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
) d
JOIN user u ON d.user_id = u.id;
执行计划里可能看到 <derived2>,表示派生表被物化。如果派生表数据量很大,物化本身就是成本。优化器还有可能做“派生表条件下推”或“合并”,具体取决于版本和条件。看到 SELECT table 为 <derivedN> 时,留意 filtered 和 rows,估算物化后的规模。
还有一种常见坑是 IN (SELECT ...) 的写法。
sql复制EXPLAIN SELECT * FROM user
WHERE id IN (SELECT user_id FROM orders WHERE status = 1);
在 5.7 版本中,优化器会把 IN 子查询转换为 semi-join,执行计划里会看到 user 表和 orders 表的关联方式。若看到 Full scan on NULL key,说明子查询结果为空时可能采用全扫策略,需要结合具体数据量评估。
4.3 分区表的 EXPLAIN 怎么读
分区表上 EXPLAIN 多了一列或一个分区信息显示。MySQL 8.0 里用 EXPLAIN SELECT ... FROM table PARTITION(p0) 可以指定分区;不带分区条件时,EXPLAIN 会在 partitions 列显示可能访问的分区列表。
核心优化思路是分区裁剪。如果执行计划显示走了多个无关分区,说明查询条件没有和分区键关联上。在分区表上,WHERE 条件必须尽量包含分区键,否则每个分区都要扫描,性能不会比普通表好。
4.4 EXPLAIN ANALYZE 是执行计划的高级补充
MySQL 8.0.18 之后推出了 EXPLAIN ANALYZE。它不是传统 EXPLAIN 那种估算计划,而是真实执行 SQL,并输出每一步的实际耗时、实际行数、循环次数。用法很简单:
sql复制EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 10086;
输出结果中会有 actual_time、actual_rows、loops 这样的关键信息。它能告诉你优化器估算的 rows 与实际扫描行数差异有多大,也能看到每个节点真实的执行耗时。这是排查慢 SQL 时非常实用的工具,尤其当 EXPLAIN 的估算值和真实执行相差很大时,ANALYZE 的结果基本就是标准答案。
不过要注意,EXPLAIN ANALYZE 会真实执行 SQL,所以写操作要谨慎,一般只用于只读查询的调优。更新或删除语句如果真要分析,建议先包一层事务并回滚,避免影响线上数据。
5. 一条慢 SQL 的完整排查过程(从 EXPLAIN 到索引优化)
只懂语法没用,得走一遍真实排查链路。这里分享一个典型场景:订单列表页越来越慢,从点击到展示要 2 秒以上。通过 EXPLAIN 定位并解决的过程,非常有代表性。
5.1 第一步:拿到慢 SQL 并看执行计划
页面按“当前用户 + 订单状态 + 创建时间倒序 + 分页”查询,核心 SQL 大致是:
sql复制SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10086
AND status IN (0, 1)
ORDER BY created_at DESC
LIMIT 10;
先跑 EXPLAIN:
sql复制EXPLAIN SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10086
AND status IN (0, 1)
ORDER BY created_at DESC
LIMIT 10;
初步结果里,key 是 idx_user_id,type 是 ref,rows 估算 3000。看起来走了索引,但 Extra 里赫然出现 Using filesort。这就意味着:定位到该用户 3000 行订单后,MySQL 要额外做一次排序才能返回最新的 10 条。
5.2 第二步:分析 filesort 的根源并设计联合索引
问题的本质是 user_id 和 created_at 两个条件分别在两棵索引树上。idx_user_id 只解决定位问题,不解决排序问题。
根据最左前缀原则,建立联合索引:
sql复制ALTER TABLE orders ADD KEY idx_user_status_created (user_id, status, created_at);
这个索引的思考顺序是:等值条件的 user_id 放最前面;status 是 IN 列表,也可以作为等值匹配的一部分;created_at 放在最后,既做排序字段,又在联合索引内有序。这样执行计划大概率会走到新索引,Extra 变为 Using index condition 或更理想的 Using index。
5.3 第三步:验证优化效果
再次执行 EXPLAIN:
sql复制EXPLAIN SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10086
AND status IN (0, 1)
ORDER BY created_at DESC
LIMIT 10;
此时 key 变成 idx_user_status_created,Extra 不再显示 Using filesort,rows 估算也降到了几十行。这就是索引设计带来的直观变化。
需要说明的是,如果普通索引无法覆盖 SELECT 里的字段,Extra 里回表仍然必要。这个场景里 SELECT 包含了 order_no、amount 等字段,不在联合索引里,所以回表无法避免。如果要彻底免回表,可以把查询改为覆盖索引的字段组合,但实际业务中不建议把所有字段都塞进索引,索引列过多会带来写放大和存储成本。
5.4 第四步:从执行计划到统计信息
优化完成后还有一个容易被忽略的点:执行计划好不好,还取决于表和索引的统计信息是否准确。InnoDB 的统计信息是通过采样估算的,如果数据频繁增删改,统计信息可能滞后。此时可以先用 ANALYZE TABLE orders; 更新统计信息,再重看 EXPLAIN。
如果一条 SQL 明明有索引却不用,有时是因为统计信息认为回表成本太高,选择全表扫描。这时候与其强行用 FORCE INDEX,不如先更新统计信息,或者优化索引结构。
5.5 常见坑:为什么加了索引却没走
结合过往经验,我把常见原因列成一张表:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| key 为 NULL,type 为 ALL | 隐式类型转换、列上有函数、字符集不一致 | 统一字段类型、避免在索引列上使用函数 |
| 有索引但 rows 很大 | 数据分布导致优化器认为回表成本高 | 使用覆盖索引或调整索引字段顺序 |
| Extra 出现 Using temporary | GROUP BY 或 DISTINCT 字段没有对应索引 | 为分组字段设计联合索引 |
| Extra 出现 Using filesort | ORDER BY 字段缺少引导列 | 将排序字段纳入联合索引末尾 |
| JOIN 里被驱动表 ALL | 被驱动表关联字段无索引 | 在关联字段上增加索引 |
| 前缀索引长度不足 | key_len 偏短,导致过滤后仍需回表 | 适当增加索引前缀长度或改用全列索引 |
这张表基本覆盖了我日常排查中遇到的大部分情况。
6. 我常用的 EXPLAIN 速查心得与扩展思路
文章最后这部分,分享一些实用的判断习惯和索引设计方法论,不是教科书结论,都是实际业务里反复验证过的做法。
6.1 十分钟快速看执行计划的口诀
拿到一条慢 SQL,我习惯按这个顺序看 EXPLAIN:
- 先看 type,如果能到 range 以上,说明基本定位方式合理;如果 ALL,优先排查条件列有没有可用索引。
- 再看 key,确认真实使用的索引与 possible_keys 的差异。
- 看 rows 和 filtered,估算结果集体量。
- 最后看 Extra,重点找 Using filesort、Using temporary、Using join buffer。
这套流程适合所有新接手慢 SQL 的情况。先看骨架,再看细节,避免一上来就被各种索引名称绕晕。
6.2 索引设计上的两个受用原则
第一个原则是“等值在前,范围/排序在后”。联合索引设计时,把 WHERE 中最常出现的等值条件列放在前面,把范围查询列、ORDER BY 列放在后面,这是充分利用 B+ 树有序性的关键。
第二个原则是“覆盖优先,但不过度”。能用覆盖索引减少回表当然最好,但索引本质是拿存储换查询性能。写入频繁的表尤其要考虑索引数量,每多一个索引,INSERT/UPDATE 的代价都会上升。一般情况下,单表索引数量控制在 5 个以内是比较稳妥的。
6.3 从 EXPLAIN 结果反推统计信息变化
遇到过很多次:生产环境一条 SQL 昨天走索引,今天突然全表扫描。EXPLAIN 结果变化很大,但 SQL 没变。这时候不要只盯着索引,先检查统计信息和数据分布,比如字段值的区分度是否下降、数据的基数是否改变。最典型的情况是 status 字段大量行变成同一个值,优化器认为走索引代价高于全表扫描。
如果确实需要强制走某条索引,可以使用 FORCE INDEX,但要谨慎。这属于以人力对抗优化器的兜底手段,且一旦数据分布再次变化,强制索引可能反而更慢。
6.4 结合 optimizer trace 看优化器决策
当 EXPLAIN 无法解释为什么选这个索引时,可以开启优化器追踪:
sql复制SET optimizer_trace = "enabled=on";
SELECT ...;
SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE;
optimizer trace 会记录优化器比较不同成本、选择索引的完整过程,比 EXPLAIN 更细。日常排查中,只有遇到极端场景才会用到它,但知道这个工具能节省不少排查时间。
6.5 不要太依赖 EXPLAIN 的单次结果
最后想强调一点:EXPLAIN 输出的是基于当前统计信息的最优推测,不代表实际执行。数据更新、统计信息变更、索引调整都会改变执行计划。真正要想让 SQL 稳定高效,核心是把索引和查询条件设计好,而不是事后反复调优。
另外,MySQL 8.0 的 EXPLAIN 对 JSON 格式的支持也很好用,可以输出更结构化的信息:
sql复制EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 10086;
JSON 格式里能看到每一层的 cost_info,包括读取成本、评估成本等,对理解优化器如何算账很有帮助。分析复杂 SQL 时,比传统表格更直观。
从我自己的实践来说,EXPLAIN 是一个值得花时间反复研究的功能。它看起来只是一条命令,但背后隐藏的索引原理、成本估算和执行策略,值得每个做数据库开发的人深入琢磨。
7. 结尾:一个不得不说的细节
最后补一个实际经验:EXPLAIN 是分析工具,不是万能工具。它只告诉你执行计划,不代表线上真实运行。同样的 SQL,不同 MySQL 版本、不同数据量、不同硬件环境下表现可能完全不同。真正有效的调优,永远是“合理索引 + 规范写法 + 持续监控”三者配合。
如果刚开始接触,建议拿自己负责的业务表,从最基础的等值查询开始,反复执行 EXPLAIN,观察 type、key、rows 的变化。多查几次,索引设计的感觉就出来了。
