一、查询场景与优化目标
店里有 10000 张订单,老板要找:42 号顾客最近已付款的 5 单。
SELECT id, customer_id, status, created_at
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 5;status = 1 表示已付款;按下单时间倒序,时间相同再按单号倒序,取前 5 单。练习库和数据会在 3.2 创建。
同一个问题,可以有三种找法:
| 找法 | 对应的数据库操作 |
|---|---|
| 翻遍 10000 张订单,再筛选、排序 | 没有合适索引,全表扫描 |
| 按顾客目录找到他的 100 张,再筛选、排序 | 使用 customer_id 单列索引 |
| 按“顾客 → 状态 → 时间 → 单号”目录,顺着拿够 5 张 | 使用匹配条件和排序的联合索引 |
EXPLAIN 看“准备怎么找”,EXPLAIN ANALYZE 真正执行,查看每一步实际做了多少工作。 后面的实验就对比这三种找法。
二、执行计划基础
2.1 EXPLAIN:预估执行计划与关键字段
普通 EXPLAIN 展示优化器的预估方案。先弄明白下面这个写法:
FORMAT=TRADITIONAL 就是“把执行计划按传统表格形式展示”。
FORMAT:展示格式。TRADITIONAL:传统的,这里指一行一行的表格。
例如,下面这条 SQL 是查看“按顾客号找订单”的执行计划。这里只认识写法,先不用运行;练习库和表会在 3.2 创建,idx_customer 索引会在 3.4 创建。
-- FORMAT=TRADITIONAL:把预估执行计划显示成表格。
EXPLAIN FORMAT=TRADITIONAL
SELECT id
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42;结果会像这样(只摘出部分列,数值为示意,不是实测):
| id | table | type | key | rows | Extra |
|---|---|---|---|---|---|
| 1 | orders | ref | idx_customer | 100 | Using index |
对着列名找信息:用了哪个索引,看 key;预计检查多少行,看 rows。这里第一列 id 是执行计划中的查询标识,不是订单表的订单号。
如果把展示格式换成 TREE:
-- FORMAT=TREE:用树形文字展示预估执行计划,仍然不是真实执行统计。
EXPLAIN FORMAT=TREE
SELECT id
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42;结果会类似下面这样(同样只是示意):
-> Covering index lookup on orders using idx_customer (customer_id=42) ...可以把它读成:“通过 idx_customer 索引找 42 号顾客的订单,索引里的信息就够用了。”
两种格式是在用不同方式展示执行计划。FORMAT 不会给表加索引,也不是优化 SQL 的开关。 本文统一写 FORMAT=TRADITIONAL,就是为了让结果按表格展示,方便你对照列名阅读。
接下来先盯住表格里的四处:
| 看哪里 | 你在问什么 | 本次最关心的变化 |
|---|---|---|
type | 怎么找? | ALL 全表扫描 → ref 按普通索引找一批 |
key | 实际用哪个目录? | NULL → 索引名称 |
rows | 预计检查多少行? | 大约 10000 → 大约 100;这是估算 |
Extra | 还得做什么? | Using filesort 是否消失,是否出现 Using index |
再认识两个容易混淆的词:
Using filesort:需要额外排序,不等于一定落盘。Using index:所需列可以从索引拿齐,称为覆盖索引;不只是“用过索引”。
2.2 EXPLAIN ANALYZE:实际执行统计
先把耗时、cost、loops 都放一边,只看每一步“交给下一步多少张订单”。
还是老板那句话:找出 42 号顾客最近已付款的 5 单。
你可以想象四个人接力干活:
| 谁在干活 | 他做什么 | 交给下一个人多少张 |
|---|---|---|
翻柜子的人 Table scan | 把全部订单拿出来,逐张往下传 | 10000 张 |
挑订单的人 Filter | 只留下“42 号顾客、已付款”的订单 | 50 张 |
排顺序的人 Sort | 看过这 50 张,找出最新的 5 张,按顺序往下传 | 5 张 |
控制数量的人 Limit | 收够 5 张就停止接收 | 5 张 |
rows 就是这一行里的那个人,实际往外传了多少张。 这里四个步骤都只执行一次,即 loops=1。
所以执行计划先简化成这样看。下面是阅读示意,不是本实验实测输出:
Limit rows=5 最终交给你 5 张
Sort rows=5 向上交出排好序的 5 张
Filter rows=50 筛出 50 张符合条件的订单
Table scan rows=10000 从表里读出 10000 张缩进表示“上面这一步,依赖下面那一步提供订单”。因此先从最下面看:
10000 张订单
↓ 挑出顾客是 42,并且已付款的
50 张订单
↓ 比较时间,取最新的
5 张订单
↓ 返回
你看到的 5 条结果最容易误解的是 Sort 的 rows=5:
它表示“往外给了 5 张”,并不表示“只看了 5 张”。它接收了下面筛出来的 50 张,才能判断哪 5 张最新。
再看时间,例如(数字是假设值):
actual time=0.1..0.3 rows=5 loops=1可以先读成:
这一步执行了 1 次;从开始计时,到交出第一张用了 0.1 毫秒,到完成用了 0.3 毫秒,一共交出 5 张。
0.3 毫秒已经包含它等待、读取下面步骤的时间,所以不要把各层时间相加。
你现在只需要会找两个数字:
- 最下面的读取节点
rows=10000:数据库读了多少行? - 最上面的
Limit rows=5:最后只给了我多少行?
这次实验想让你看到的就是:最后只要 5 行,数据库却可能先读了 10000 行。
三、索引优化对比实验
3.1 前置准备与执行方式
你当前的版本是 MySQL 8.0.31,当前默认数据库是 1test(来自你的查询截图)。这个版本可以运行本文实验,不需要为了练习升级到 8.4。
| 本文用到的功能 | MySQL 8.0.31 是否支持 |
|---|---|
EXPLAIN FORMAT=TRADITIONAL:表格计划 | 支持;该版本普通 EXPLAIN 默认也是表格 |
EXPLAIN FORMAT=TREE:树形计划 | 支持 |
EXPLAIN ANALYZE FORMAT=TREE:实际执行统计 | 支持;ANALYZE 从 8.0.18 引入,显式 FORMAT=TREE 从 8.0.21 支持 |
联合索引中 created_at DESC, id DESC:降序索引 | 支持 |
继续使用你截图里的数据库客户端即可,新建查询窗口,按小节顺序执行。本文语法以 MySQL 8.0 为依据,计划具体文字、估算和耗时以你在 8.0.31 的输出为准。
命令行用户也可以用 mysql -h 127.0.0.1 -u 你的用户名 -p 连接,再粘贴 SQL;密码在提示时输入。
先运行:
-- 确认连接版本及当前默认库,避免在错误的连接里练习。
SELECT VERSION(), DATABASE();DATABASE() 返回 1test,表示当前默认库,并不是版本号的一部分。 后面的 SQL 都写完整的 explain_milk_tea_lab.orders,所以操作对象是独立练习库,不会因为当前选着 1test 就把表建到那里;无需先执行 USE 1test。
本篇只提供代码,尚未替你连接数据库执行。下方初始化及建索引会写数据库,由你在练习实例手动运行;如果要我代执行,须先按数据库操作约定核对完整 SQL 和影响范围,再由你回复“确认执行”。
3.2 实验表与测试数据初始化
目标:explain_milk_tea_lab 数据库;新建 digits、orders 两张表,分别写入 10 行、10000 行。 不修改业务表,不包含清空或删除操作。数据库和表名不使用 IF NOT EXISTS:如果重名就报错停下,避免悄悄复用旧数据。
这段只执行一次;任一句报错都先停下,不要接着执行余下语句。
CREATE DATABASE explain_milk_tea_lab;
-- 10 个数字,后面通过四次组合生成 0~9999,不需要递归或存储过程。
CREATE TABLE explain_milk_tea_lab.digits (
d TINYINT NOT NULL PRIMARY KEY
) ENGINE=InnoDB;
INSERT INTO explain_milk_tea_lab.digits (d)
VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9);
-- 起初只有主键,没有 customer_id 的索引。
-- note 模拟订单备注,留到最后观察“索引能不能把所需列拿齐”。
CREATE TABLE explain_milk_tea_lab.orders (
id INT NOT NULL PRIMARY KEY,
customer_id INT NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL,
note VARCHAR(200) NOT NULL
) ENGINE=InnoDB;
INSERT INTO explain_milk_tea_lab.orders
(id, customer_id, status, created_at, note)
SELECT n + 1,
MOD(n, 100) + 1,
MOD(FLOOR(n / 100), 2),
DATE_ADD('2026-01-01 00:00:00', INTERVAL n MINUTE),
RPAD(CONCAT('奶茶订单-', n + 1, '-'), 100, 'x')
FROM (
SELECT a.d + b.d * 10 + c.d * 100 + d.d * 1000 AS n
FROM explain_milk_tea_lab.digits a
CROSS JOIN explain_milk_tea_lab.digits b
CROSS JOIN explain_milk_tea_lab.digits c
CROSS JOIN explain_milk_tea_lab.digits d
) AS numbers;验收数据,预期精确为 10000 / 100 / 50:
SELECT COUNT(*) AS total_orders,
SUM(customer_id = 42) AS customer_42_orders,
SUM(customer_id = 42 AND status = 1) AS customer_42_paid
FROM explain_milk_tea_lab.orders;为什么我能提前知道? 每连续 100 张订单,每位顾客各有 1 单;100 批共 100 单。付款状态每批切换一次,所以每人恰好 50 单已付款。它没有把“42 号顾客”与付款状态绑定,避免造出某顾客全部未付款的假场景。
3.3 第一轮:全表扫描
先确认:你已完成 3.2 初始化,还没有执行 3.4、3.5 的建索引语句。这一轮要观察只有主键、没有顾客索引时,MySQL 怎么找订单。
下面两条 SQL 分开执行、分开看结果。它们问的是同一个问题:“42 号顾客最近已付款的 5 单是什么?”但给你的观察结果不同。
3.3.1 查看预估执行计划
-- 只查看预估执行计划,按表格展示,不返回订单数据。
EXPLAIN FORMAT=TRADITIONAL
SELECT id, customer_id, status, created_at
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 5;执行后会产生什么?
你会得到一张执行计划表,通常有一行。下面仅摘出关键列,这是预期示意,不是你实例的实测结果:
| table | type | key | rows | Extra |
|---|---|---|---|---|
| orders | ALL | NULL | 约 10000 | Using where; Using filesort |
它不会返回 5 张订单,而是在告诉你:“我准备怎样找这 5 张订单。”
这张表具体看什么?按顺序看四处:
| 看哪列 | 第一轮预期看到什么 | 你应该怎样理解 |
|---|---|---|
type | ALL | 准备扫描整张订单表,相当于挨张翻柜子 |
key | NULL | 没有选择用于这次查询的索引;不代表表没有主键 |
rows | 接近 10000,未必正好 10000 | 预计检查这么多行;这是估算,还不是实际读取量 |
Extra | Using where; Using filesort | 读出来后要筛顾客和付款状态,还需要额外按时间、单号排序 |
此时先得出一个计划层面的判断:MySQL 打算翻整张表,再筛选、排序。接下来用第二条 SQL 看它实际干了多少活。
3.3.2 获取实际执行统计
-- ANALYZE 会真正执行这条 SELECT,返回执行统计,不返回订单结果集。
EXPLAIN ANALYZE FORMAT=TREE
SELECT id, customer_id, status, created_at
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 5;你实际执行出来的结果如下,原样保留:
-> Limit: 5 row(s) (cost=1012.65 rows=5) (actual time=3.803..3.805 rows=5 loops=1)
-> Sort: orders.created_at DESC, orders.id DESC, limit input to 5 row(s) per chunk (cost=1012.65 rows=9724) (actual time=3.803..3.804 rows=5 loops=1)
-> Filter: ((orders.`status` = 1) and (orders.customer_id = 42)) (cost=1012.65 rows=9724) (actual time=0.178..3.723 rows=50 loops=1)
-> Table scan on orders (cost=1012.65 rows=9724) (actual time=0.119..3.073 rows=10000 loops=1)这是你已经跑过的 SELECT 的执行统计,不是订单结果集。下面像回放一样,把四个人干的活逐步拆开。
3.3.3 执行节点字段解析
以最下面这一行为例:
-> Table scan on orders
(cost=1012.65 rows=9724)
(actual time=0.119..3.073 rows=10000 loops=1)| 原文 | 大白话解释 | 你的这次结果 |
|---|---|---|
-> 和缩进 | 一个处理步骤;缩进更深的是上面步骤的数据来源 | 扫表提供数据,过滤步骤接着处理 |
Table scan on orders | 扫描 orders 表 | 挨张翻订单 |
cost=1012.65 | 优化器事前估算的成本分值 | 不是 1012.65 毫秒,也不是订单数量 |
第一个 rows=9724 | 事前估计这一步会输出多少行 | 估计读出 9724 行 |
actual | 从这里开始是真实执行统计 | 与前面的预估分开读 |
time=0.119..3.073 | 从这个节点启动,到产出首行、完成的耗时,单位毫秒 | 首行 0.119 毫秒,完成 3.073 毫秒 |
第二个 rows=10000 | 这一步实际输出多少行 | 实际读出 10000 行 |
loops=1 | 这一步执行了几轮 | 执行一轮,不是只读一行 |
本次所有节点 loops 都是 1,所以可以直接读这些行数与时间。以后若 loops 大于 1,实际行数和时间按每轮平均值理解,不能把平均值当成全部循环的总量。
3.3.4 Table scan:全表扫描节点
Table scan on orders
(cost=1012.65 rows=9724)
(actual time=0.119..3.073 rows=10000 loops=1)翻订单的人说:
我原先估计柜子里大概有 9724 张订单。真正翻了一遍,向上交出了 10000 张。第一张在 0.119 毫秒时交出,整个扫表步骤在 3.073 毫秒时完成。
为什么 9724 不等于 10000? InnoDB 的计划行数来自统计估算,并非每次生成计划都先精确 COUNT 全表。这里差 276 行,约为实际总量的 2.76%,单凭这个差距不需要急着更新统计信息。
这里没有按顾客定位的索引访问节点,而是 Table scan:实际确实翻了整张表。 这些时间是执行节点统计,不能据此判断发生了多少次物理磁盘读取。
3.3.5 Filter:条件过滤节点
Filter: ((orders.`status` = 1) and (orders.customer_id = 42))
(cost=1012.65 rows=9724)
(actual time=0.178..3.723 rows=50 loops=1)先把条件翻译成人话:
orders.status = 1:订单已经付款。and:两个条件必须同时成立。orders.customer_id = 42:订单属于 42 号顾客。orders.是表名前缀,反引号只是标识符的引用写法,不改变筛选含义。
挑订单的人说:
下面传来的 10000 张订单我都检查了,只有 50 张同时满足这两个条件。我在 0.178 毫秒时交出第一张合格订单,到 3.723 毫秒时完成这一步。
这里实际过滤掉了 9950 行,只保留了 0.5%。 结合底下扫描节点的 10000 行,才知道它检查过多少数据;单看 Filter 的 rows=50,只能知道它向上交出了 50 行。
这里的预估 rows=9724 却没有体现实际的过滤比例,与实际 50 相差约 194 倍。可以确定的是:这个节点的行数估算没有反映这次真实筛选结果;不能只凭这份文本就断言统计信息损坏,或必须执行 ANALYZE TABLE。
注意 3.723 毫秒包括从下面扫表取数据的时间,不是“扫表花 3.073,再单独过滤花 3.723”。各节点的时钟也不是同一条全局时间轴。
3.3.6 Sort:排序节点
Sort: orders.created_at DESC, orders.id DESC,
limit input to 5 row(s) per chunk
(cost=1012.65 rows=9724)
(actual time=3.803..3.804 rows=5 loops=1)把排序规则拆开:
| 原文 | 意思 |
|---|---|
orders.created_at DESC | 先按下单时间倒序,新的在前 |
orders.id DESC | 如果下单时间相同,再按单号倒序,大的在前 |
limit input to 5 row(s) per chunk | 排序器识别到了只需要前 5 行,可以采用限制排序候选的策略 |
最后那句是内部排序优化的说明,不是说只从表里读取 5 行,也不是说把整张表每 5 行分一组分别取结果。这次下面的 Filter 已经实际交出了 50 行,排序器需要消费这些候选,才能知道谁是最新的 5 行。仅凭这句话也不能推断是否使用了磁盘。
排顺序的人说:
我得先看过合格订单,才能知道谁最新。因此到 3.803 毫秒才交出第一张排好序的订单,到 3.804 毫秒完成,向上交出了 5 张。
为什么这里的两个时间挨得很近? 因为排序需要先处理输入;等第一张有序结果能交出时,主要准备工作已经完成,后面几张很快就能交出。
不要用 3.804 - 3.803 = 0.001 毫秒当作全部排序成本。这只是首行产出到节点完成的间隔,前面准备结果的工作也属于该节点耗时,而且包括子节点工作。
同理,此处预估 rows=9724 不表示实际排了 9724 张。实际输入看下面 Filter 的 50,实际输出看本节点的 5。 各层相同的 cost 也不能相加,更不表示各层实际耗时相同。
3.3.7 Limit:返回行数限制节点
Limit: 5 row(s)
(cost=1012.65 rows=5)
(actual time=3.803..3.805 rows=5 loops=1)控制数量的人说:
老板只要 5 张。我从下面接收排好序的结果,收够 5 张就停。这次预估与实际都是 5 行,首行在 3.803 毫秒产出,到 3.805 毫秒完成。
顶层约 3.805 毫秒是这次 ANALYZE 记录的根节点执行耗时,包含下层节点的工作;它不等于数据库客户端显示的完整往返耗时,也不是未来每次执行都会固定为这个数。
LIMIT 5 限制最终输出数量。它没有让本次扫表也只读 5 行:没有合适的有序索引时,仍得先找出候选、确定谁最新。
3.3.8 执行流程与统计汇总
| 顺序 | 实际做了什么 | 输入 → 输出 | 节点完成耗时(包含子节点) |
|---|---|---|---|
| 1 | Table scan:扫整张表 | 订单表 → 10000 行 | 3.073 毫秒 |
| 2 | Filter:筛顾客与付款状态 | 10000 → 50 行 | 3.723 毫秒 |
| 3 | Sort:按时间、单号倒序提供前 5 行 | 50 → 5 行 | 3.804 毫秒 |
| 4 | Limit:最多产出 5 行 | 5 → 5 行 | 3.805 毫秒 |
不要把最后一列相加。 它们是各个节点自己的计时,外层包含下层的工作。
现在可以用你真实的数字说出结论:
这次查询为了找 42 号顾客最近已付款的 5 单,扫描了 10000 行,筛出 50 行,再排序取出 5 行。根节点完成耗时约 3.805 毫秒。虽然现在耗时很短,但这条访问路径仍需要扫整张表。
第一条传统 EXPLAIN 的真实表格尚未贴入文档,3.3.1 的表格仍是预期示意,不能把其中的值当作实测。你目前这份 ANALYZE 已经能证实第一轮的完整数据处理过程。
3.4 第二轮:单列索引查询
老板加了一份按顾客号排列的目录。下面只给练习表新增一个索引,不改变订单内容:
CREATE INDEX idx_customer
ON explain_milk_tea_lab.orders (customer_id);把 3.3 的两条 EXPLAIN 原样再执行一遍。 查询完全不动,只改变索引,才方便看清因果。
-> Limit: 5 row(s) (cost=26.00 rows=5) (actual time=0.753..0.753 rows=5 loops=1)
-> Sort: orders.created_at DESC, orders.id DESC, limit input to 5 row(s) per chunk (cost=26.00 rows=100) (actual time=0.750..0.750 rows=5 loops=1)
-> Filter: (orders.`status` = 1) (actual time=0.117..0.723 rows=50 loops=1)
-> Index lookup on orders using idx_customer (customer_id=42) (actual time=0.109..0.710 rows=100 loops=1)| 观察点 | 第一轮预期 | 第二轮预期 |
|---|---|---|
type(访问数据的方式) | ALL(全表扫描,挨张翻整柜订单) | ref(按普通索引的等值条件,找到顾客号为 42 的一批订单;不是只找一行) |
key(实际选择的索引) | NULL(本次查询没有选择索引) | idx_customer(使用刚创建的顾客号索引,先定位这位顾客的订单) |
| 底层读取节点(从哪里、怎样取出数据) | Table scan,10000 行(扫整张表,读出全部订单) | Index lookup,100 行(沿顾客号索引查找,读出 42 号顾客的 100 张订单) |
| 付款过滤后的行数(筛完后剩多少) | 50(从 10000 张里筛出这位顾客已付款的 50 张) | 50(已定位这位顾客的 100 张,再筛出已付款的 50 张) |
Using filesort(需要额外排序) | 有(把筛出的 50 张订单按时间、单号倒序排列,找最新的 5 张) | 仍然有(顾客号索引没有按下单时间排好,仍需要额外排序) |
| 最终返回(SELECT 最终产出多少行) | 5(最新的 5 张已付款订单) | 5(答案相同,寻找它们时读取的数据更少) |
这里 type、key、Using filesort 对照第一条 SQL 的表格看;实际读取、过滤和最终产出行数,对照第二条 SQL 中各节点 actual 后的 rows 看。上表第二轮仍是预期,具体以你运行后的结果为准。
为什么还要排序?目录只把顾客分好了类,没替你按付款状态和时间排列。他的 100 张订单仍要筛成 50 张,再找出最新 5 张。
已经走索引,为什么还有优化空间?
“用过目录”只回答了一半问题。你还得问:目录帮我缩小到多少张?剩下的排序省了吗?
3.5 第三轮:联合索引优化过滤与排序
这次让目录在每位顾客下面继续按状态分类,再把时间和单号从大到小排好。新增第二个索引,保留上轮索引用于对照:
CREATE INDEX idx_customer_status_time
ON explain_milk_tea_lab.orders
(customer_id, status, created_at DESC, id DESC);再次原样执行 3.3 的两条 EXPLAIN。
下面是你在 MySQL 8.0.31 上实际得到的第三轮 ANALYZE 结果。它是执行计划输出,不是可执行 SQL:
-> Limit: 5 row(s) (cost=6.04 rows=5) (actual time=0.476..0.478 rows=5 loops=1)
-> Covering index lookup on orders using idx_customer_status_time (customer_id=42, status=1) (cost=6.04 rows=50) (actual time=0.238..0.239 rows=5 loops=1)3.5.1 覆盖索引查找节点解析
Covering index lookup on orders using idx_customer_status_time (customer_id=42, status=1)| 原文 | 什么意思 | 放回奶茶店场景 |
|---|---|---|
Covering(覆盖) | 本次查询所需的列都能从这个索引取得,不需要为取得其他列而回表 | 目录上已经有要看的全部信息,不必再找完整订单 |
index lookup(索引查找) | 根据给定条件定位索引中的匹配记录 | 直接找到顾客 42 的已付款分区 |
on orders | 查询对象是 orders 表 | 查的是订单 |
using idx_customer_status_time | 实际使用这个联合索引 | 用的是“顾客 → 状态 → 时间倒序 → 单号倒序”目录 |
(customer_id=42, status=1) | 用顾客号和付款状态这两个等值条件进行定位 | 一开始就锁定“42 号顾客、已付款”,不用先找出他的所有订单再筛付款状态 |
这里查询只需要 id、customer_id、status、created_at,这四列都在联合索引里,所以是 Covering。若再取索引里没有的 note,就不再满足这种覆盖条件。
3.5.2 预估行数与实际输出行数的差异
(cost=6.04 rows=50)
(actual time=0.238..0.239 rows=5 loops=1)| 字段和值 | 什么意思 |
|---|---|
cost=6.04 | 优化器估算的成本分值,不是毫秒,也不是读取次数 |
预估 rows=50 | 预计这个顾客有 50 张已付款订单,与你的造数规则一致 |
actual time=0.238..0.239 | 这个索引节点首行产出耗时约 0.238 毫秒,完成耗时约 0.239 毫秒 |
实际 rows=5 | 这次索引访问实际向上交出了 5 行,没有把全部 50 行都交出来 |
loops=1 | 这个节点执行了一轮,不是只读取了一条记录 |
这次 50 与 5 的差别,不是估算失准,而是 LIMIT 让执行提前停止了。
联合索引前两列已经固定为顾客 42、状态 1,后面的 created_at DESC, id DESC 恰好满足查询顺序。因此:
定位顾客 42、状态 1 的索引区间
↓ 这里的记录已经按所需顺序排列
依次交出第 1、2、3、4、5 行
↓ 上面的 Limit 已经收够
停止继续取后面的匹配记录所以原先的 Filter 和 Sort 节点都不需要了:两个筛选条件直接用于索引定位,顺序直接由索引提供。这里的“5 行”是该节点输出的记录数,不代表总共只有 5 次内存访问或磁盘读取;索引树定位本身也需要工作。
3.5.3 LIMIT 提前终止与耗时分析
Limit: 5 row(s)
(cost=6.04 rows=5)
(actual time=0.476..0.478 rows=5 loops=1)Limit: 5 row(s)(最多产出 5 行):对应 SQL 的LIMIT 5。- 预估
rows=5(预计最终产出 5 行):优化器考虑了数量限制。 - 实际
rows=5(本次确实产出 5 行):这 5 行就是按指定顺序取得的订单,ANALYZE 返回的是统计而非订单内容。 actual time=0.476..0.478(首行、完成耗时):顶层节点首行约 0.476 毫秒,完成约 0.478 毫秒。loops=1(执行一轮):本次没有反复执行这个节点。
为什么顶层时间比索引节点时间大? 每个节点独立计时,顶层包含调用下层及自身处理等工作,计时范围不同。不能仅凭这两行精确归因两者的差额,也不能把 0.478 + 0.239 当成总耗时。本次根节点完成耗时约 0.478 毫秒。
3.5.4 全表扫描与联合索引实测对比
| 观察点 | 第一轮实测 | 第三轮实测 |
|---|---|---|
| 取数据方式 | Table scan(全表扫描) | Covering index lookup(覆盖索引查找) |
| 读取节点实际输出 | 10000 行(整张表) | 5 行(按索引顺序取够就停) |
| 单独的 Filter 节点 | 有(10000 行里筛出 50 行) | 无(顾客、状态直接用于索引定位) |
| 单独的 Sort 节点 | 有(接收 50 行候选,产出前 5 行) | 无(索引已经提供所需顺序) |
| 为取得其他列而回表 | 全表扫描场景不适合用这个说法对比 | 不需要(查询所需列都在索引里) |
| 最终产出 | 5 行 | 5 行 |
| 根节点完成耗时 | 约 3.805 毫秒 | 约 0.478 毫秒 |
这两次记录里,根节点耗时约相差 8 倍,但这是两次测量,不能保证每次都快 8 倍;缓存和当时负载都会影响耗时。更稳定的证据是:读取节点输出从 10000 行变成 5 行,且不再需要单独过滤和排序。
你这次贴的是树形统计,已经能确认上述变化。第一条传统 EXPLAIN 的 key 预期是 idx_customer_status_time,Extra 预期出现 Using index 且没有 Using filesort;它们仍需以表格的真实输出核对,不冒充已看到的结果。
3.6 查询结果一致性验证
现在把第一节的普通 SELECT 执行一次,真正看订单。
这套数据下,返回的 id 必须依次为:
9942
9742
9542
9342
9142它们的 customer_id 都是 42,status 都是 1,created_at 从新到旧。索引改变寻找方式,不改变这条确定排序查询的答案。
最终对照表:
| 轮次 | 生活里的找法 | 预期底层读取量 | 排序 | 返回量 |
|---|---|---|---|---|
| 无顾客索引 | 整柜翻一遍 | 10000 | 需要 | 5 |
| 单列索引 | 翻这位顾客的订单 | 100 | 需要 | 5 |
| 联合索引 | 直接到已付款分区,拿最新 5 张 | 约 5 | 省掉 | 5 |
第一、三轮 ANALYZE 的实测结果已记录在 3.3、3.5;上表第二轮仍是基于结构和数据分布推导的预期,尚未记录你实例的实测结果。精确数据总数、筛选结果和订单 id 可以算定;优化器是否选该计划、预估行数和耗时必须以你的输出为准。
四、扩展实验:回表与访问类型
4.1 查询非索引列与回表
联合索引里没有备注,老板却又要看备注。保持条件和排序不变,只在 SELECT 中多取 note。
先看传统表格计划:
EXPLAIN FORMAT=TRADITIONAL
SELECT id, customer_id, status, created_at, note
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 5;再看实际执行统计。你贴出的树形结果对应下面这条 ANALYZE,而不是上面的传统表格格式:
-- 真正执行 SELECT,观察加入 note 后的访问方式和实际行数。
EXPLAIN ANALYZE FORMAT=TREE
SELECT id, customer_id, status, created_at, note
FROM explain_milk_tea_lab.orders
WHERE customer_id = 42 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 5;你的实际结果(这是输出,不是可执行 SQL):
-> Limit: 5 row(s) (cost=17.50 rows=5) (actual time=2.514..2.517 rows=5 loops=1)
-> Index lookup on orders using idx_customer_status_time (customer_id=42, status=1) (cost=17.50 rows=50) (actual time=2.251..2.254 rows=5 loops=1)4.1.1 覆盖索引与非覆盖索引访问对比
| 对比 | 第三轮:不查 note | 这次:多查 note |
|---|---|---|
| 访问节点 | Covering index lookup(覆盖索引查找,信息在索引里拿齐) | Index lookup(索引查找,但索引不能提供全部所需列) |
| 使用的索引 | idx_customer_status_time | 仍是 idx_customer_status_time(没有因为多取备注就放弃索引) |
note 从哪里取 | 不需要 | 按索引记录中的主键,到聚簇索引中取完整记录里的备注,即回表 |
| 是否出现 Sort 节点 | 没有 | 仍然没有(索引顺序仍满足 ORDER BY) |
| 索引访问节点实际输出 | 5 行 | 仍是 5 行(LIMIT 仍能提前停止) |
放回奶茶店场景:目录已经能告诉你哪 5 张订单最新,但没写备注;你得按目录上的单号,再找到这 5 张完整订单取备注。
回表工作包含在这个 Index lookup 节点中,不一定另外显示一个“回表”节点。这里的 rows=5 是节点输出 5 行,不是磁盘读取次数,也不是对内部每次记录访问的逐次计数。
4.1.2 执行统计解析
| 原文 | 什么意思 |
|---|---|
using idx_customer_status_time | 实际仍使用这个联合索引 |
(customer_id=42, status=1) | 直接按顾客号和付款状态定位匹配区间 |
cost=17.50 | 优化器估算的成本分值;比上次的 6.04 高,与需要取完整记录的访问方式一致,但不能把差值当作实际回表耗时 |
索引节点预估 rows=50 | 预计有 50 行符合顾客、付款条件 |
索引节点实际 rows=5 | 有序地提供前 5 行后,上面的 LIMIT 收够了,不必取完 50 行 |
actual time=2.251..2.254 | 索引访问节点首行产出约 2.251 毫秒,完成约 2.254 毫秒,包含取得所需记录的工作 |
Limit 的预估和实际 rows=5 | 预计最终产出 5 行,本次也确实产出 5 行 |
顶层 actual time=2.514..2.517 | 整个根节点首行产出约 2.514 毫秒,完成约 2.517 毫秒;不能与下层时间相加 |
两处 loops=1 | 两个节点各执行一轮,不是只取一条记录 |
4.1.3 回表开销与索引排序
这次根节点完成耗时约 2.517 毫秒,第三轮那次是 0.478 毫秒。多取 note 确实引入了回表取列的工作,但这只是两次测量,不能断言差出的 2.039 毫秒全部来自回表;缓存、负载和运行时开销也会影响结果。
从执行结构可以确定的变化是:
第三轮:索引里信息齐全 → 按顺序交出 5 行 → 结束
这一次:索引里找到订单 → 回表补齐 note → 按顺序交出 5 行 → 结束覆盖索引解决“信息够不够”,索引顺序解决“要不要重新排序”。 多取 note 影响前者,没有改变后者,所以 Covering 消失了,Sort 却没有出现。
如果查看第一条 SQL 的传统表格,key 预期仍为联合索引,Extra 中单独的 Using index 预期消失,也预期没有 Using filesort;这些具体字段值仍以表格实际输出为准。
别把 Using index condition 当成 Using index:前者是索引条件下推,仍可能回表。也别因此给索引塞满备注字段;索引更宽会增加存储与写入维护成本。
4.2 主键等值查询:const
像拿着取餐号找唯一一单,不用筛顾客,也不用排序:
EXPLAIN FORMAT=TRADITIONAL
SELECT id, customer_id, note
FROM explain_milk_tea_lab.orders
WHERE id = 9942;重点看 type=const、key=PRIMARY、rows 通常为 1。主键等值查询最多确定一行。
4.3 主键范围查询:range
EXPLAIN FORMAT=TRADITIONAL
SELECT id, note
FROM explain_milk_tea_lab.orders
WHERE id BETWEEN 9901 AND 9910;重点看 type=range、key=PRIMARY,预估行数通常接近 10。真的执行同一 SELECT,恰好返回 10 张订单。
至此你已经亲手见过 ALL / ref / const / range。先能解释这四种,再去补其余术语,不用先背“性能排名”。
五、常见问题排查
5.1 优化器未选择预期索引
先运行只读检查:
SHOW INDEX FROM explain_milk_tea_lab.orders;
SELECT COUNT(*) FROM explain_milk_tea_lab.orders;
SHOW CREATE TABLE explain_milk_tea_lab.orders;检查索引有没有创建成功、行数是不是 10000、SQL 是否原样执行、是否连到了另一个实例。别先加 FORCE INDEX,否则会掩盖优化器本来的选择。
如果确认是统计信息偏差,可以再评估 ANALYZE TABLE explain_milk_tea_lab.orders;。它会更新统计信息,不是只读查询,不作为本实验必须执行的步骤;让我代执行仍需展示影响并获得“确认执行”。
5.2 语法与重复执行错误
| 现象 | 处理方式 |
|---|---|
| EXPLAIN ANALYZE 语法报错 | 先看 VERSION();你的 8.0.31 支持此语法;检查是否误写为 ANALYZE FORMAT=TRADITIONAL,或实际连到了其他实例 |
| Database/Table already exists | 停下核对是否已经初始化,不要顺手 DROP 或重复 INSERT |
| Duplicate key name | 说明该轮索引可能已创建,先 SHOW INDEX 检查 |
| 传统 EXPLAIN 出来的是树 | 检查是否写了 FORMAT=TRADITIONAL,以及客户端是否在另行展示计划 |
5.3 耗时差异不明显
10000 行在本机内存里可能都很快,客户端显示的总耗时还混有网络与界面开销。这次先比较读取量、Sort 节点和覆盖索引,不要为了看见“慢”而盲目扩大数据。
多跑几次耗时也可能因缓存而变化;读取量从 10000 到 100 再到约 5,才是本次最值得记住的证据。
六、面试回答与常见问题
6.1 EXPLAIN 分析思路
不要一上来把十几列从左到右背一遍。面试官更想知道你能不能根据执行计划定位问题,可以按下面这条线回答:
先看访问对象和执行顺序
→ 再看如何访问数据、是否选对索引
→ 再看预计扫描量和过滤效果
→ 最后看排序、临时表、回表等额外工作
→ 有条件时用 EXPLAIN ANALYZE 对比预估和实际对应到传统表格,重点可以归成四组:
| 关注点 | 主要字段 | 面试时怎么解释 |
|---|---|---|
| 查谁、按什么顺序查 | id、select_type、table | 先确认当前计划属于哪个查询块、正在访问哪张表;多表查询还要关注驱动顺序 |
| 怎么找到数据 | type、possible_keys、key、key_len、ref | 看访问方式、候选索引、实际索引,以及联合索引大致用到了哪些部分 |
| 预计处理多少数据 | rows、filtered | rows 是预计检查行数,filtered 是预计过滤后保留比例;两者结合估算向下一步传递的数据量 |
| 还做了哪些额外工作 | Extra | 关注 Using filesort、Using temporary、Using index、Using index condition 等,但要结合数据量判断 |
面试记忆句
先看从哪查、怎么查、查多少,再看查完还要做什么。最后用 ANALYZE 验证猜得准不准。
6.2 30 秒标准回答
面试官问:“你看 EXPLAIN 一般关注什么?”可以这样回答:
我一般不会只看有没有走索引。首先看
table和执行顺序,确认访问对象;然后看type、possible_keys、key和key_len,判断访问方式以及索引是否合适;接着看rows和filtered,估算扫描量和过滤后的数据量;最后看Extra里有没有额外排序、临时表、覆盖索引等信息。对于重点慢 SQL,我还会用EXPLAIN ANALYZE对比预估行数和实际行数,并关注实际耗时和loops,找出数据在哪一步突然放大。
这段回答的核心不是背字段,而是说明你的排查顺序:访问路径 → 数据量 → 额外工作 → 实际验证。
6.3 1—2 分钟展开回答
如果面试官让你详细说,可以结合本文实验这样展开:
EXPLAIN 是看优化器准备怎么执行 SQL。我拿到计划后,先看
table和执行顺序,尤其是多表关联时,前一张表产生的数据会影响后续表被访问多少次。然后我会看
type。比如大表出现ALL,说明可能在全表扫描;ref表示通过普通索引的等值条件找到一批记录;const通常是通过主键或唯一索引最多确定一行。但访问类型不能单独下结论,还要结合表大小和扫描行数。接着看
possible_keys、key、key_len和ref。possible_keys是候选索引,key是实际选择的索引。key_len可以辅助判断联合索引大致使用了多少部分,但我不会死背字节数,因为它受字段类型、字符集和 NULL 等因素影响。再看
rows和filtered。传统 EXPLAIN 里的 rows 是估算值,我会关注预计扫描量是不是远大于最终返回量,以及前面的节点会向后面传多少数据。比如这篇实验第一轮最后只返回 5 行,实际却先扫描了 10000 行,再过滤成 50 行并排序,这就是明显的优化方向。最后看
Extra,比如Using filesort表示需要额外排序,Using temporary表示使用内部临时表,Using index表示覆盖索引。它们只是线索,不能看到 filesort 就直接判定 SQL 很慢,还要看参与处理的数据量。如果环境允许,我会再执行
EXPLAIN ANALYZE。它会真正运行查询,我会对比预估 rows 和 actual rows,再看 actual time、loops 以及树中哪个节点输入输出差距大。本文加联合索引后,计划从扫描 10000 行、过滤和排序,变成 Covering index lookup 实际交出 5 行,这种前后对比比只说“走索引了”更有说服力。
回答到这里,已经体现了三个能力:知道字段含义、知道阅读顺序、能用实际数据判断问题。
6.4 高频追问与短答
6.4.1 type 出现 ALL,就一定有问题吗?
不一定。ALL 表示全表扫描,但小表扫描几十行可能比走索引再回表更便宜。需要结合表大小、实际扫描行数、调用频率和耗时判断;如果是大表、高频查询且过滤后只留很少数据,就值得重点检查。
6.4.2 key 有值,就说明 SQL 很快吗?
不说明。key 有值只代表选择了某个索引。如果索引区分度低、命中数据太多、需要大量回表,或者后面仍要排序和临时表,查询仍然可能很慢。还要继续看 rows、filtered、Extra 和实际计划。
6.4.3 possible_keys 有值,为什么 key 还是 NULL?
possible_keys 只是候选,key 才是最终选择。优化器可能判断条件会命中大量数据,或者表很小,认为全表扫描成本更低,因此放弃候选索引。
6.4.4 rows 是结果行数吗?
不是。传统 EXPLAIN 的 rows 是优化器预计当前步骤需要检查的行数,不保证精确,也不是最终返回量。EXPLAIN ANALYZE 中 actual 后的 rows 是该节点每轮实际向上输出的行数;还要结合下层节点和 loops 理解总工作量。
6.4.5 Using filesort 是不是表示一定写磁盘?
不是。它表示 MySQL 不能直接利用当前读取顺序满足排序,需要额外排序;排序可能在内存中完成,也可能因数据量和配置使用磁盘。判断问题大小时要看参与排序的数据量和实际耗时。
6.4.6 Using index 和用了索引是一回事吗?
不是。实际选择哪个索引看 key。Extra 中的 Using index 通常表示覆盖索引,也就是查询需要的列可以直接从索引取得,不必为取缺少的列再访问完整记录。
6.4.7 EXPLAIN 和 EXPLAIN ANALYZE 有什么区别?
普通 EXPLAIN 主要展示优化器的预估计划;EXPLAIN ANALYZE 会真正执行查询,并提供实际行数、耗时和循环次数。因此线上重查询不能随手执行 ANALYZE,要先评估负载并优先在测试环境或只读副本验证。
6.5 常见误区与准确表述
| 不建议只这样说 | 更完整的表达 |
|---|---|
| “看到 ALL 就加索引” | “大表出现 ALL 时,结合扫描量、过滤比例和查询频率判断是否需要优化” |
| “key 不为空就没问题” | “key 只说明选了索引,还要看扫描量、回表、排序和实际耗时” |
| “Using filesort 就是磁盘排序” | “它表示需要额外排序,是否使用磁盘还要看数据量和运行条件” |
| “rows 就是返回条数” | “普通 EXPLAIN 的 rows 是预计检查行数,最终返回量要结合查询和实际计划” |
| “cost 就是耗时” | “cost 是优化器比较方案的成本分值,不是毫秒” |
如果只记一条面试主线,就记住:
我不是为了证明 SQL 用了索引,而是要找出数据库在哪一步读了太多数据、重复执行太多次,或者做了不必要的排序和临时处理。
七、核心知识回顾
- 为什么
LIMIT 5仍可能先扫描 10000 行?因为得先找全候选,再确定谁最新。 - 为什么走了索引还要排序?单列目录只解决了按顾客定位,没有满足时间顺序。
- 为什么第三轮能提早停?前两列等值固定后,剩余索引顺序正好匹配 ORDER BY。
- 为什么多取一个 note,覆盖索引就没了?索引里没存这列,需要回完整记录取。
- rows 到底看哪个?表格里看估算,ANALYZE 里看每个节点的实际输出,继续向下追读取节点。
记忆画面
整柜翻 → 只翻他的 → 按顺序拿够就走。
读执行计划时,把“用了哪个索引”继续追问成“它究竟帮我少干了多少活”。
跑完可以贴三轮的表格计划和树形计划,重点保留 key / rows / Extra 以及树中全部节点;这样就能逐行对照你的真实结果解释。
八、官方参考资料
本文按官方“查看计划 → 解释输出 → 对照实际执行”的知识顺序组织,实验设计与订单编号是本文自建示例。
- MySQL 8.0:EXPLAIN Statement:语法、TREE 格式和 ANALYZE 实际执行统计。
- MySQL 8.0:EXPLAIN Output Format:访问类型、rows、filtered 与 Extra 的含义。