Files
cs-note/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
T
2026-05-24 11:42:38 +08:00

27 KiB
Raw Blame History

tags, create time
tags create time
MySQL
EXPLAIN
执行计划
性能分析
2026-05-16 00:00

EXPLAIN 完全指南

概述

EXPLAIN 是 MySQL 性能分析的入口工具——它展示 SQL 语句的执行计划,告诉你优化器打算怎么用索引、怎么 JOIN、会不会用临时表和文件排序。

[!QUESTION] 为什么不能直接靠"写了索引就一定能用到"? 因为 MySQL 使用的是 Cost-Based Optimizer (CBO):优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。EXPLAIN 就是用来观察和优化器"想法"是否一致的镜子。

[!TIP] EXPLAIN 输出的阅读顺序 面对一屏 EXPLAIN 输出,可以按"从大到小"的优先级快速判断:

  1. type — 最快判断索引使用情况(ALL = 🔴 全表扫描)
  2. key — 确认实际命中的索引名
  3. rows — 预估扫描行数,决定是否需要优化
  4. Extra — 有没有 filesort / temporary 等额外开销
  5. key_len — 联合索引用了几列?只用了前缀?
  6. ref — 索引在跟什么做比较?

先扫 type + key 就能判断"这 SQL 有没有大问题",再看 rows + Extra 判断"问题有多严重"。

基本用法

-- 标准 EXPLAIN
EXPLAIN SELECT * FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 1 AND o.amount > 100;

-- JSON 格式(信息最全,推荐)
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com';

-- 实际执行后再看成本(含统计信息)
EXPLAIN ANALYZE SELECT ...;  -- MySQL 8.0.18+

输出字段全览

[!NOTE] 核心思路 EXPLAIN 输出的每一行对应一张表的访问方式。多表 JOIN 时会输出多行,id 相同时,行号越小(越靠上)越先执行——第一行是驱动表,后续行是被驱动表。

字段 含义 重要程度 关注点
id 查询编号 ⚪ 了解 相同 id = 同层查询,不同 id = 嵌套子查询
select_type 查询类型 🟡 辅助 SIMPLE/PRIMARY/SUBQUERY/DERIVED,判断复杂度
table 访问的表 ⚪ 了解 可能是别名或 <derivedN> 物化表
type 访问类型 🔴 必看 索引使用的第一指标(const > ref > range > ALL)
possible_keys 可能用到的索引 🟡 辅助 NULL = 没有候选索引,需创建
key 实际使用的索引 🔴 必看 NULL = 优化器放弃索引,选了全表扫描
key_len 索引使用字节数 🟡 辅助 判断联合索引是否被充分利用
ref 索引比较对象 🟡 辅助 const/列名/func,理解 JOIN 驱动关系
rows 预估扫描行数 🔴 必看 越小越好,rows × filtered% ≈ 进入下一步的行数
filtered WHERE 过滤率 (%) 🟡 辅助 100% = 索引完全满足条件,越低说明回表后还要过滤
Extra 附加信息 🔴 必看 filesort/temporary = 🔴;Using index = 🟢 覆盖索引

select_type:查询层级识别

select_type 告诉你这一行代表的是什么级别的查询——最外层、子查询、还是 UNION?

select_type 含义 典型场景
SIMPLE 简单查询(无子查询/UNION) SELECT * FROM t WHERE id = 1
PRIMARY 最外层查询 包含子查询时,外层标记为 PRIMARY
SUBQUERY SELECT 列表或 WHERE 中的子查询 WHERE id IN (SELECT ...)
DEPENDENT SUBQUERY 依赖外层结果的关联子查询 WHERE EXISTS (SELECT ... FROM t2 WHERE t2.id = t1.id)
DERIVED FROM 子句中的派生表 FROM (SELECT ...) AS tmp
UNION UNION 中第二个及之后的 SELECT SELECT ... UNION SELECT ...
MATERIALIZED 物化子查询(8.0+) 优化器将子查询结果存入临时表
-- 一个包含子查询的查询
EXPLAIN
SELECT u.username, order_count
FROM users u
JOIN (
    SELECT user_id, COUNT(*) AS order_count
    FROM orders
    GROUP BY user_id
) AS oc ON u.id = oc.user_id
WHERE u.id IN (SELECT user_id FROM vip_users);
+----+-------------+------------+--------+---------------+---------+---------+------+------+-------+
| id | select_type | table      | type   | possible_keys | key     | key_len | ref  | rows | Extra |
+----+-------------+------------+--------+---------------+---------+---------+------+------+-------+
|  1 | PRIMARY     | <derived2> | ALL    | NULL          | NULL    | NULL    | NULL | 1000 |       |  ← FROM 子句的派生表
|  1 | PRIMARY     | u          | eq_ref | PRIMARY       | PRIMARY | 4       | oc.user_id | 1 | |
|  2 | DERIVED     | orders     | index  | idx_user_id   | idx_user_id | 4   | NULL | 100K | Using index |
|  3 | SUBQUERY    | vip_users  | ALL    | NULL          | NULL    | NULL    | NULL | 500  |       |
+----+-------------+------------+--------+---------------+---------+---------+------+------+-------+

