臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、查询场景与优化目标

店里有 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;

结果会像这样(只摘出部分列,数值为示意,不是实测):

idtabletypekeyrowsExtra
1ordersrefidx_customer100Using 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;

执行后会产生什么?

你会得到一张执行计划表,通常有一行。下面仅摘出关键列,这是预期示意,不是你实例的实测结果:

tabletypekeyrowsExtra
ordersALLNULL约 10000Using where; Using filesort

它不会返回 5 张订单,而是在告诉你:“我准备怎样找这 5 张订单。”

这张表具体看什么?按顺序看四处:

看哪列第一轮预期看到什么你应该怎样理解
typeALL准备扫描整张订单表,相当于挨张翻柜子
keyNULL没有选择用于这次查询的索引;不代表表没有主键
rows接近 10000,未必正好 10000预计检查这么多行;这是估算,还不是实际读取量
ExtraUsing 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 执行流程与统计汇总

顺序实际做了什么输入 → 输出节点完成耗时(包含子节点)
1Table scan:扫整张表订单表 → 10000 行3.073 毫秒
2Filter:筛顾客与付款状态10000 → 50 行3.723 毫秒
3Sort:按时间、单号倒序提供前 5 行50 → 5 行3.804 毫秒
4Limit:最多产出 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、filteredrows 是预计检查行数,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 以及树中全部节点;这样就能逐行对照你的真实结果解释。

八、官方参考资料

本文按官方“查看计划 → 解释输出 → 对照实际执行”的知识顺序组织,实验设计与订单编号是本文自建示例。