329 lines
12 KiB
Markdown
329 lines
12 KiB
Markdown
---
|
||
tags: [MySQL, EXPLAIN, 执行计划, 性能分析]
|
||
create time: 2026-05-16 00:00
|
||
---
|
||
|
||
# EXPLAIN 完全指南
|
||
|
||
## 概述
|
||
|
||
`EXPLAIN` 是 MySQL 性能分析的入口工具——它展示 SQL 语句的执行计划,告诉你优化器打算怎么用索引、怎么 JOIN、会不会用临时表和文件排序。
|
||
|
||
> [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"?
|
||
> 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。
|
||
|
||
## 基本用法
|
||
|
||
```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+
|
||
```
|
||
|
||
## 关键字段速查
|
||
|
||
| 字段 | 含义 | 关注点 |
|
||
|------|------|--------|
|
||
| **type** | JOIN 类型 | 是否走索引(ref/range 优,ALL 差)|
|
||
| **key** | 实际使用的索引 | NULL = 没用索引 |
|
||
| **rows** | 预估扫描行数 | 越小越好 |
|
||
| **Extra** | 附加信息 | 重点关注 Using where/index/filesort/temporary |
|
||
| **cost** | 预估成本(JSON 格式) | optimizer_cost 越低越好 |
|
||
|
||
## 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` 的组合。
|
||
|
||
## 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+ 引入,在存储引擎层预过滤,减少回表次数 ✅
|
||
|
||
### 其他 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
|
||
-- 原始查询(慢)
|
||
EXPLAIN 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;
|
||
|
||
-- 典型 bad result:
|
||
-- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary
|
||
-- → 全表扫描 + 临时表 + 文件排序
|
||
|
||
-- 修复方案
|
||
-- 1. 创建复合索引
|
||
ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id);
|
||
|
||
-- 2. 查询改写(反向排序优化)
|
||
-- 先用索引定位 top 20 的 pk,再 JOIN 拿数据
|
||
SELECT u.username, o.amount
|
||
FROM orders o
|
||
JOIN users u ON u.id = o.user_id
|
||
WHERE o.created_at >= '2026-01-01'
|
||
ORDER BY o.created_at DESC
|
||
LIMIT 20;
|
||
|
||
-- 新的 EXPLAIN:
|
||
-- type: range on orders, ref on users
|
||
-- key: idx_created_user
|
||
-- Extra: Using index condition; Using where
|
||
-- → 索引范围扫描 + 快速消除 filesort
|
||
```
|
||
|
||
## 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
|
||
```
|
||
|
||
## 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;
|
||
```
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/MySQL/16-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
|
||
- [[hhs/MySQL/19-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
|
||
- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
|