[!QUESTION] 为什么 DERIVED 表的 rows 是 1000? 因为派生表 oc 是先物化到临时表的,MySQL 对物化表的行数统计通常不精确。这也是为什么尽量避免在大表上使用 FROM 子查询——物化过程本身就有开销。MySQL 8.0 的 Merged Derived Table 优化可以将简单的派生表"合并"回外层查询,减少物化。

type 详解(最重要)

flowchart LR
    system["system<br/>只有1行"] --> const["const<br/>常量级访问"]
    const --> eq_ref["eq_ref<br/>唯一索引 × 驱动行"]
    eq_ref --> ref["ref<br/>等值匹配多行"]
    ref --> range["range<br/>索引范围"]
    range --> index["index<br/>全索引扫描"]
    index --> ALL["ALL<br/>全表扫描 🔴"]
    
    style system fill:#00D866,color:#fff
    style const fill:#00D866,color:#fff
    style eq_ref fill:#00D866,color:#fff
    style ref fill:#00B6BC,color:#fff
    style range fill:#FF9F43,color:#000
    style index fill:#C44569,color:#fff
    style ALL fill:#EE5A24,color:#fff

各级别详解

级别 名称 含义 示例
system 系统表 表只有一行(MyISAM 等引擎) SELECT * FROM (SELECT 1) t
const 常量 最多一行匹配 WHERE primary_key = 1
eq_ref 唯一索引 唯一索引扫描,每行匹配被驱动表一行 JOIN ON pk = ?
ref 索引查找 等值匹配多行(非唯一索引 / 最左前缀的部分匹配) WHERE email_prefix = 'a@'
range 范围扫描 索引上的范围查询 WHERE id > 100
index 索引全扫 扫描整个索引树 SELECT COUNT(*)
ALL 全表扫描 无可用索引,逐行扫描 需要优化

[!WARNING] 不要盲目追求 ref 以上级别 range 在某些场景(如范围不大)完全可以接受。关键是看 rows 字段和 Extra 的组合。

key_len:索引使用了多少?

key_len 表示 MySQL 在索引中实际使用的字节数。对于联合索引,它是判断"索引被用了几列"的最直接证据。

[!QUESTION] 联合索引 idx_a_b_c (a, b, c),EXPLAIN 显示 key_len = 8,用了几列? 需要知道各列的数据类型才能算出来。key_len 的计算规则如下:

计算规则

类型 字节数 说明
INT 4 固定 4 字节
BIGINT 8 固定 8 字节
CHAR(n) n × 字符集字节数 utf8mb4 = 每字符 4 字节
VARCHAR(n) n × 字符集字节数 + 2 额外 2 字节存长度
DATE 3 固定 3 字节
TIMESTAMP/DATETIME 5/8 固定长度
NULLABLE 列 +1 所有类型额外 +1 字节标记 NULL

[!TIP] 速算公式 key_len = 各列字节数之和,其中每列 = 类型基础长度 + (VARCHAR 额外 2) + (可 NULL 额外 1)

反过来看:如果 key_len 比你预期的短,说明联合索引只用到了前缀几列。

实战计算

CREATE TABLE users (
    id    BIGINT PRIMARY KEY,
    name  VARCHAR(64) NOT NULL,     -- utf8mb4
    age   INT,
    email VARCHAR(128) DEFAULT NULL,
    INDEX idx_name_age_email (name, age, email)
);
使用方式 key_len 计算 说明
WHERE name = 'Tom' 64 × 4 + 2 = 258 VARCHAR(64) utf8mb4,NOT NULL 无额外字节
WHERE name = 'Tom' AND age = 20 258 + 4 = 262 age 是 INT,NOT NULL
WHERE name = 'Tom' AND age = 20 AND email = 'a@b.c' 262 + 128 × 4 + 2 + 1 = 777 email 可 NULL,额外 +1
EXPLAIN SELECT * FROM users WHERE name = 'Tom' AND age = 20;
-- key: idx_name_age_email, key_len: 262
-- → 说明索引只用到了前 2 列(name + age),没用到 email

