读懂 Explain:从执行计划找到慢查询的真正原因
EXPLAIN的每一列都有定义,但逐列背下来并不能帮你优化 SQL。真正有用的是一套读法:先看访问类型和扫描行数,再看Extra里的额外代价,最后用EXPLAIN ANALYZE拿实际数据验证。
本文所有输出都来自同一张确定性生成的 10 万行订单表,在 MySQL 8.4.11 上实测,脚本与原始输出见文末配套实验:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL,
status VARCHAR(16) NOT NULL, -- CREATED / PAID / SHIPPED / CLOSED 各约 1/4
amount DECIMAL(10,2) NOT NULL,
phone VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_customer_created (customer_id, created_at),
KEY idx_phone (phone)
);
-- 10 万行,5000 个客户,每个客户 20 单,订单日期分布在 240 天内一、先说结论
- 先看
type和rows。type说明怎么找数据,rows说明预计要读多少行。ALL配上几十行无所谓,ref配上几十万行才是问题。 - 再看
Extra。Using filesort、Using temporary表示额外的排序和临时表;Using index表示覆盖索引,不需要回表。 key_len能看出联合索引用了几列。 它是判断「最左前缀到底命中了几列」最直接的依据。rows只是估算。 MySQL 8.0.18 起可以用EXPLAIN ANALYZE真正执行一次,拿到每一步的实际行数和耗时;估算与实际差距大时,问题往往出在统计信息或条件写法上。- 索引失效的常见原因很具体:在索引列上套函数、字符串列和数字比较、联合索引缺少最左列、排序字段不在索引里。
二、三列定方向:type、rows、key
2.1 type:怎么找数据
还有 system(表只有一行)和 NULL(优化阶段就得出结果,不需要访问表)两种特殊情况,实际业务中较少关注。
实测对比:同一个客户的订单,按客户查和按日期查:
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- type=ref key=idx_customer_created key_len=4 rows=20
EXPLAIN SELECT * FROM orders WHERE created_at >= '2026-06-01';
-- type=ALL key=NULL rows=99841 filtered=33.33第二条没有以 created_at 开头的索引,idx_customer_created 的第一列是 customer_id,用不上,只能全表扫描。
2.2 rows 与 filtered:预计读多少、留下多少
rows:存储引擎预计要读取的行数。filtered:读出来的行中,预计有百分之多少满足剩余条件。
两者相乘约等于这一步输出的行数。上面第二条查询 rows=99841、filtered=33.33,意思是「读近 10 万行,留下约三分之一」。读得多、留得少是最典型的浪费。
2.3 key 与 key_len:联合索引用了几列
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND created_at >= '2026-06-01';
-- type=range key=idx_customer_created key_len=9 rows=8 Extra=Using index conditionkey_len=9 等于 INT(4 字节)加 DATETIME(5 字节),说明联合索引的两列都用上了;只按 customer_id 查询时是 4。
常见类型的长度可以这样估算:INT 4、BIGINT 8、DATETIME 5、VARCHAR(n) 在 utf8mb4 下为 n × 4 + 2,列允许 NULL 时再加 1。实测 idx_phone 的 key_len 是 82,正好是 VARCHAR(20) 的 20 × 4 + 2。
三、Extra:藏在最后一列的额外代价
| 值 | 含义 | 需要处理吗 |
|---|---|---|
Using index | 覆盖索引,所需列都在索引里,不回表 | 好事 |
Using index condition | 索引条件下推(ICP),在存储引擎层先用索引过滤 | 好事 |
Using where | Server 层还要再过滤 | 看 rows 与 filtered |
Using filesort | 需要额外排序,数据量大时会用磁盘 | 大结果集时需要处理 |
Using temporary | 需要临时表,常见于 GROUP BY、DISTINCT | 大结果集时需要处理 |
Backward index scan | 反向扫描索引来满足 DESC 排序 | 好事,避免了排序 |
3.1 覆盖索引:只查索引里有的列
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42 AND created_at >= '2026-06-01';
-- type=range key_len=9 Extra=Using where; Using indexid 是主键,二级索引的叶子节点本来就存着它,所以这条查询完全不需要回到聚簇索引。把 SELECT * 改成只取需要的列,是成本最低的优化之一。
3.2 排序字段在不在索引里
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY amount DESC LIMIT 10;
-- type=ref rows=20 Extra=Using filesort
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 10;
-- type=ref rows=20 Extra=Backward index scan同一个客户下,索引里的 created_at 已经有序,按它排序可以直接反向读索引;amount 不在索引里,只能先取出 20 行再排序。20 行的排序无所谓,但如果某个大客户有几十万单,Using filesort 就会变成瓶颈。
四、EXPLAIN ANALYZE:用实际数据验证估算
EXPLAIN 只给估算,EXPLAIN ANALYZE 会真正执行查询,并输出每一步的实际耗时和行数。它输出的是树形结构,读法和普通 EXPLAIN 的表格不同:
对应的原始输出(节选):
-> Limit: 5 row(s) (actual time=60.8..60.8 rows=5 loops=1)
-> Sort: `COUNT(*)` DESC, limit input to 5 row(s) per chunk (actual time=60.8..60.8 rows=5 loops=1)
-> Stream results (cost=11087 rows=4972) (actual time=0.911..60.4 rows=5000 loops=1)
-> Group aggregate: count(0) (cost=11087 rows=4972) (actual time=0.909..60 rows=5000 loops=1)
-> Filter: (orders.`status` = 'PAID') (cost=10088 rows=9984) (actual time=0.905..58.9 rows=25000 loops=1)
-> Index scan on orders using idx_customer_created (cost=10088 rows=99841) (actual time=0.879..53.8 rows=100000 loops=1)这是「统计已支付订单最多的 5 个客户」:
SELECT customer_id, COUNT(*) FROM orders WHERE status = 'PAID'
GROUP BY customer_id ORDER BY COUNT(*) DESC LIMIT 5;读法:
- 找实际行数最大的一层:最内层扫描了整棵
idx_customer_created索引,读了 10 万行。 - 看过滤比例:
Filter之后只剩 2.5 万行,四分之三的读取被浪费了。 - 看时间落在哪:
actual time=a..b中,a是返回第一行的时间,b是返回全部行的时间,单位毫秒。总耗时 60.8ms,其中 53.8ms 在最内层扫描。
加一个以 status 开头的联合索引后:
ALTER TABLE orders ADD KEY idx_status_customer (status, customer_id);-> Covering index lookup on orders using idx_status_customer (status='PAID')
(cost=5172 rows=47252) (actual time=0.153..3.35 rows=25000 loops=1)最内层变成只读 2.5 万行的覆盖索引查找,7 次采样的中位耗时从 60.4ms 降到 5.23ms。
也要注意 EXPLAIN ANALYZE 会真正执行语句:SELECT 会完整跑一遍,UPDATE、DELETE 会真的修改数据。对写语句和很慢的查询,应在隔离的测试环境或 replica 上使用。
五、四种常见的索引失效
5.1 在索引列上套函数
EXPLAIN ANALYZE SELECT * FROM orders WHERE DATE(created_at) = '2026-06-01';
-- Table scan on orders (actual rows=100000),即使 created_at 上有单列索引索引里存的是 created_at 的原值,DATE(created_at) 的结果不在索引中。改写成范围条件:
SELECT * FROM orders WHERE created_at >= '2026-06-01' AND created_at < '2026-06-02';这里有一个实测中容易忽略的细节:只改写条件还不够。在只有 idx_customer_created 的情况下,改写后仍然是全表扫描,因为这个索引的第一列是 customer_id。补上 created_at 的单列索引后,才变成:
-> Index range scan on orders using idx_created over ('2026-06-01 00:00:00' <= created_at < '2026-06-02 00:00:00')
(cost=187 rows=415) (actual time=0.0924..0.391 rows=415 loops=1)MySQL 8.0.13 起也支持函数索引(如 KEY ((DATE(created_at)))),但改写成范围条件通常更通用。
5.2 字符串列与数字比较
EXPLAIN SELECT * FROM orders WHERE phone = 13800000042;
-- type=ALL possible_keys=idx_phone key=NULL rows=99841
EXPLAIN SELECT * FROM orders WHERE phone = '13800000042';
-- type=ref key=idx_phone key_len=82 rows=1phone 是 VARCHAR,与数字比较时,MySQL 会把每一行的字符串转换成数字再比较,索引就用不上了。possible_keys 里有 idx_phone、key 却是 NULL,就是这种问题的典型信号。在 Java 里,这通常是 MyBatis 参数类型写成了 Long。
5.3 联合索引缺少最左列
见第二节:WHERE created_at >= ... 用不上 (customer_id, created_at)。联合索引按第一列排序,第一列相同时才按第二列排序,跳过第一列就没有顺序可用。
5.4 统计信息不准导致选错计划
实测中有两处估算和实际差距明显:
| 查询 | 估算行数 | 实际行数 |
|---|---|---|
created_at 一天范围,无合适索引时的过滤结果 | 约 1.1 万(多次运行在 10,816—11,100 之间) | 415 |
status = 'PAID' 覆盖索引查找 | 47,252 | 25,000 |
估算来自统计信息的抽样,同一份数据重新 ANALYZE TABLE 后也会变化。估算偏差大时,优化器可能选错索引或连接顺序。可以先执行 ANALYZE TABLE 更新统计信息;对数据分布很不均匀的列,MySQL 8.0 起还可以用直方图(ANALYZE TABLE ... UPDATE HISTOGRAM ON col)。不建议一上来就用 FORCE INDEX,数据分布变化后它会成为新的隐患。
六、排查慢查询的顺序
- 拿到真实 SQL 和参数:从慢查询日志或 APM 中取,不要凭代码猜,参数类型不同计划可能完全不同。
EXPLAIN看方向:type、key、key_len、rows、Extra。EXPLAIN ANALYZE看事实(隔离的测试环境或 replica):实际行数最大、耗时最长的那一层是哪一层。- 按原因处理:改写条件、补联合索引、改成覆盖索引、更新统计信息。
- 回归验证:同样的参数再跑一次
EXPLAIN ANALYZE,对比实际行数和耗时。
七、常见误区
- 「
type不是ALL就没问题」:index也是扫描整棵索引;ref在数据倾斜时也可能读几十万行。 - 「
rows就是实际读取行数」:它是估算,偏差可能达到几十倍,要用EXPLAIN ANALYZE确认。 - 「
possible_keys里有索引就会用」:它只是候选,key才是实际选择。 - 「
Using filesort表示用了磁盘文件」:它表示需要额外排序,数据量小时在内存中完成。 - 「加了索引就能加速」:条件写法不对、最左列不匹配时,索引根本不会被使用。
小结
读执行计划的顺序是:type 和 rows 判断读了多少,key_len 判断联合索引用了几列,Extra 判断有没有额外的排序和临时表,最后用 EXPLAIN ANALYZE 找到实际行数最大的那一层。大多数慢查询的原因都很朴素:条件写法让索引失效,或者索引的列顺序不匹配查询。
表结构与索引设计的整体思路,见 数据库设计与 SQL 调优。
配套实验
- codesphere-labs/storage/mysql-explain-plans:确定性造数的 10 万行订单表,五个索引阶段、24 条查询的完整
EXPLAIN、JSON 计划、EXPLAIN ANALYZE、Handler 与 ICP 计数(验证记录)
参考资料