读懂 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、key
1.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,用不上,只能全表扫描。
type 不是 ALL 也不代表没问题:index 同样扫描整棵索引,ref 在数据倾斜时也可能读几十万行,所以要和 rows 一起看。
1.2 rows 与 filtered:预计读多少、留下多少
rows:存储引擎预计要读取的行数。filtered:读出来的行中,预计有百分之多少满足剩余条件。
两者相乘约等于这一步输出的行数。上面第二条查询 rows=99841、filtered=33.33,意思是「读近 10 万行,留下约三分之一」。读得多、留得少是最典型的浪费。rows 是基于统计信息的估算,偏差可能达到几十倍,第三节用 EXPLAIN ANALYZE 确认实际值。
1.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。
possible_keys 只是候选索引,key 才是优化器实际选择的那一个。
二、Extra:藏在最后一列的额外代价
| 值 | 含义 | 需要处理吗 |
|---|---|---|
Using index | 覆盖索引,所需列都在索引里,不回表 | 好事 |
Using index condition | 索引条件下推(ICP),在存储引擎层先用索引过滤 | 好事 |
Using where | Server 层还要再过滤 | 看 rows 与 filtered |
Using filesort | 需要额外排序,数据量大时会用磁盘 | 大结果集时需要处理 |
Using temporary | 需要临时表,常见于 GROUP BY、DISTINCT | 大结果集时需要处理 |
Backward index scan | 反向扫描索引来满足 DESC 排序 | 好事,避免了排序 |
2.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 * 改成只取需要的列,是成本最低的优化之一。
2.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 就会变成瓶颈。它表示需要一次额外的排序,不代表用了磁盘文件:数据量小时排序在内存中完成,超过 sort_buffer_size 才落盘。
三、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 上使用。
四、四种常见的索引失效
加了索引不等于能加速。条件写法不对、最左列不匹配、统计信息不准时,优化器根本不会用它:
4.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)))),但改写成范围条件通常更通用。
4.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。
4.3 联合索引缺少最左列
见第一节:WHERE created_at >= ... 用不上 (customer_id, created_at)。联合索引按第一列排序,第一列相同时才按第二列排序,跳过第一列就没有顺序可用。
4.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,对比实际行数和耗时。
表结构与索引设计的整体思路,见 数据库设计与 SQL 调优。
配套实验
- codesphere-labs/storage/mysql-explain-plans:确定性造数的 10 万行订单表,五个索引阶段、24 条查询的完整
EXPLAIN、JSON 计划、EXPLAIN ANALYZE、Handler 与 ICP 计数(验证记录)
参考资料