Learn
MySQL/14-explain

执行计划 EXPLAIN

前两章你已经知道索引怎么工作、该怎么建,但「SQL 到底有没有走索引、走得好不好」不能靠猜——EXPLAIN 就是优化器交出的答卷。看懂它,慢 SQL 优化就从玄学变成工程。

1. 基本用法

几行数据的表优化器一律选全表扫描,所以先把 orders 灌大一点再看计划:

EXPLAIN 基本用法
-- 造数:把订单表放大到约 700 行
INSERT INTO orders (order_no, user_id, status, total_amount, created_at)
SELECT CONCAT(o.order_no, '-', d1.n, d2.n),
       o.user_id, o.status, o.total_amount, o.created_at
FROM orders o,
     (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
      UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d1,
     (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
      UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d2;
ANALYZE TABLE orders;
 
EXPLAIN SELECT o.order_no, u.username
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.user_id = 1;

每行代表访问一张表(或物化结果),从上往下就是访问顺序(id 相同时)。上例中 users 因为按主键常量定位是 const,orders 走 idx_user_created 的 ref。

2. 核心列逐个讲

列含义
id子查询/UNION 的层级编号,越大越先执行;相同则自上而下
select_typeSIMPLE / PRIMARY / SUBQUERY / DERIVED(派生表)/ UNION 等
table访问的表(或 derived2 这类物化表)
type访问类型,最重要的一列,见下节
possible_keys候选索引
key实际选用的索引,NULL 即没走索引
key_len用到的索引字节数——判断联合索引用了几列的关键
ref与索引比较的对象(常量 const、另一表的列)
rows预估扫描行数(基于统计信息,不是精确值)
filtered经过 WHERE 后剩余行数百分比估算
Extra附加信息,藏着最多的优化线索

key_len 的用法:索引 (user_id BIGINT, created_at DATETIME) 全用上是 8 + 5 = 13;如果 key_len 只有 8,说明只用到了 user_id——最左前缀断了或范围截断了。这是判断「联合索引到底吃了几列」的硬指标。

3. type:访问类型等级表

从好到差:

type含义例子
system/const主键或唯一索引的常量查询,最多一行WHERE id = 1
eq_refJOIN 时被驱动表按主键/唯一索引匹配,每行最多配一行JOIN u ON u.id = o.user_id
ref普通(非唯一)索引等值查询WHERE user_id = 1
range索引范围扫描WHERE id > 100、IN、BETWEEN
index扫整棵索引树(常配合覆盖索引)无条件但只取索引列
ALL全表扫描没索引可用

经验线:核心业务查询至少要到 range,最好 ref 以上。看到 ALL 先检查是不是缺索引或索引失效;但小表 ALL 无所谓,别教条。

把五个等级挨个跑一遍,对照 type 与 key_len:

type 等级实测
-- const:主键常量
EXPLAIN SELECT * FROM orders WHERE id = 1;
 
-- eq_ref:被驱动表按主键匹配
EXPLAIN SELECT o.order_no, u.username FROM orders o JOIN users u ON u.id = o.user_id;
 
-- ref:普通索引等值,key_len=8 说明只吃了 user_id 一列
EXPLAIN SELECT * FROM orders WHERE user_id = 1;
 
-- range:索引范围
EXPLAIN SELECT * FROM orders WHERE user_id IN (1, 2, 3);
 
-- ALL:没有索引可用
EXPLAIN SELECT * FROM orders WHERE total_amount > 100;

4. Extra 关键字解读

Extra含义态度
Using index覆盖索引,无回表很好
Using index condition索引下推 ICP 生效好
Using whereServer 层过滤(引擎返回后再筛)中性,结合 rows 看
Using filesort需要额外排序(内存或磁盘)大结果集时要优化
Using temporary用了临时表(GROUP BY/DISTINCT 常见)大数据量时要优化
Using join buffer (hash join)被驱动表无索引,走 Hash Join检查连接列索引

Using filesort + Using temporary 同时出现的 GROUP BY/ORDER BY 大查询,通常就是慢查询榜单的常客——解法多半是让联合索引同时满足过滤和排序(第 13 章)。

Extra 关键字与索引提示
-- Using index:三列全在 idx_user_created 里,不回表
EXPLAIN SELECT id, user_id, created_at FROM orders WHERE user_id = 1;
 
-- Using filesort:排序列没被索引的有序性覆盖
EXPLAIN SELECT id, total_amount FROM orders ORDER BY total_amount DESC LIMIT 10;
 
-- Using temporary:GROUP BY 一个无索引列
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
 
-- 索引提示:强制走 / 禁止走 idx_user_created,对比 key 与 rows
EXPLAIN SELECT * FROM orders FORCE INDEX (idx_user_created) WHERE user_id = 1;
EXPLAIN SELECT * FROM orders IGNORE INDEX (idx_user_created) WHERE user_id = 1;

5. EXPLAIN ANALYZE(8.0.18+):看真实执行

EXPLAIN 是估算,EXPLAIN ANALYZE 真的执行一遍并报告实际耗时与行数:

EXPLAIN ANALYZE
SELECT user_id, COUNT(*) FROM orders
WHERE created_at >= '2026-07-01'
GROUP BY user_id;
-> Table scan on <temporary>  (actual time=0.61..0.62 rows=3 loops=1)
    -> Aggregate using temporary table  (actual time=0.60..0.60 rows=3 loops=1)
        -> Filter: (orders.created_at >= TIMESTAMP'2026-07-01 00:00:00')
            (cost=0.65 rows=1.33) (actual time=0.05..0.07 rows=4 loops=1)
            -> Table scan on orders  (cost=0.65 rows=4) (actual time=0.04..0.05 rows=4 loops=1)

读法:树形结构自内向外执行;每个节点对比 (cost=估算 rows=估算) 与 (actual time=首行..全部 rows=实际 loops=次数)。估算 rows 与实际 rows 差几个数量级,就是统计信息过期或优化器误判的信号:

ANALYZE TABLE orders;   -- 重新收集统计信息,常能纠正误判

注意:EXPLAIN ANALYZE 会真实执行语句(8.0 只支持 SELECT),对大查询慎用于生产高峰。

6. 优化器提示(Hints)

统计信息修正后优化器仍然选错时,可以下「指令」:

-- 强制/禁止使用某索引
SELECT * FROM orders FORCE INDEX (idx_user_created) WHERE user_id = 1;
SELECT * FROM orders IGNORE INDEX (idx_user_created) WHERE user_id = 1;
 
-- 8.0 风格的注释 Hint(推荐,粒度更细)
SELECT /*+ INDEX(o idx_user_created) */ * FROM orders o WHERE o.user_id = 1;
SELECT /*+ NO_INDEX(o idx_user_created) */ * FROM orders o WHERE o.user_id = 1;
 
-- JOIN 顺序提示
SELECT /*+ JOIN_ORDER(o, u) */ * FROM orders o JOIN users u ON u.id = o.user_id;
 
-- 单条语句的资源护栏:最多执行 3000 毫秒
SELECT /*+ MAX_EXECUTION_TIME(3000) */ * FROM orders;
⚠️Hint 是最后手段,不是常规武器

硬编码的 FORCE INDEX 会随数据分布变化而过时——今天的最优索引可能是明年的性能炸弹,而且表删了该索引 SQL 直接报错。优先顺序永远是:改写 SQL → 调整索引 → ANALYZE TABLE → 最后才是 Hint,并留注释说明原因。

7. 实战分析套路

拿到一条慢 SQL 的固定流程:

1. EXPLAIN 看全貌
   ├─ type 是 ALL? → 缺索引 or 索引失效(函数/隐式转换/最左前缀)
   ├─ key 用的索引对吗?key_len 吃满了吗?
   ├─ rows 估算量级合理吗?
   └─ Extra 有 filesort / temporary?
2. EXPLAIN ANALYZE 对照实际行数与耗时,定位最耗时的节点
3. 措施:建/改联合索引(等值+范围+排序一体设计)、改写 SQL、ANALYZE TABLE
4. 再次 EXPLAIN 验证,对比前后 rows 与耗时
💡EXPLAIN FORMAT=TREE 和 FORMAT=JSON

EXPLAIN FORMAT=TREE 输出与 ANALYZE 相同的树形结构(不执行),比表格更容易看清 JOIN 顺序和 Hash Join;FORMAT=JSON 则包含成本数字 cost_info,适合深挖优化器为什么这么选。

小结

  • EXPLAIN 五个关键读数:type(访问等级)、key(真用的索引)、key_len(联合索引吃了几列)、rows(估算量)、Extra(filesort/temporary/覆盖索引)
  • type 至少 range,核心查询争取 ref/eq_ref;ALL + 大 rows = 优先处理对象
  • EXPLAIN ANALYZE 给真实耗时,估算与实际差距大就 ANALYZE TABLE
  • Hint 能救急,但优先改 SQL 和索引
  • 优化闭环:EXPLAIN → 改 → 再 EXPLAIN 对比
🎯练习
  1. 对 SELECT * FROM orders WHERE DATE(created_at) = '2026-07-01' 执行 EXPLAIN,确认 type=ALL;改写成范围条件后再看,记录 type 与 rows 的变化。
  2. 构造一条 ORDER BY 引发 Using filesort 的查询,通过加联合索引让 filesort 消失。
  3. 用 EXPLAIN FORMAT=TREE 观察一条三表 JOIN 的执行顺序,指出驱动表,并用 JOIN_ORDER Hint 改变顺序对比 rows 估算。