[!NOTE] 核心用法 看到 key_len 后,对照表结构算一下"如果用满所有列应该是多少字节"。差得远 = 索引没充分利用,可能需要调整查询条件或索引顺序。

ref 字段:索引在跟谁比?

ref 显示索引的查找值来自哪里——是常量、函数还是另一张表的列。

ref 值 含义 示例
const 与常量比较 WHERE id = 1
func 与函数结果比较 WHERE created_at = NOW()
db.table.column 与另一张表的列比较(JOIN) ON u.id = o.user_id → ref 显示 o.user_id
NULL 无法使用等值比较 range 扫描(如 WHERE id > 100)
EXPLAIN SELECT u.username, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'pending';
+----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+
| id | select_type | table | type | possible_keys | key          | key_len | ref              | rows | Extra |
+----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+
|  1 | SIMPLE      | o     | ref  | idx_status    | idx_status   | 130     | const            | 5000 | Using where |
|  1 | SIMPLE      | u     | eq_ref | PRIMARY     | PRIMARY      | 8       | test.o.user_id   | 1    |       |
+----+-------------+-------+------+---------------+--------------+---------+------------------+------+-------+
  • 第一行:o 表用 idx_status 索引,ref = const(status = 'pending' 是常量)
  • 第二行:u 表用主键,ref = test.o.user_id(被驱动表的列来自 o.user_id)

[!TIP] ref 帮你理解 JOIN 驱动关系 当 ref 显示另一张表的列名时,说明当前表是被驱动表。驱动表的行数(rows)越小,被驱动表的回表次数就越少——这就是为什么小表驱动大表的原则。

Extra 关键字解读

[!SUCCESS] Using index — 覆盖索引(Index Only Scan) 数据在索引中全部找到,无需回表。这是最优的 Extra 信息。

[!INFO] Using where — 服务器层过滤 读完索引后还需 WHERE 条件过滤。正常现象,不代表性能问题。

  • Using where + Using index = 覆盖索引 + WHERE 过滤 → 最佳实践 ✅
  • Using where 无 Using index = 回表后才过滤 → 可考虑加索引优化 ⚠️

[!TIP] Using index condition — 索引下推(ICP) MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅

ICP 深入理解:下推 vs 不下推

-- 假设有联合索引 idx_status_city (status, city)
EXPLAIN SELECT * FROM users
WHERE status = 'active' AND city LIKE '北京%';

Without ICP(MySQL 5.6 之前):存储引擎只用 status 过滤,拿到所有 active 用户的主键 → 逐行回表 → Server 层再过滤 city LIKE '北京%'。假设 active 用户有 10 万,就要回表 10 万次。

With ICP(MySQL 5.6+):存储引擎在索引层直接过滤 city LIKE '北京%',只把满足两个条件的行回表。可能只回表 2000 次。

flowchart LR
    A["存储引擎层<br/>idx_status_city"] --> B{"ICP 可用?"}
    
    B -->|No ICP| C["只用 status 定位<br/>回表 100K 行"]
    C --> D["Server 层过滤 city<br/>丢弃 98K 行 ⚠️"]
    
    B -->|With ICP| E["status 定位 + city 预过滤<br/>索引层过滤后只剩 2K 行"]
    E --> F["只回表 2K 行 ✅"]
    
    style D fill:#EE5A24,color:#fff
    style F fill:#00D866,color:#fff

[!NOTE] ICP 的适用条件 ICP 只在联合索引且 WHERE 条件包含索引列但不满足最左前缀时才有意义。比如 idx(a, b),WHERE a > 1 AND b = 2 中 b = 2 就是 ICP 下推的候选条件。如果只查 WHERE a = 1,ICP 无从发挥。

其他 Extra 信息速查

关键词 含义 处理
Impossible where WHERE 永远为假(如 status = 1 AND status = 2) 检查 SQL 逻辑是否正确
Select tables optimized 优化器发现子查询可展开 无需处理,已自动优化 ✅
Using distinct 内部去重,类似 DISTINCT 看能否改用 GROUP BY + 索引
Scan & filter InnoDB 特有:全索引扫描后逐行过滤 考虑加更精确的索引

Using temporary:何时出现

