MySQL EXPLAIN 实战:读懂执行计划优化慢查询

慢查询是线上接口超时、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     : 全表扫描(最慢,线上大表必须消灭)

经验法则:联表查询至少保证被驱动表是 refeq_ref;单表过滤若看到 ALLrows 上万,基本就是慢查询嫌疑犯。

三、Extra 里的危险信号

Extra 值含义处理
Using filesort无法用索引排序,需额外排序建联合索引把 ORDER BY 列纳入
Using temporary用了临时表(GROUP BY/DISTINCT)索引覆盖分组列,避免内存落盘
Using where回表后再过滤一般可接受,配合覆盖索引消除
Using index覆盖索引,无需回表最理想,保留

Using filesortUsing 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,但 statuscreated_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 filesortORDER BY 是否纳入联合索引
Using temporaryGROUP BY/DISTINCT 列能否被索引覆盖
rows 估算偏离实际统计信息过期,ANALYZE TABLE
线上改大表索引卡死用在线 DDL / gh-ost,且注意 binlog 订阅 的延迟

把 EXPLAIN 当成慢查询的“CT 机”:每次优化前先拍一张计划,对照上面的清单逐项消项,比盲目加索引靠谱得多。

上一篇 Starship 实战:用 Rust 打造跨 Shell 高颜值终端提示符
下一篇 浏览器存储选型:IndexedDB与localStorage