从零理解 EXPLAIN 执行计划
来源:《MySQL 是怎样运行的:从根儿上理解 MySQL》第十六章「Explain 详解(上)」、第十七章「Explain 详解(下)」读书笔记。内容为个人理解重述,非原文摘录;示例为自行设计,与原书示例无关。
一条查询语句写下去之后,MySQL 会先经过查询优化器的一番处理------按成本和规则挑连接顺序、挑每张表的访问方式------最终生成一份"执行计划"。EXPLAIN 就是用来把这份计划摊开给我们看的工具。这篇笔记按 EXPLAIN 输出的各个列逐一说明,并在容易混淆的地方配上示例。
目录
- [为什么需要 EXPLAIN](#为什么需要 EXPLAIN "#sec-1")
- [id 与 table:这一行说的是谁](#id 与 table:这一行说的是谁 "#sec-2")
- select_type:这个小查询是什么身份
- type:单表访问方式的性能排行
- [possible_keys / key / key_len](#possible_keys / key / key_len "#sec-5")
- [ref / rows / filtered](#ref / rows / filtered "#sec-6")
- Extra:常见提示解析
- [JSON 格式执行计划:看到真实成本](#JSON 格式执行计划:看到真实成本 "#sec-8")
- [SHOW WARNINGS:查看语句被优化成什么样](#SHOW WARNINGS:查看语句被优化成什么样 "#sec-9")
一、为什么需要 EXPLAIN
在查询语句前面加上 EXPLAIN,就能看到 MySQL 打算怎么执行这条语句:多表连接的顺序是什么、每张表用什么方式访问、预计要扫多少条记录等等。本文示例使用一个简化的订单场景:
sql
CREATE TABLE orders (
id INT NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32),
user_id INT,
status VARCHAR(20),
province VARCHAR(50),
city VARCHAR(50),
district VARCHAR(50),
remark VARCHAR(255),
PRIMARY KEY (id),
UNIQUE KEY idx_order_no (order_no),
KEY idx_user_id (user_id),
KEY idx_status (status),
KEY idx_area (province, city, district)
) ENGINE=InnoDB;
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
mobile VARCHAR(11),
province VARCHAR(50),
PRIMARY KEY (id),
UNIQUE KEY idx_mobile (mobile),
KEY idx_province (province)
) ENGINE=InnoDB;
假设两张表都存了几万条业务数据。
二、id 与 table:这一行说的是谁
table 很直观,就是这条记录对应的表名。id 稍微绕一点:一条语句里每出现一个 SELECT 关键字,就会分配一个唯一的 id。
连接查询虽然涉及多张表,但只有一个 SELECT,所以 id 相同:
sql
EXPLAIN SELECT * FROM orders INNER JOIN users ON orders.user_id = users.id;
-- orders、users 两行记录的 id 都是 1
-- 排在前面的 orders 是驱动表,排在后面的 users 是被驱动表
子查询、UNION 则会引入多个 SELECT,id 也跟着变多:
sql
EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT id FROM users) OR status = 'closed';
-- orders(外层查询)id = 1
-- users(子查询) id = 2
这里有个很实用的技巧:查询优化器经常会把子查询偷偷改写成连接查询 ,改没改写光看 SQL 看不出来,但看执行计划一目了然------如果相关表的 id 变成了同一个值,就说明发生了改写:
sql
EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE province = '北京市');
-- orders、users 的 id 全都是 1,说明子查询被转成了连接查询
UNION 因为要去重,会额外借助一张临时表,执行计划里会多一行 id 为 NULL、table 显示成 <union1,2> 的记录;如果用的是不去重的 UNION ALL,就不会有这一行。
三、select_type:这个小查询是什么身份
每个 id 对应的小查询,都会被贴上一个 select_type 标签,说明它在整条语句里扮演的角色:
| 取值 | 含义 |
|---|---|
| SIMPLE | 不含 UNION 或子查询的查询(含普通连接查询) |
| PRIMARY | 大查询中最左边(最外层)的查询 |
| UNION | UNION/UNION ALL 中除最左边外的其余查询 |
| UNION RESULT | 为 UNION 去重而建的临时表对应的查询 |
| SUBQUERY | 不相关子查询,且被物化执行 |
| DEPENDENT SUBQUERY | 相关子查询,依赖外层查询的值 |
| DEPENDENT UNION | 依赖外层查询的 UNION 中非最左查询 |
| DERIVED | 以物化方式执行的派生表(FROM 子句中的子查询) |
| MATERIALIZED | 子查询被物化后再与外层做连接 |
其中 SUBQUERY 和 DEPENDENT SUBQUERY 最容易混淆,区别在于子查询是否依赖外层查询的值:
sql
-- 不相关子查询:子查询能独立执行一次,结果被物化后复用 ------ SUBQUERY
EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT id FROM users) OR status = 'closed';
-- 相关子查询:子查询里引用了外层的 orders.province,必须跟着外层每一行重新算一遍 ------ DEPENDENT SUBQUERY
EXPLAIN SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE users.province = orders.province)
OR status = 'closed';
DERIVED 和 MATERIALIZED 也容易搞混,区别在于物化出来的表是被当成"派生表"直接查询,还是被拿去跟外层表做连接:
sql
-- FROM 子句里的子查询本身被物化成一张临时表直接查询 ------ DERIVED
EXPLAIN SELECT * FROM (SELECT user_id, COUNT(*) c FROM orders GROUP BY user_id) t WHERE c > 3;
-- WHERE 子句里的子查询被物化后,再与外层表 orders 做连接查询 ------ MATERIALIZED
EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT id FROM users);
四、type:单表访问方式的性能排行
type 这一列的取值本身就是一份性能排行榜,从好到差排开:
sql
system → const → eq_ref → ref → fulltext → ref_or_null
→ index_merge → unique_subquery → index_subquery
→ range → index → ALL
下面按排行榜的顺序逐个说明。
system:表里只有一条记录,而且该表使用的存储引擎统计数据是精确的(比如 MyISAM、Memory,InnoDB 的行数是估算值,享受不到这个待遇)。假设我们另建一张 MyISAM 的配置表:
sql
CREATE TABLE site_config (id INT) ENGINE=MyISAM;
INSERT INTO site_config VALUES(1);
EXPLAIN SELECT * FROM site_config;
-- type: system
const:主键或唯一索引与常量做等值匹配,一步到位:
sql
EXPLAIN SELECT * FROM orders WHERE id = 1001;
-- type: const
eq_ref:连接查询中,被驱动表靠主键/唯一索引做等值匹配访问(如果是联合唯一索引,则要求所有列都参与等值比较)------这是被驱动表能拿到的最好成绩:
sql
EXPLAIN SELECT * FROM orders INNER JOIN users ON orders.user_id = users.id;
-- users(被驱动表)的 type 是 eq_ref
ref:最常见的情形,普通二级索引的等值匹配:
sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;
-- type: ref
fulltext :走全文索引进行匹配。假设给 remark 列建了全文索引:
sql
ALTER TABLE orders ADD FULLTEXT INDEX idx_remark_ft (remark);
EXPLAIN SELECT * FROM orders WHERE MATCH(remark) AGAINST('春节 发货');
-- type: fulltext
ref_or_null :在 ref 的基础上,索引列还允许匹配 NULL:
sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 OR user_id IS NULL;
-- type: ref_or_null
index_merge:单张表同时用上了不止一个索引,走 Intersection / Union / Sort-Union 三种索引合并方式之一:
sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 OR status = 'closed';
-- 分别可用 idx_user_id、idx_status,MySQL 把两次索引扫描的结果合并
-- type: index_merge
unique_subquery:IN 子查询被转成 EXISTS 之后,子查询里的表如果靠主键做等值匹配访问,就是这个类型:
sql
EXPLAIN SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE users.province = orders.province)
OR status = 'closed';
-- users 的 type: unique_subquery(转成 EXISTS 后按主键 id 等值匹配)
index_subquery :和 unique_subquery 类似,只是子查询里的表用的是普通二级索引而不是主键:
sql
EXPLAIN SELECT * FROM orders
WHERE remark IN (SELECT province FROM users WHERE users.id = orders.user_id)
OR status = 'closed';
-- users 的 type: index_subquery(province 走的是普通索引 idx_province)
range:索引区间扫描:
sql
EXPLAIN SELECT * FROM orders WHERE user_id > 1000 AND user_id < 2000;
-- type: range
index:用上了覆盖索引,但得把整个索引扫一遍(无法用 ref/range 缩小范围):
sql
EXPLAIN SELECT city FROM orders WHERE district = '海淀区';
-- 查询列表 city、搜索条件 district 都在联合索引 idx_area 里,
-- 但 district 排在联合索引的第 3 列,用不上 ref/range,只能扫完整个索引
-- type: index
ALL:全表扫描,最没有效率的一种:
sql
EXPLAIN SELECT * FROM orders;
-- type: ALL
记住一条规律就够用了:除了 ALL,其余方法都在吃索引的红利;除了 index_merge,其余方法一次最多只能用一个索引。
五、possible_keys / key / key_len
possible_keys 是候选索引,key 是优化器最终选定的索引:
sql
EXPLAIN SELECT * FROM orders WHERE user_id > 100000 AND status = 'closed';
-- possible_keys: idx_user_id, idx_status
-- key: idx_status ------ 优化器算完成本后,觉得用 idx_status 更划算
候选索引不是越多越好,优化器要给每个候选都算一遍成本,候选太多反而拖慢优化过程本身,用不上的索引该删就删。
key_len 看着像是在说存储占用,其实作用是让你能一眼看出联合索引到底吃上了几列 。它的计算方式是:索引列本身占用的最大字节数 + (允许 NULL 则 +1)+ (变长类型固定 +2)。以联合索引 idx_area(province, city, district) 为例(各列 VARCHAR(50),utf8 字符集每字符 3 字节):
sql
-- 只用上联合索引的第 1 列,key_len = 153(50×3字节 +1可空 +2变长标记)
EXPLAIN SELECT * FROM orders WHERE province = '北京市';
-- key_len: 153
-- 同时用上联合索引的前 2 列,key_len 直接翻倍
EXPLAIN SELECT * FROM orders WHERE province = '北京市' AND city = '朝阳区';
-- key_len: 306
看到 key_len 从 153 变成 306,不用细算就知道:这次多吃上了一列索引。
六、ref / rows / filtered
ref 告诉你等值匹配的对象是什么------常量、别的表的某一列,还是一个函数的结果:
sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;
-- ref: const (匹配一个常量)
EXPLAIN SELECT * FROM orders INNER JOIN users ON orders.user_id = users.id;
-- ref: shop.users.id (匹配另一张表的列)
EXPLAIN SELECT * FROM orders INNER JOIN users ON users.mobile = TRIM(orders.remark);
-- ref: func (匹配一个函数的结果,索引效果打了折扣)
rows 是优化器预估要扫的记录数,filtered 是这些记录里还有多少比例能通过其余条件。单独看 filtered 意义不大,真正有用的地方是算驱动表的扇出:
sql
EXPLAIN SELECT * FROM orders INNER JOIN users ON orders.user_id = users.id
WHERE orders.status = 'closed';
-- orders(驱动表):rows = 20000, filtered = 5.00
驱动表 orders 的扇出 ≈ 20000 × 5% = 1000,意味着接下来大概要对被驱动表 users 访问 1000 次左右------扇出越大,被驱动表被访问的次数就越多,这也是优化器挑选驱动表时要考虑的核心指标之一。
七、Extra:常见提示解析
| 提示 | 含义 |
|---|---|
| Using index | 覆盖索引,无需回表 |
| Using index condition | 索引条件下推(ICP),见下方示例 |
| Using where | 有条件需要在 server 层判断 |
| Using join buffer (Block Nested Loop) | 被驱动表无法有效利用索引,改用内存块做嵌套循环 |
| Using filesort | 排序无法用索引完成,需要文件排序 |
| Using temporary | 需要借助内部临时表完成去重/分组 |
| Not exists | 外连接 + IS NULL 场景下的优化,提前收工 |
| Using intersect(...) / union(...) / sort_union(...) | 三种索引合并策略 |
| Start temporary / End temporary | semi-join 的 DuplicateWeedout 策略 |
| LooseScan | semi-join 的 LooseScan 策略 |
| FirstMatch(tbl_name) | semi-join 的 FirstMatch 策略 |
其中最值得展开的是索引条件下推(ICP)。回忆一下之前讲过的"回表":二级索引查到记录后,要靠主键再去聚簇索引查一次才能拿到完整数据。如果搜索条件里,一部分能确定范围、另一部分虽然用不上范围查找、但好歹也是索引列,与其每扫到一条记录就急着回表,不如先在存储引擎层把这些索引相关的条件一次性判断完,不满足就直接跳过:
sql
EXPLAIN SELECT * FROM orders WHERE order_no > 'ORD20240000' AND order_no LIKE '%99';
-- Extra: Using index condition
-- order_no > 'ORD20240000' 能确定范围;order_no LIKE '%99' 用不上范围查找,但同属 order_no 列
-- 两个条件都下推到存储引擎层一起判断,省掉大量无谓的回表
因为回表是二级索引特有的负担 (聚簇索引本身就包含全部列),所以 ICP 只对二级索引有意义。凡是条件涉及的列不在当前索引里、必须等回表拿到完整记录才能判断的,会显示成 Using where:
sql
EXPLAIN SELECT * FROM orders WHERE remark = '春节延迟发货';
-- Extra: Using where ------ remark 没有索引,只能全表扫完后在 server 层挨个判断
Using temporary 也值得留意,它出现在很多 DISTINCT、GROUP BY 场景里,说明 MySQL 得现造一张临时表来完成去重或分组:
sql
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Extra: Using temporary; Using filesort
这里有个容易被忽略的细节:GROUP BY 默认会隐式带上 ORDER BY,所以哪怕语句里没写排序,也会同时出现 Using filesort。如果确实不需要排序,显式写上 ORDER BY NULL 就能把这个提示去掉:
sql
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status ORDER BY NULL;
-- Extra: Using temporary (Using filesort 消失了)
八、JSON 格式执行计划:看到真实成本
rows、filtered 说到底都是估算,想知道优化器算出来的成本具体是多少,可以在 EXPLAIN 和查询语句之间加上 FORMAT=JSON:
sql
EXPLAIN FORMAT=JSON SELECT * FROM orders INNER JOIN users ON orders.user_id = users.id
WHERE orders.status = 'closed';
输出里每张表都带一个 cost_info:
json
"cost_info": {
"read_cost": "980.32",
"eval_cost": "102.15",
"prefix_cost": "1082.47",
"data_read_per_join": "2M"
}
不用深究 read_cost、eval_cost 各自怎么算的,只需要盯住 prefix_cost ------它是"截止到这张表为止"的累计成本,所以最后一张表的 prefix_cost,就是整条查询预计的总成本,拿来对比不同写法孰优孰劣非常直接。
九、SHOW WARNINGS:查看语句被优化成什么样
EXPLAIN 之后紧接着执行一句 SHOW WARNINGS,如果返回的 Code 是 1003,Message 会给出优化器重写后大致的样子:
sql
EXPLAIN SELECT orders.order_no, users.mobile
FROM orders LEFT JOIN users ON orders.user_id = users.id
WHERE users.mobile IS NOT NULL;
SHOW WARNINGS;
-- Message 里 LEFT JOIN 变成了 JOIN
-- 因为 users.mobile IS NOT NULL 这个条件,让左连接失去了保留 orders 未匹配行的意义
-- 优化器索性把它优化成了普通内连接
需要注意的是,Message 展示的只是帮助理解的参考,并不是能直接拿去执行的标准 SQL。
把这几列串起来看:table/id/select_type 告诉你这一行说的是哪张表、属于哪个查询;type/possible_keys/key/key_len 告诉你这张表打算怎么被访问;ref/rows/filtered 告诉你访问的规模有多大;Extra 补充那些没地方安放的细节。把这套读法练熟,看一眼执行计划基本就能判断一条慢查询卡在哪一步。