MySQL - EXPLAIN 执行计划

从零理解 EXPLAIN 执行计划

来源:《MySQL 是怎样运行的:从根儿上理解 MySQL》第十六章「Explain 详解(上)」、第十七章「Explain 详解(下)」读书笔记。内容为个人理解重述,非原文摘录;示例为自行设计,与原书示例无关。

一条查询语句写下去之后,MySQL 会先经过查询优化器的一番处理------按成本和规则挑连接顺序、挑每张表的访问方式------最终生成一份"执行计划"。EXPLAIN 就是用来把这份计划摊开给我们看的工具。这篇笔记按 EXPLAIN 输出的各个列逐一说明,并在容易混淆的地方配上示例。

目录

  1. [为什么需要 EXPLAIN](#为什么需要 EXPLAIN "#sec-1")
  2. [id 与 table:这一行说的是谁](#id 与 table:这一行说的是谁 "#sec-2")
  3. select_type:这个小查询是什么身份
  4. type:单表访问方式的性能排行
  5. [possible_keys / key / key_len](#possible_keys / key / key_len "#sec-5")
  6. [ref / rows / filtered](#ref / rows / filtered "#sec-6")
  7. Extra:常见提示解析
  8. [JSON 格式执行计划:看到真实成本](#JSON 格式执行计划:看到真实成本 "#sec-8")
  9. [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 则会引入多个 SELECTid 也跟着变多:

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 因为要去重,会额外借助一张临时表,执行计划里会多一行 idNULLtable 显示成 <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 子查询被物化后再与外层做连接

其中 SUBQUERYDEPENDENT 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';

DERIVEDMATERIALIZED 也容易搞混,区别在于物化出来的表是被当成"派生表"直接查询,还是被拿去跟外层表做连接

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 也值得留意,它出现在很多 DISTINCTGROUP 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 格式执行计划:看到真实成本

rowsfiltered 说到底都是估算,想知道优化器算出来的成本具体是多少,可以在 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_costeval_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 补充那些没地方安放的细节。把这套读法练熟,看一眼执行计划基本就能判断一条慢查询卡在哪一步。

相关推荐
小大宇1 小时前
mongoDB dump技巧
数据库·mongodb
huijingjituan1 小时前
三方聊天软件工具定制开发|打造企业级智能通讯平台
数据库·安全·阿里云·实时互动·腾讯云
zyplayer-doc1 小时前
研发接口文档怎么长期维护:zyplayer-doc把API、Markdown和变更记录放进同一个知识库
大数据·数据库·人工智能·笔记·pdf·ocr
NineData1 小时前
Oracle 卡住时先看阻塞源,ChatDBA 会先把锁链路理出来
数据库·oracle·ninedata·锁故障·锁等待·chatdba·锁阻塞
Macbethad2 小时前
基于WPF与.NET的洁净厂房数据孪生平台技术方案:架构、实现与SEMI标准实践
数据库·系统架构
咏方舟【长江支流】2 小时前
【开源】跨语言·跨平台·跨数据库(6) ——一种ORM缓存的接口实现和容错处理
数据库·缓存·开源·咏方舟-长江支流·用宝框架
努力进修2 小时前
破除工业 AI 业务落地壁垒:多模时序融合架构重塑设备全维度数据价值
数据库·人工智能·架构
静水楼台x2 小时前
spring security
java·数据库·spring
深度研习笔记2 小时前
OpenCV工业视觉进阶14|SQLite违规数据库存储+历史记录查询+Excel报表导出,完成项目商用数据闭环(验收必备)
数据库·opencv·sqlite