-- 常见触发场景
EXPLAIN SELECT city, AVG(age) FROM users GROUP BY city;
-- Extra: Using temporary; Using filesort
-- 需要用临时表存储每个 city 的聚合结果

EXPLAIN SELECT DISTINCT city FROM users;
-- Extra: Using temporary
-- DISTINCT 内部用临时表去重

[!WARNING] Using temporary + Using filesort 当 GROUP BY 的分组列和 ORDER BY 的排序列不一致时,MySQL 会先建临时表再额外排序。 解决思路:让索引同时满足 GROUP BY + ORDER BY 的顺序要求。

Using filesort:深度分析

flowchart TD
    A["WHERE status='pending'"] --> B["idx_status 索引扫描<br/>拿到所有 pending 订单的 pk"]
    B --> C{"created_at 能在索引中找到吗?"}
    
    C -->|能:<br/>联合索引 idx_status_created| D["直接有序返回<br/>No filesort ✅"]
    C -->|不能:<br/>单列索引 idx_status| E["取 pk 列表 → 回表拿完整行<br/>→ 内存中排序<br/>Filesort ⚠️"]
    
    D --> F["可能的解决方案"]
    E --> F
    
    F --> F1["添加联合索引 (status, created_at)"]
    F --> F2["调整 WHERE 条件使索引生效"]
    F --> F3["加大 sort_buffer_size"]
    
    style D fill:#00D866,color:#fff
    style E fill:#EE5A24,color:#fff

[!QUESTION] 思考:ORDER BY 一定会触发 filesort 吗? 不一定!如果查询使用了索引且排序字段与索引顺序一致,MySQL 可以直接按索引序扫描,跳过排序步骤。关键看 索引是否天然有序。

-- ✅ No filesort — 走联合索引天然有序
EXPLAIN SELECT * FROM orders
WHERE status = 'pending'
ORDER BY status, created_at;
-- key: idx_status_created, Extra: Using where

-- ❌ Using filesort — WHERE 用了另一个索引,无法利用排序
EXPLAIN SELECT * FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;
-- key: idx_user_id, Extra: Using where; Using filesort

实战案例分析

下面用一个完整的调优案例,把前面所有知识点串起来。

场景:订单列表页越来越慢

-- 表结构
CREATE TABLE orders (
    id         BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id    BIGINT NOT NULL,
    status     VARCHAR(20) NOT NULL,
    amount     DECIMAL(10,2),
    created_at DATETIME NOT NULL,
    INDEX idx_user_id (user_id)
) ENGINE=InnoDB;
-- 表中有 100 万行数据

-- 慢查询:某用户最近的已支付订单
EXPLAIN SELECT *
FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;

Step 1:看 type + key — 全表扫描?

+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
| id | select_type | table  | type | possible_keys | key        | key_len | ref   | rows   | Extra                     |
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
|  1 | SIMPLE      | orders | ref  | idx_user_id   | idx_user_id| 8       | const | 98000  | Using where; Using filesort |
+----+-------------+--------+------+---------------+------------+---------+-------+--------+---------------------------+
  • type = ref(走了索引 ✅),但 rows = 98000(user_id=42 有 9.8 万条订单,太多)
  • Extra = Using where; Using filesort 🔴 — 回表后还要过滤 status + created_at,再排序

[!QUESTION] 问题出在哪? idx_user_id 是单列索引,只帮你定位 user_id。status 和 created_at 的过滤都在回表后才做,9.8 万次回表 + filesort = 慢。

Step 2:创建联合索引 — 关注 key_len

ALTER TABLE orders ADD INDEX idx_uid_status_created (user_id, status, created_at);
EXPLAIN SELECT *
FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+
| id | select_type | table  | type  | possible_keys                               | key                     | key_len | ref  | rows | Extra |
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+
|  1 | SIMPLE      | orders | range | idx_user_id, idx_uid_status_created          | idx_uid_status_created  | 97      | NULL | 1200 | Using where |
+----+-------------+--------+-------+---------------------------------------------+-------------------------+---------+------+------+----+

逐字段分析:

字段 变化 说明
type ref → range 用 created_at 范围扫描,精确了
key idx_user_id → idx_uid_status_created 用上了新索引 ✅
key_len 8 → 97 8 = 只用了 user_id(BIGINT);97 = user_id(8) + status(VARCHAR(20)×4+2+1=83) + created_at(DATETIME=5+1=6) → 三列全用上 ✅
rows 98000 → 1200 索引直接过滤到 1200 行,减少了 98.8%
Extra Using filesort 消失 ✅ created_at 在索引末尾,DESC 排序直接倒序扫描

