EXPLAIN 执行计划详解(type/Extra/rows 关键字段)
为什么 EXPLAIN 是 DBA 的第一道防线
线上 MySQL 慢查询,80% 的场景不需要打开 profiling,不需要抓堆栈,也不需要看 performance_schema。一条 EXPLAIN 就够了。
EXPLAIN 是 MySQL 提供的执行计划查看工具,它告诉你优化器打算怎么执行你的 SQL。你不需要猜,不需要"我觉得这里应该走索引"——EXPLAIN 直接把决策摆在你面前。
但问题是,很多人 EXPLAIN 看完了,只知道有没有走索引,却看不懂 type 和 Extra 的深层含义。这篇文章把 EXPLAIN 输出中最关键的三个字段彻底讲清楚。
EXPLAIN 输出长什么样
先看一个例子:
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10\G输出:
id: 1
select_type: SIMPLE
table: orders
type: ref
possible_keys: idx_user_id
key: idx_user_id
key_len: 8
ref: const
rows: 156
Extra: Using index condition; Using filesort逐行解读:
- type=ref:等值匹配,通过索引找到所有匹配的行,还不错
- key=idx_user_id:实际用到的索引是
idx_user_id - rows=156:预估扫描 156 行(实际可能更多,但量级没问题)
- Extra="Using index condition; Using filesort":用到了索引下推(ICP)来减少回表,但
ORDER BY create_time走了文件排序——这是优化点
三个关键字段详解
1. type:访问类型,决定 SQL 效率的下限
type 从好到差的排序是:
system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALLconst / system
- 主键或唯一索引等值查询,最多返回一行
WHERE id = 1对主键的精准匹配- 这是 MySQL 最快的访问方式,没有之一
- system 是 const 的特例——表只有一行(系统表常见)
eq_ref
- 多表 JOIN 时,被驱动表使用主键或唯一索引等值匹配
SELECT * FROM t1 JOIN t2 ON t1.id = t2.id,t2 的访问类型就是 eq_ref- 对 JOIN 来说是最高效的
ref
- 普通索引的等值匹配(非唯一)
WHERE user_id = 123,user_id 有索引但不是唯一索引- 可能返回多行,但走索引,性能可接受
ref_or_null
- ref 的变体,额外还要查 IS NULL 的行
WHERE user_id = 123 OR user_id IS NULL- 比 ref 多一次 NULL 值的扫描,性能稍差
index_merge
- 优化器同时使用多个索引,合并结果集
- 常见于 OR 条件或多条件复合查询
- 底层走 Intersection(交集)、Union(并集)、Sort-Union 三种合并策略
- 坑:索引合并不一定是好事,有时候建联合索引比合并更高效
range
- 索引范围扫描:
>,<,BETWEEN,IN,LIKE 'abc%' - 比 ref 差,但仍在可接受范围
- 注意:
IN列表如果太长(比如 IN 里面 1000 个值),优化器可能退化为ALL
index
- 全索引扫描,比全表扫描好一点,但也是灾难
- 常见于
SELECT COUNT(*)或覆盖索引但无法做范围过滤的场景 - 意味着你遍历了整个索引树,虽然比全表扫描小,但数据量大了依然扛不住
ALL
- 全表扫描,看到这个就应该警铃大作
- 除了极少数情况(小表、数据量 < 100 行),必须优化
实战判断:看到 type=ALL 或 type=index,直接标记为慢 SQL,加入优化队列。type=ref 或 type=range 是可以接受的,但需要结合 rows 和 Extra 进一步判断。
2. Extra:额外信息,暴露了 SQL 的隐藏问题
Extra 字段是 EXPLAIN 中最容易被忽视但最有价值的信息。它告诉你 MySQL 在处理这条 SQL 时额外做了什么。
Using index(覆盖索引)
- 查询所需的字段全部在二级索引中,不需要回表
- 这是最好的 Extra,性能最好
- 出现条件:索引的叶子节点包含了查询的所有字段
Using index condition(索引下推 ICP)
- 用到了 MySQL 5.6 引入的 ICP 优化
- 把 WHERE 条件中可以利用索引的过滤条件下推到存储引擎层
- 注意:ICP 仍然需要回表,只是减少了回表的行数
- 面试高频考点:Using index 和 Using index condition 的区别
Using where
- 回表后,在 server 层又做了过滤
- 通常意味着索引筛选不够彻底,有大量数据回表后被过滤掉
- 如果能将过滤条件包含在索引中,可以升级为 Using index
Using filesort(文件排序)
- MySQL 需要额外排序,且无法通过索引排序
- 排序数据量小 → 在内存(sort_buffer)中排序
- 排序数据量大 → 使用磁盘临时文件,性能急剧下降
- 优化方向:让 ORDER BY 字段走索引排序
- 踩坑:sort_buffer_size 设太大也会有问题——每个会话分配一份,高并发下内存爆炸
Using temporary(临时表)
- MySQL 使用临时表来存储中间结果
- 常见于
GROUP BY、DISTINCT、子查询 - 如果临时表太大,会写入磁盘,性能极差
- 优化方向:给 GROUP BY 和 DISTINCT 的字段加索引,或者改写成 JOIN
Using index for group-by
- MySQL 利用索引的排序特性直接完成 GROUP BY,无需临时表
- 这是 GROUP BY 的最优方案
Using where; Using index
- 查询条件在索引中过滤,但查询字段也在索引中(覆盖索引)
- 不需要回表,不需要额外读取数据行
- 这是第二好的 Extra(仅次于纯 Using index)
Using index for skip scan
- MySQL 8.0.13+ 引入的 Skip Scan Range Access Method
- 适用于联合索引但跳过前导列的场景
- 比如索引
(a, b),查询WHERE b = 1,MySQL 遍历 a 的不同值,对每个 a 值做 b 的范围扫描 - 性能比全索引扫描好,但比正常的 range 差
3. rows:预估扫描行数
rows 是优化器估算的扫描行数。注意是估算,不是精确值。
rows 的应用场景:
- 比较同一个 SQL 加不同索引后的扫描行数变化
- 结合
type判断:type=ref但rows=100000,说明索引选择性差,扫描行数太多 - 如果
rows远大于实际返回行数(如 rows=100000,实际返回 10 行),说明索引过滤性不够
rows 的局限性:
- 不精确,统计信息可能过时(
ANALYZE TABLE可以刷新) - 不反映实际回表 IO 次数
- 在多表 JOIN 场景下,每层的 rows 会相乘,最终估算可能偏差很大
更准确的工具:MySQL 8.0.18+ 的 EXPLAIN ANALYZE 提供实际执行耗时和循环次数。
代码实战:用 EXPLAIN 分析一条慢 SQL
假设我们有一张订单表,数据量约 500 万行,有一条慢查询:
-- 慢 SQL
SELECT order_id, user_id, amount, status, create_time
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10;先看 EXPLAIN:
EXPLAIN SELECT order_id, user_id, amount, status, create_time
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10\G输出:
id: 1
select_type: SIMPLE
table: orders
type: ALL
possible_keys: idx_status
key: NULL
rows: 4832100
Extra: Using where; Using filesort问题诊断:
type=ALL:全表扫描,483 万行Extra=Using filesort:还要文件排序- 等于把整张表扫一遍再排序,肯定慢
优化方案:建联合索引 (status, create_time),让 WHERE 和 ORDER BY 都走索引
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);
EXPLAIN SELECT order_id, user_id, amount, status, create_time
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10\G优化后输出:
id: 1
select_type: SIMPLE
table: orders
type: ref
possible_keys: idx_status_time
key: idx_status_time
key_len: 2
ref: const
rows: 523000
Extra: Using index condition变化:
type=ALL→type=ref:不再全表扫描rows=4832100→rows=523000:扫描行数减少到 1/9Extra去掉了Using filesort:ORDER BY 走索引排序,无需额外排序
但 rows=523000 仍然偏高,因为 status=1 的记录占了 50 万行,而我们要的是 ORDER BY 后取前 10 条。这里还可以进一步优化:延迟关联。
EXPLAIN SELECT o.order_id, o.user_id, o.amount, o.status, o.create_time
FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 10
) AS tmp ON o.id = tmp.id\G子查询部分:
id: 1
select_type: PRIMARY
table: <derived2>
type: ALL
rows: 10
Extra: NULL子查询(id=2):
id: 2
select_type: DERIVED
table: orders
type: ref
key: idx_status_time
rows: 10
Extra: Using index核心原理:子查询用覆盖索引(Using index)只扫描 id 和排序字段,找到 top 10 的 id,再回表取出完整数据。回表次数从 523000 次降到 10 次,这才是真正的优化精髓。
Agent 场景类比:EXPLAIN 就像 LLM 的 Chain-of-Thought
如果你做过 AI Agent 的 prompt 调优,你一定遇到过 LLM 不按你的指令走,你不知道它中间在想什么。这时候你会用 Chain-of-Thought 让模型暴露推理过程。
EXPLAIN 就是数据库的 Chain-of-Thought。
- LLM 的 CoT 输出是"我要先理解问题,然后检索知识,最后生成回答"
- 数据库的 EXPLAIN 输出是"我要先走 idx_status_time 索引过滤 status=1 的记录,再用索引排序,然后回表取 10 行数据"
两者都让你从黑盒变成白盒。你不需要猜测,不需要等待执行完才知道结果——执行前你就知道它打算怎么干了。
面试官问你"MySQL 怎么优化一条慢 SQL",你回答"先 EXPLAIN,然后看 type 和 Extra 判断问题,再建索引"——这跟 Agent 调优时先看 CoT 再改 prompt 是一个思维模式。
高级实战:三表 JOIN 的性能分析
这是我在线上真实遇到过的场景。一个 Agent 平台的后台,需要查用户最近 30 天的 Agent 调用记录,涉及三张表:agents(Agent 定义)、sessions(会话)、invocations(调用记录)。
-- 慢 SQL,页面加载卡死
SELECT a.name, s.session_id, i.input_tokens, i.output_tokens, i.latency_ms, i.created_at
FROM agents a
JOIN sessions s ON a.agent_id = s.agent_id
JOIN invocations i ON s.session_id = i.session_id
WHERE a.owner_id = 10086
AND i.created_at >= '2026-06-23'
AND i.latency_ms > 5000
ORDER BY i.created_at DESC
LIMIT 50;EXPLAIN 输出:
id | select_type | table | type | possible_keys | key | rows | Extra
1 | SIMPLE | a | ref | idx_owner_id | idx_owner_id | 5 | Using index
1 | SIMPLE | s | ref | idx_agent_id | idx_agent_id | 120 | NULL
1 | SIMPLE | i | ALL | idx_session_id | NULL | 2500000 | Using where; Using filesort问题分析:
- a 表走索引,5 行,没问题
- s 表走索引,120 行,也还好
- i 表(invocations)不走索引,全表扫描 250 万行,还要
Using filesort排序 - 问题是优化器认为
idx_session_id对i.latency_ms > 5000和i.created_at排序没有帮助,所以选择了全表扫描
优化方案:给 invocations 建联合索引 (session_id, created_at, latency_ms, input_tokens, output_tokens),让 WHERE 和 ORDER BY 都走索引,且覆盖查询的所有字段。
ALTER TABLE invocations ADD INDEX idx_session_created_latency
(session_id, created_at, latency_ms, input_tokens, output_tokens);优化后 EXPLAIN:
id | select_type | table | type | possible_keys | key | rows | Extra
1 | SIMPLE | a | ref | idx_owner_id | idx_owner_id | 5 | Using index
1 | SIMPLE | s | ref | idx_agent_id | idx_agent_id | 120 | NULL
1 | SIMPLE | i | ref | idx_session_created_latency | idx_session_created_latency | 1800 | Using index condition; Using index变化:
- i 表从
type=ALL(250 万行)变成type=ref(1800 行) - 去掉了
Using filesort,排序走索引 Extra=Using index condition; Using index:覆盖索引,不需要回表- 查询从 3 秒降到 20 毫秒
踩坑实录:EXPLAIN 的 5 个常见误区
误区 1:rows 小就一定快
错。rows 是扫描行数预估,不是 IO 次数。type=index 配合 rows=100 走的是全索引扫描,可能比 type=ref 配合 rows=1000 更慢,因为索引扫描需要遍历整个索引树。
误区 2:用了索引就是好
错。type=index 也是用了索引,但它是全索引扫描。如果你看到 type=index 而 rows 很大,本质上跟全表扫描没区别。
误区 3:Extra 为空就是好
错。Extra 为空意味着没有额外信息,不代表没有性能问题。type=ALL 且 Extra 为空,说明全表扫描且没有额外操作——但全表扫描本身就是问题。
误区 4:EXPLAIN 说走索引,线上就一定走
错。EXPLAIN 是优化器基于当前统计信息的预估。线上的参数值、数据分布、并发情况都可能不同。possible_keys 里有索引但 key 为 NULL,说明优化器认为走索引不如全表扫描——可能是统计信息过时了,也可能是你的 SQL 写法让优化器"误判"。
误区 5:EXPLAIN ANALYZE 可以替代 EXPLAIN
对部分场景成立,但不完全。EXPLAIN ANALYZE 会实际执行 SQL,输出每一步的实际耗时和行数。它的优点是精确,缺点是:
- 会实际执行,写操作需要包裹在事务里回滚
- 大数据量下可能执行很久
- 不适合线上直接执行
EXPLAIN 与 EXPLAIN ANALYZE 对比(MySQL 8.0.18+)
| 特性 | EXPLAIN | EXPLAIN ANALYZE |
|---|---|---|
| 是否执行 SQL | 不执行 | 执行 |
| 输出内容 | 预估执行计划 | 实际执行计划 + 实际耗时 |
| 精确度 | 预估,可能偏差 | 精确 |
| 执行风险 | 无 | 会执行,写操作需回滚 |
| 适用场景 | 日常分析、上线前 | 生产问题定位、性能瓶颈确认 |
EXPLAIN ANALYZE 的输出示例:
-> Limit: 50 row(s) (actual time=0.234..0.312 rows=50 loops=1)
-> Sort: i.created_at DESC, limit input to 50 row(s) per chunk (actual time=0.233..0.311 rows=50 loops=1)
-> Nested loop inner join (actual time=0.021..0.283 rows=1800 loops=1)
-> Index lookup on a using idx_owner_id (actual time=0.011..0.015 rows=5 loops=1)
-> Nested loop inner join (actual time=0.008..0.050 rows=1800 loops=5)
-> Index lookup on s using idx_agent_id (actual time=0.003..0.008 rows=120 loops=5)
-> Index lookup on i using idx_session_created_latency (actual time=0.001..0.005 rows=1800 loops=600)每一行都有 actual time(实际耗时)、rows(实际行数)、loops(循环次数)。你一眼就能看出哪一步最耗时,而不是像 EXPLAIN 那样只能靠猜。
总结
| 字段 | 好 | 差 | 判断标准 |
|---|---|---|---|
| type | system/const/eq_ref/ref | ALL/index | 看见 ALL 必须优化 |
| Extra | Using index | Using filesort/Using temporary | 两个 using 是提速信号,两个 using 是减速警报 |
| rows | 小(< 1000) | 大(> 10000) | 结合 type 看,type 好但 rows 大说明索引选择性差 |
EXPLAIN 的核心用法:对比优化前后的执行计划,看 type 有没有提升、Extra 有没有出现 Using index、rows 有没有下降。不要只看绝对值,看变化趋势。
最后一条铁律:EXPLAIN 只是预估,线上慢 SQL 的最终判断标准是实际执行时间。分析完 EXPLAIN 后,用小数据量验证,分批上线,用 performance_schema 和慢查询日志做最终确认。