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

634 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, EXPLAIN, 执行计划, 性能分析]
create time: 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 判断"问题有多严重"。
## 基本用法
```sql
-- 标准 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+) | 优化器将子查询结果存入临时表 |
```sql
-- 一个包含子查询的查询
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 详解(最重要)
```mermaid
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 比你预期的短,说明联合索引**只用到了前缀几列**。
### 实战计算
```sql
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 |
```sql
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`)|
```sql
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 不下推
```sql
-- 假设有联合索引 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 次。
```mermaid
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:何时出现
```sql
-- 常见触发场景
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:深度分析
```mermaid
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 可以直接按索引序扫描,跳过排序步骤。关键看 **索引是否天然有序**。
```sql
-- ✅ 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
```
## 实战案例分析
下面用一个完整的调优案例,把前面所有知识点串起来。
### 场景:订单列表页越来越慢
```sql
-- 表结构
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
```sql
ALTER TABLE orders ADD INDEX idx_uid_status_created (user_id, status, created_at);
```
```sql
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 确认
```sql
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**:从秒级优化到了亚毫秒级 🚀
```mermaid
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 信息、索引选择细节。
```json
{
"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,这就是有效的索引设计。
```mermaid
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 都更直观。
```sql
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 查询可以放心使用。
```sql
-- 传统方式 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,按以下步骤排查:
```mermaid
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` 强制指定,或改写查询 |
### 别忘了维护统计信息
```sql
-- 当 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`)
## 关联笔记
- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 回表原理与 Extra 中 Using index 的底层原因