[!NOTE] 关键决策点 这里 ORDER BY created_at DESC 恰好可以用到索引的倒序扫描(InnoDB 支持 Reverse Index Scan),所以 filesort 消失了。如果 ORDER BY 的列不在索引中,filesort 仍会出现。

Step 3:EXPLAIN ANALYZE 确认

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
-> Limit: 20 row(s)
    -> Index range scan on orders using idx_uid_status_created
        (actual time=0.089..0.142 rows=20 loops=1)
  • rows=20:只扫了 20 行就满足 LIMIT,配合索引的有序性,几乎零开销
  • actual time < 0.2ms:从秒级优化到了亚毫秒级 🚀
flowchart LR
    A["Step 1<br/>rows: 98K<br/>filesort 🔴"] --> B["Step 2<br/>rows: 1.2K<br/>无 filesort ✅"]
    B --> C["Step 3<br/>rows: 20<br/>0.14ms 🚀"]
    
    style A fill:#EE5A24,color:#fff
    style B fill:#FF9F43,color:#000
    style C fill:#00D866,color:#fff

EXPLAIN FORMAT=JSON 精读

FORMAT=JSON 输出最完整的执行计划,包含嵌套子查询、Cost 信息、索引选择细节。

{
  "query_block": {
    "select_id": 1,
    "table": {
      "table_name": "orders",
      "access_type": "range",
      "possible_keys": ["idx_created_user"],
      "key": "idx_created_user",
      "key_length": "8",
      "rows": 5000,
      "filtered": 100.0,
      "index_condition": "orders.created_at >= '2026-01-01'",
      "cost_information": {
        "total_cost": "53000",          ← 总成本(越小越好)
        "optimizer_cost": "53000"       ← 优化器计算的成本值
      }
    }
  }
}

关键字段说明

JSON 字段 含义 实战要点
access_type 与 type 等价 range/ref 优先,ALL 需关注
key_length 实际使用索引的字节数 越短说明用的列越少,可优化
rows 预估扫描行数 估算值,可能与实际情况偏差
filtered WHERE 筛选率 (%) rows × filtered% = 进入下一步的行数
index_condition ICP 预过滤条件 有 = 使用了索引下推 ✅
using_temporary / using_filesort 布尔值 true = 会触发对应操作

Cost 分析:如何判断查询是否健康?

[!TIP] Cost 评估经验法则

  • cost < 1000:通常没问题 ✅
  • cost 1000 ~ 10000:中等负载可接受,关注高频 SQL ⚠️
  • cost > 10000:大概率需要优化 🔴

关键点:optimizer_cost 是绝对值而非相对值,不同版本 MySQL 的计算方式可能变化。更有价值的是 对比两种方案的 cost 差值——比如加了一个索引后 cost 从 50000 降到 5300,这就是有效的索引设计。

flowchart LR
    A["编写慢 SQL"] --> B["EXPLAIN 看执行计划"]
    B --> C{"access_type?"}
    C -->|ALL 全表扫描| D["检查 possible_keys<br/>添加合适的索引"]
    C -->|range/ref/optimal| E{"Extra 中有<br/>filesort/temporary?"}
    E -->|无 → 健康✅| F["无需额外处理"]
    E -->|有 → 优化⚠️| G["调整索引顺序或改写 SQL"]
    
    style F fill:#00D866,color:#fff
    style G fill:#FF9F43,color:#000
    style D fill:#C44569,color:#fff

FORMAT=TREE:现代可读格式

MySQL 8.0.16+ 引入了 FORMAT=TREE,输出缩进树状结构,比传统表格和 JSON 都更直观。

EXPLAIN FORMAT=TREE
SELECT u.username, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 20;
-> Limit: 20 row(s)
    -> Nested loop inner join  (cost=530 rows=20)
        -> Index range scan on o using idx_created_user  (cost=530 rows=5000)
        -> Single-row index lookup on u using PRIMARY (id=o.user_id)  (cost=0.25 rows=1)

三种格式怎么选?

格式 适用场景 优点 缺点
默认表格 快速筛查 一行一表,一目了然 缺少 cost、缺少嵌套信息
FORMAT=JSON 精细分析 信息最全,可编程解析 冗长,层级关系不直观
FORMAT=TREE 日常推荐 层级清晰 + 含 cost + 易读 无法看到 possible_keys
ANALYZE 深度诊断 含真实执行时间 会实际执行 SQL

