慢查询是线上接口超时、CPU 飙高乃至雪崩的头号导火索。很多团队一遇到慢 SQL 就急着加索引,却说不清优化器到底走了哪条路——而 EXPLAIN 执行计划 正是定位根因的第一把刀。本文以 MySQL 8.0 为例,讲清如何读执行计划、识别全表扫描与文件排序,并用一条真实案例把 800ms 的查询压到毫秒级。
一、先跑起来:EXPLAIN 的三种用法
最朴素的形式是直接在你那条 SELECT 前加 EXPLAIN:
-- 默认输出纵向表格,每行一个操作
EXPLAIN
SELECT id, user_id, amount
FROM orders
WHERE status = 'PAID' AND created_at >= '2026-09-01';
-- MySQL 8.0 推荐:树状格式,直接看出嵌套与成本
EXPLAIN FORMAT=TREE
SELECT id, user_id, amount FROM orders WHERE status = 'PAID';
-- 最详细:JSON,含 cost_info 与 used_columns
EXPLAIN FORMAT=JSON
SELECT id, user_id, amount FROM orders WHERE status = 'PAID';
FORMAT=TREE 会按“先被访问的表在上”排成树,能一眼看出驱动表是谁;FORMAT=JSON 则给出每一步的预估行数与代价,适合做量化对比。
二、核心列:type、key、rows、Extra
执行计划里最该盯的是四列:type(访问类型,越靠前越好)、key(实际用到的索引)、rows(优化器估算要扫描的行数)、Extra(额外信息,藏着危险信号)。type 从好到坏大致是:
const > eq_ref > ref > range > index > ALL
const : 主键/唯一索引等值匹配,最多一行(最快)
eq_ref : 联表时驱动表每行在另一表命中唯一索引
ref : 非唯一索引等值,可能命中多行(常见且可接受)
range : 索引范围扫描(BETWEEN / IN / >)
index : 全索引扫描(比 ALL 好,但要警惕)
ALL : 全表扫描(最慢,线上大表必须消灭)
经验法则:联表查询至少保证被驱动表是 ref 或 eq_ref;单表过滤若看到 ALL 且 rows 上万,基本就是慢查询嫌疑犯。
三、Extra 里的危险信号
| Extra 值 | 含义 | 处理 |
| Using filesort | 无法用索引排序,需额外排序 | 建联合索引把 ORDER BY 列纳入 |
| Using temporary | 用了临时表(GROUP BY/DISTINCT) | 索引覆盖分组列,避免内存落盘 |
| Using where | 回表后再过滤 | 一般可接受,配合覆盖索引消除 |
| Using index | 覆盖索引,无需回表 | 最理想,保留 |
Using filesort 和 Using temporary 是性能杀手,尤其当 rows 很大时,排序/分组会在内存放不下而落盘,延迟瞬间翻几倍。
四、真实案例:一条 800ms 的查询
4.1 原始 SQL 与执行计划
订单后台按用户+状态+时间区间拉列表,单表 2000 万行,平均 800ms:
EXPLAIN
SELECT id, user_id, status, amount, created_at
FROM orders
WHERE user_id = 1024
AND status = 'PAID'
AND created_at >= '2026-09-01'
ORDER BY created_at DESC
LIMIT 20;
-- 计划:type=ref, key=idx_user, rows=86000, Extra=Using filesort
4.2 根因:索引没覆盖排序,且存在隐式转换
虽然走了 idx_user,但 status 与 created_at 不在索引里,ORDER BY created_at 无法用索引,于是触发 Using filesort,要先扫 8.6 万行再排序。更隐蔽的是:status 字段是 VARCHAR 而代码传的是整数,发生隐式类型转换导致索引失效——这类坑在《MySQL 字符集与时区踩坑》里专门展开过。改法:传字符串、并补联合索引。
4.3 改写 + 覆盖索引
-- 联合索引把等值列、范围列、排序列、回表列一次性包圆
ALTER TABLE orders
ADD INDEX idx_user_status_time (user_id, status, created_at, id);
-- 参数用字符串,避免隐式转换
SELECT id, user_id, status, amount, created_at
FROM orders
WHERE user_id = 1024
AND status = 'PAID' -- 字符串,索引不失效
AND created_at >= '2026-09-01'
ORDER BY created_at DESC
LIMIT 20;
-- 计划:type=ref, key=idx_user_status_time, rows=20, Extra=Using index
改写后 rows 从 86000 降到 20,Extra 变成理想的 Using index(覆盖索引,零回表、零排序),耗时从 800ms 降到 3ms。关于覆盖索引与最左前缀的底层原理,参见《MySQL 索引底层原理》。
五、执行计划会骗人吗:统计信息失效
优化器的 rows 是估算值,依据是表的统计信息。大表频繁写入后统计信息可能严重失真,导致选错索引。怀疑时手动更新:
-- 重新采样统计信息(线上用默认即可,别加 FULL)
ANALYZE TABLE orders;
-- 看优化器到底怎么算的
SET optimizer_trace = "enabled=on";
SELECT id, user_id, amount FROM orders WHERE status = 'PAID';
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace = "enabled=off";
如果 OPTIMIZER_TRACE 显示它“以为”只扫 10 行其实扫了 8 万行,就是统计信息过期,ANALYZE TABLE 后往往自动选对索引。
六、线上看真实耗时:EXPLAIN ANALYZE
MySQL 8.0.18+ 支持 EXPLAIN ANALYZE,它会真实执行并返回实际行数与耗时,是估算与现实的桥梁:
EXPLAIN ANALYZE
SELECT id, user_id, amount FROM orders WHERE status = 'PAID';
-- 输出含 (actual time=0.012..0.045 rows=20) 等真实数据
-- 若估算 rows 与实际 rows 差距巨大,优先怀疑统计信息
七、MySQL 与 PostgreSQL 的差异
同样看执行计划,PG 用 EXPLAIN (ANALYZE, BUFFERS),输出的是顺序/循环嵌套/哈希连接与 shared read 缓冲区命中;MySQL 更关注 type 与 Extra。两者思路一致——都是“先估算、再验证”——但关键字不同。PG 侧的读法见《PostgreSQL 慢查询优化》。事务与锁引起的慢查询则另看《MySQL 死锁排查》。
八、索引下推 ICP:把过滤做在索引层
MySQL 5.6+ 默认开启索引条件下推(Index Condition Pushdown,ICP)。当查询有索引但 WHERE 里还有无法走索引的条件时,ICP 会允许存储引擎在遍历索引时就先过滤掉不符合的行,而不是把所有“索引匹配的行”都回表后再过滤。执行计划里表现为 Extra: Using index condition。
-- 联合索引 (city, last_name),但 first_name 不在索引里
SELECT * FROM users
WHERE city = 'Beijing' AND last_name LIKE 'Wang%' AND first_name LIKE '%ei';
-- 没有 ICP:先按 (city, last_name) 回表取全行,再判断 first_name
-- 有 ICP:在索引层就顺手判断 first_name,减少回表行数
-- Extra 出现 Using index condition 即表示 ICP 已生效
ICP 不是银弹——它只在“回表成本高”时收益明显。如果本就能用覆盖索引(Using index), ICP 反而多余。判断标准是看 rows 与实际回表行数的差距。
九、JOIN 优化:驱动表怎么选
多表关联时,优化器会估算哪个表当驱动表(外层循环)成本更低,原则是“小表驱动大表”。但统计信息失真时它可能选反,导致大表被反复扫描。确认执行计划里 type 至少 ref,否则可用 STRAIGHT_JOIN 强制连接顺序:
-- 强制 orders 作为驱动表(左表)先被访问
SELECT o.id, u.nickname
FROM orders STRAIGHT_JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID' AND u.city = 'Beijing';
-- 验证:EXPLAIN 中第一行的表即驱动表
-- 若驱动表选错,STRAIGHT_JOIN 可“扳回一城”
注意 STRAIGHT_JOIN 是手写覆盖优化器,仅在实测更优时才用,否则版本升级后可能反噬。优先还是把统计信息修准、索引建对。
十、深分页 LIMIT 优化
列表页常见的 LIMIT 100000, 20 看似只取 20 行,优化器却要先扫描并丢弃前 10 万行,越翻越慢。解法是用覆盖索引 + 延迟关联:先通过索引拿到要的 20 个主键,再回表取完整行。
-- 慢:直接深分页
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 快:先拿主键,再 JOIN 回表
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) t ON o.id = t.id;
子查询只走 id 主键索引(覆盖、无需回表),拿到 20 个主键后外层再精准回表,耗时从秒级降到毫秒级。这是“大偏移分页”的标准解法。
十一、避坑自查清单
| 现象 | 优先排查 |
| type=ALL | 是否缺索引 / 函数包裹列 / 隐式转换 |
| Using filesort | ORDER BY 是否纳入联合索引 |
| Using temporary | GROUP BY/DISTINCT 列能否被索引覆盖 |
| rows 估算偏离实际 | 统计信息过期,ANALYZE TABLE |
| 线上改大表索引卡死 | 用在线 DDL / gh-ost,且注意 binlog 订阅 的延迟 |
把 EXPLAIN 当成慢查询的“CT 机”:每次优化前先拍一张计划,对照上面的清单逐项消项,比盲目加索引靠谱得多。




