MySQL 执行计划 EXPLAIN 解读(mysql-explain-analyze)
EXPLAIN 有 12 列,但每次必看的只有四项:type、key、rows、Extra。
其余八列用来解释这四个为什么是这样。
这个技能做三件事:读四列 → 用 key_len 反推用到第几列 → 按 Extra 给处置动作。
硬边界(先看这条)
| 能做 | 不能做 |
|---|---|
| 逐列解释 EXPLAIN 输出的含义 | ❌ 代替 EXPLAIN ANALYZE 的真实执行数据 |
| 按 type/rows/Extra 给出严重度分级 | ❌ 保证按下述建议改完一定变快 |
| 用 key_len 反推实际用到的索引列 | ❌ 处理分区裁剪、直方图、优化器开关等高级场景 |
| 指出 join buffer / filesort 的根因方向 | ❌ 直接给出最终索引方案(需结合表结构与统计信息) |
以下情况必须先做别的:
rows估算与实际差一个数量级 → 先ANALYZE TABLE刷新统计信息- 想看真实执行 → 用
EXPLAIN ANALYZE(MySQL 8.0.18+) Impossible WHERE→ 先检查 SQL 是不是写错了,不是优化问题
一、什么时候用这个技能
| 用户这样说 | 就用 | |---|---| | "EXPLAIN 结果怎么看" | ✅ | | "type=ALL 是不是很严重" | ✅ | | "key_len 怎么算出用了几列" | ✅ | | "Using filesort 要不要管" | ✅ | | "filtered 只有 10 是什么情况" | ✅ | | "possible_keys 有值但 key 是 NULL" | ✅ | | "EXPLAIN 和 EXPLAIN ANALYZE 有什么区别" | ✅ |
二、完整工作流
第 1 步 读规则文件 rules/explain_fields.md
第 2 步 拿到执行计划:EXPLAIN <SQL>; 或 MySQL 8 的 EXPLAIN ANALYZE <SQL>;
第 3 步 把结果存成文本,跑诊断脚本
python scripts/explain_check.py --file explain.txt \
--cols "tenant_id:int:no,status:tinyint:no,nick:varchar(64):no" --charset utf8mb4
第 4 步 按脚本给出的严重/错误/警示逐项处理(先看 type 与 key)
第 5 步 改完再 EXPLAIN 一次,对比 rows 与 Extra
第 6 步 rows 与实际不符时用 EXPLAIN ANALYZE 或 Handler% 状态核实
三、type:从优到劣(最重要的一列)
| # | type | 含义 | 判定 |
|---|---|---|---|
| 1 | system / const | 主键或唯一索引一次命中 | 最优 |
| 2 | eq_ref | 联表时被驱动表主键等值匹配 | 好 |
| 3 | ref / ref_or_null | 非唯一索引等值匹配 | 好 |
| 4 | range | 范围扫描(> < BETWEEN IN LIKE 'x%') | 可接受 |
| 5 | unique_subquery / index_subquery | 子查询转索引查找 | 可接受 |
| 6 | index_merge | 合并多个索引 | 警示:说明索引没建全 |
| 7 | index | 全索引扫描(扫整棵树) | 警示:比 ALL 略好而已 |
| 8 | ALL | 全表扫描 | 严重 |
判定阈值:OLTP 单表查询 type 至少要到 range,最好是 ref/const;出现 ALL 且 rows > 1 万必须处理。
四、key_len:反推用到第几列(很多人不会算)
| 类型 | NOT NULL | 可为 NULL | |---|---|---| | TINYINT / SMALLINT / INT / BIGINT | 1 / 2 / 4 / 8 | +1 | | DATE / TIMESTAMP / DATETIME | 3 / 4 / 5 | +1 | | CHAR(n) | n × 字符集字节 | +1 | | VARCHAR(n) | n × 字符集字节 + 2(长度前缀) | +1 |
字符集字节:latin1=1、utf8=3、utf8mb4=4、gbk=2。
例:KEY idx_tsn (tenant_id INT, status TINYINT, nick VARCHAR(64)),utf8mb4:
| 用到几列 | 计算 | key_len | |---|---|---| | tenant_id | 4 | 4 | | + status | 4 + 1 | 5 | | + nick | 5 + (64×4+2) | 263 |
看到 key_len=5 说明第三列没被用上(很可能被范围条件截断了,见「联合索引失效」技能)。
五、Extra 逐条处置
| Extra | 要紧吗 | 处置 |
|---|---|---|
| Using index | 好 | 覆盖索引,不回表,保持 |
| Using index condition | 好 | ICP,减少回表但仍会回表 |
| Using where | 看情况 | 与 type=ALL 同现说明全表 + Server 层过滤 |
| Using filesort | 要管 | 排序没用上索引顺序 |
| Using temporary | 要管 | 用了临时表,关注是否落盘 |
| Using join buffer (BNL / hash join) | 严重 | 关联字段没索引 |
| Range checked for each record | 严重 | 关联写法有问题 |
| Impossible WHERE | 提示 | 条件恒假,检查 SQL 是否写错 |
| Select tables optimized away | 最优 | 优化器直接算出(如 MIN/MAX 走索引) |
六、脚本用法
python scripts/explain_check.py --file explain.txt
python scripts/explain_check.py --file explain.txt \
--cols "tenant_id:int:no,status:tinyint:no,nick:varchar(64):no" --charset utf8mb4
| 参数 | 说明 |
|---|---|
| --file | EXPLAIN 输出文本(支持 mysql 客户端 +---+ 表格与竖线简化格式) |
| --cols | 索引列定义,用于 key_len 反推用到第几列:名:类型:是否可空 |
| --charset | 字符集,默认 utf8mb4(决定 VARCHAR/CHAR 的字节数) |
| --json / --out | JSON 输出 / 指定输出文件 |
| --selftest | 跑内置自检 |
脚本做四件事:① 解析 EXPLAIN 表格;② 按 type 排序判访问方式优劣(ALL/index 直接提示);③ key_len 反推实际用到第几列;④ rows × filtered 与 Extra 逐项给出严重/错误/警示/提示分级诊断。
静态诊断基于 EXPLAIN 的估算值。估算与实际差得多时,用
EXPLAIN ANALYZE的actual rows复核。
七、人工确认清单(这步不能省)
- [ ]
rows与实际行数是否同量级(差得多先ANALYZE TABLE) - [ ]
key选的和你预期一致吗(不一致先看统计信息,不要急着FORCE INDEX) - [ ] JOIN 两侧关联字段的类型与字符集一致吗(不一致会静默失效)
- [ ]
filtered低是因为条件没进索引,还是数据分布本身如此 - [ ] 驱动表是过滤后行数最小的那张吗
- [ ] 有没有用
EXPLAIN ANALYZE复核过actual time
八、最常见的坑
坑一:只看到 key 有值就以为没问题。
key 有值但 key_len 只有 4,说明只用了最左一列;后面的条件全部在 Server 层过滤。必须算 key_len。
坑二:以为 type=index 是好事。
index 是全索引扫描,扫的是整棵索引树,只是比全表扫的行宽小一点。真正好的是 ref/const/range。
坑三:忽略 filtered。
rows=50000, filtered=10% 意味着 5 万行被读出来,最后只剩 5 千行。真正参与后续计算的是 rows × filtered%,这个数才决定 JOIN 与排序的成本。
坑四:把 index_merge 当优化成果。
它说明没有一条能覆盖条件的联合索引,只能把两个索引各自扫一遍再合并去重。正解是补联合索引。
坑五:rows 是估算值。
EXPLAIN 从来不会真的执行 SQL。统计信息过期时它能报 100 行而实际 100 万行。验证用 EXPLAIN ANALYZE 的 actual rows,或用 FLUSH STATUS + SHOW STATUS LIKE 'Handler%'。
坑六:看到 filesort 就加索引,不看排序方向。
ORDER BY a ASC, b DESC 混用方向时,MySQL 5.7 加了索引照样 filesort。要么统一方向,要么 MySQL 8 建对应降序索引。
坑七:JOIN 两边字段类型不一致。
orders.user_id 是 varchar,user.id 是 int,被转换的一侧索引失效,表现为 Using join buffer。这是最常见的静默失效。
坑八:用 FORCE INDEX 掩盖问题。
统计信息没刷新、数据分布变了,FORCE INDEX 短期有效,长期会在数据量变化后突然反向。先 ANALYZE TABLE,再考虑 hint。
九、边界与免责
- 本技能解读的是 EXPLAIN 估算结果,真实执行请以
EXPLAIN ANALYZE、慢日志与线上观测为准 - MySQL 版本差异明显:
DATETIME在 5.5 是 8 字节、5.6+ 是 5 字节;EXPLAIN ANALYZE需要 8.0.18+;8.0.18 起才有 hash join - 修改索引属于生产变更,必须先走评审并在预发验证回滚方案
- 涉及
FORCE INDEX、optimizer_switch的改动应作为临时手段并留整改 TODO
十、许可
本技能由 刘天宝|墨燊 原创,采用 Apache License 2.0。
rules/explain_fields.md 中的字节数与版本差异以 MySQL 官方文档为准;不同版本请重新核对 DATETIME 等类型的字节长度。
微信扫一扫