[!TIP] 推荐工作流 日常排查先用 FORMAT=TREE 快速定位瓶颈(哪一步 cost 最高),需要细节时切到 FORMAT=JSON 看 key_len、possible_keys 等。

EXPLAIN ANALYZE:看到真实执行代价

EXPLAIN ANALYZE 是 MySQL 8.0.18+ 引入的功能——它会真正执行一次 SQL,然后返回实际运行时间、每行的实际扫描数等统计信息。

[!WARNING] 注意事项 EXPLAIN ANALYZE 会执行 SQL。如果有写入操作(如 INSERT/UPDATE),需要谨慎;但对于纯 SELECT 查询可以放心使用。

-- 传统方式 vs 现代方式
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
-- → 只有预估数据,可能不准

EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
-- → 预估 + 实际运行结果
-- Extra: Rows examined: 42 (vs. rows estimate: 100)

典型输出解读

-> Index lookup on orders using idx_user_id (user_id=42)
   (actual time=0.034..0.156 rows=42 loops=1)
  • actual time:首行耗时 .. 总耗时(微秒级)
  • rows:实际扫描行数(与预估 rows 对比,差距大说明统计信息过时)
  • loops:循环次数,JOIN 场景尤其重要

[!QUESTION] 什么时候用 EXPLAIN vs EXPLAIN ANALYZE?

  • EXPLAIN:快速预览,不执行 SQL,适合批量排查和 CI 门禁
  • EXPLAIN ANALYZE:精准诊断,需要实际执行,适合深入分析某个慢查询
  • 日常开发建议先用 EXPLAIN 快速筛查,再对问题 SQL 用 EXPLAIN ANALYZE 定位根因

优化策略速查

遇到问题 SQL,按以下步骤排查:

flowchart TD
    A["拿到慢 SQL"] --> B["EXPLAIN 看执行计划"]
    B --> C{"type = ALL?"}
    C -->|是| D["检查 possible_keys → 加索引"]
    C -->|否| E{"Extra 有 filesort?"}
    E -->|是| F["调整索引顺序,覆盖 ORDER BY"]
    E -->|否| G{"Extra 有 temporary?"}
    G -->|是| H["让 GROUP/DISTINCT 走索引"]
    G -->|否| I{"rows 很大?"}
    I -->|是→ rows × filtered% 仍大| J["优化 WHERE 条件或加更精确的索引"]
    I -->|否| K["查询健康 ✅"]
    
    style K fill:#00D866,color:#fff
    style D fill:#C44569,color:#fff
    style F fill:#FF9F43,color:#000
    style H fill:#FF9F43,color:#000
    style J fill:#FF9F43,color:#000

常见场景与对策

症状 根因 对策
type=ALL, Extra=Using where 无可用索引 添加 WHERE 列的索引
Using filesort ORDER BY 字段不在可用索引中 创建 (WHERE列, ORDER BY列) 联合索引
Using temporary; Using filesort GROUP BY 和 ORDER BY 不一致 索引顺序同时满足两者
rows 预估 >> 实际行数 统计信息过时 ANALYZE TABLE 表名 更新统计信息
possible_keys 有但 key 为 NULL 优化器选了全表扫描 用 FORCE INDEX 强制指定,或改写查询

别忘了维护统计信息

-- 当 EXPLAIN 预估严重偏离实际时
ANALYZE TABLE orders;

-- InnoDB 支持自动分析,但大批量写入后建议手动触发
SET GLOBAL innodb_stats_auto_recalc = ON;

核心记忆点

[!SUMMARY] EXPLAIN 六字口诀:型键行推排临

  1. 型(type):判断索引使用等级,ALL 是底线问题
  2. 键(key):确认命中哪个索引,NULL 需要警觉
  3. 行(rows):预估扫描行数,rows × filtered 决定实际负载
  4. 推(ICP):Using index condition = 存储引擎层预过滤,减少回表
  5. 排(filesort):ORDER BY 无法利用索引排序时触发
  6. 临(temporary):GROUP BY / DISTINCT 产生临时表

[!CHECKLIST] EXPLAIN 审查清单

  • type 不是 ALL(或 rows 可接受)
  • key 不是 NULL(有索引可用)
  • key_len 覆盖了联合索引的关键列
  • Extra 没有 filesort(或 ORDER BY 有索引支撑)
  • Extra 没有 temporary(或 GROUP BY 走了索引)
  • rows × filtered% 在可接受范围
  • 预估 rows 与实际偏差不大(必要时 ANALYZE TABLE)

关联笔记