MySQL 数据存储结构与查询执行生命周期深度解析

硬核是唯一的诚意

上一篇文章我们聊了性能优化实战,不少读者反馈非常硬核。但看完之后,很多朋友追问:"优化能背下来,但底层为什么这么设计?"

的确,知其然、更要知其所以然。你优化得再好,如果搞不懂 InnoDB 的数据到底在磁盘上怎么存放的、一条 SQL 从客户端发出去之后在数据库内部经历了什么,那优化终究是空中楼阁。

今天是MySQL 内功修炼的下篇,我们分两大部分彻底吃透:

  • 第一部分:数据存储结构 ------ 从磁盘最底层的页、行、表空间,一步一步摸清 MySQL 的数据到底长什么样。

  • 第二部分:查询执行生命周期 ------ 一条 SQL 从连接、解析、优化到执行,最后返回结果,每个环节发生了什么。

这两部分就像一枚硬币的两面:存储结构解决了"数据在哪儿、长什么样";执行生命周期解决了"怎么找到它、怎么把它带回给你"。读懂这两者,你就真正打通了 MySQL 底层原理的任督二脉。


第一部分:数据存储结构 ------ 从磁盘到内存,每一块数据都在掌控之中

我们平时谈论 MySQL 的"存储",核心是 InnoDB 存储引擎(MySQL 5.5 版本后的默认引擎,至今仍是绝对主流)。InnoDB 的存储结构可以用四个字概括:"页-区-段"

1.1 层次结构:段(Segment)、区(Extent)、页(Page)、行(Row)

InnoDB 从大到小将数据划分为几个层次:表空间(Tablespace)→ 段(Segment)→ 区(Extent)→ 页(Page)→ 行(Row)

  • 表空间 :最高层,每个 .ibd 文件对应一个表空间,包含该表的数据和索引。

  • :表空间内按用途划分,主要有数据段(B+ 树叶子节点)、索引段(B+ 树非叶子节点)、回滚段等。

  • :一个区固定 1 MB ,由连续的 64 个页组成,是物理空间分配的基本单位。

  • :InnoDB 与磁盘交互的最小单位 ,默认 16 KB(生产环境极少修改)。

  • :一条记录,以特定行格式存储在页中。

这个层级结构的设计充分体现了计算机存储体系的"局部性原理"------通过物理上连续的空间分配(区),让逻辑相邻的数据在磁盘上也尽量相邻,结合预读机制批量加载相邻页,显著降低随机 I/O 比例,提升缓存命中率。

对于最核心的,我们需要下钻到字节级别。


1.2 数据页结构(Page)------ 16KB 的精密机械

InnoDB 以页(Page) 为单位与磁盘交互。即使你只查询一行数据,InnoDB 也会把整个 16KB 的页加载进 Buffer Pool;即使你只修改一个字段,刷盘时也是以整页为单位写入磁盘。内存和磁盘之间的 I/O 粒度,始终是页,不是行

默认页大小 16 KB,可以通过 SHOW VARIABLES LIKE 'innodb_page_size'; 查看。InnoDB 定义了多种页类型,包括数据页(B+ 树节点)、Undo 日志页、系统页、事务数据页、溢出页等。

下面是焦点------数据页(索引页) 的内部结构。一个 16KB 的数据页,从头到尾由 7 个部分组成

text

复制代码
偏移量 0
┌─────────────────────────────────────┐
│ File Header          ← 固定 38 字节,页的"身份证"            │
├─────────────────────────────────────┤
│ Page Header          ← 固定 56 字节,页的状态控制块          │
├─────────────────────────────────────┤
│ Infimum + Supremum   ← 固定 26 字节,页内虚拟边界记录        │
├─────────────────────────────────────┤
│                                       │
│ User Records          ← 不固定,实际行数据,向下增长         │
│                                       │
├─────────────────────────────────────┤
│                                       │
│ Free Space            ← 不固定,空闲区域                    │
│                                       │
├─────────────────────────────────────┤
│ Page Directory        ← 不固定,槽数组,向上增长            │
├─────────────────────────────────────┤
│ File Trailer          ← 固定 8 字节,页完整性校验          │
└─────────────────────────────────────┘
偏移量 16383(共 16384 字节 = 16KB)

User Records 从上往下增长,Page Directory 从下往上增长,中间的 Free Space 是两者共享的缓冲地带。

我们来拆解每个部分的硬核作用。

① File Header(38 字节) ------ 页的"户口本"。

记录了该页的校验和 (与 File Trailer 对应,确保页完整性)、页号 (该页在表空间中的偏移位置)、上一页和下一页指针 (将所有页串联成双向链表,范围查询只需顺着链表遍历,无需回溯树结构)、页类型 (0x45BF 表示数据页/索引页)、LSN(日志序列号),以及该页所属的表空间 ID。这些信息支撑着 ACID 中的持久性与一致性。

② Page Header(56 字节) ------ 页内的"中央控制器"。记录了该页中记录的数量槽的数量 (后面会讲)、页在 B+ 树中的层级 、索引 ID 等关键元信息。B+ 树的根页有一个特殊性质:根页面位置万年不动。当我们创建一个新索引时,根节点是一个空页。随着记录不断插入、页分裂发生,根节点会晋升为目录页,但它的页号始终不变,InnoDB 每次使用该索引时可以直接从固定位置取出根节点页号,从而访问整个 B+ 树。

③ Infimum + Supremum(26 字节) ------ 页内两条"伪记录"。Infimum 表示该页中主键最小的记录,Supremum 表示最大的记录。在页内二分查找时,它们是边界判断的锚点。

④ User Records ------ 真正的数据存放区域,每条记录按照行格式(稍后讲)紧凑排列。

⑤ Free Space ------ 页内剩余空间。新插入的数据会先占用这块区域。

⑥ Page Directory ------ 稀疏目录(核心性能设计) 。这是页内实现二分查找 的关键机制。每 4--8 条用户记录 构成一个槽(Slot),槽中存储的是该组内最大记录的偏移量。查找时先在 Page Directory 中二分定位到槽,然后在槽内进行小范围扫描。这种"稀疏目录"的设计,将页内查找从 O(N) 降到 O(logN),极大加速了页内定位。

⑦ File Trailer(8 字节) ------ 页的完整性校验。其校验和必须与 File Header 中的校验和一致。当页从 Buffer Pool 刷回磁盘时,如果写入过程中发生异常导致页部分损坏,重启后会通过校验和不一致检测到"页损坏",从而触发恢复机制。


1.3 行格式(Row Format)------ 记录在磁盘上的真实样貌

了解了页的布局,现在我们把一个记录"放大"------看看一行数据在磁盘上到底长什么样。

InnoDB 支持四种行格式:REDUNDANT、COMPACT、DYNAMIC、COMPRESSED

行格式 引入版本 默认版本 溢出列存储方式 最大索引前缀
REDUNDANT 很早期 不默认 B+ 节点中存前 768 字节 767 字节
COMPACT MySQL 5.0 MySQL 5.1~5.6 B+ 节点中存前 768 字节 767 字节
DYNAMIC MySQL 5.7 MySQL 5.7 / 8.0 默认 整列完全移至溢出页 3072 字节
COMPRESSED MySQL 5.7+ 不默认 同 DYNAMIC + 压缩 3072 字节

目前绝大多数生产环境的 MySQL 5.7+ 以及 MySQL 8.0 都默认使用 DYNAMIC 行格式 ,可以通过 SHOW VARIABLES LIKE 'innodb_default_row_format'; 查看,通过 CREATE TABLE ... ROW_FORMAT=DYNAMIC;ALTER TABLE ... ROW_FORMAT=DYNAMIC; 修改。

DYNAMIC 和 COMPACT 的行结构完全相同,核心区别仅在于溢出数据的处理方式:

  • COMPACT :当 VARCHAR、TEXT、BLOB 等大字段超过 768 字节时,在 B+ 树节点中保留前 768 字节,剩余部分存入溢出页。数据被割裂存放在两个地方。

  • DYNAMIC :大字段发生溢出时,整列完整地移到溢出页 ,B+ 树节点中只留一个 20 字节的指针。设计思路是"要么全放行内,要么全放溢出页",避免数据割裂,B+ 树节点可以存放更多行,整体 I/O 效率更高。

来看一行记录的整体结构。一行记录由两大部分构成:额外信息(元数据)+ 真实数据

text

复制代码
┌──────────────────────────────────────────────────────────────────────┐
│ 额外信息(元数据)                                                     │
│ ┌───────────────────┬────────────────┬──────────────────┐            │
│ │ 变长字段长度列表   │ NULL 值列表    │ 记录头信息        │            │
│ │(逆序,变长)     │(逆序,变长)  │(固定 5 字节)   │            │
│ └───────────────────┴────────────────┴──────────────────┘            │
├──────────────────────────────────────────────────────────────────────┤
│ 真实数据                                                              │
│ ┌──────────┬───────────┬────────────┬──────┬──────┬─────┬──────┐    │
│ │ row_id   │ trx_id    │ roll_ptr   │ 列1  │ 列2  │ ... │ 列N  │    │
│ │(0或6字节)│ (6 字节)  │ (7 字节)   │      │      │     │      │    │
│ └──────────┴───────────┴────────────┴──────┴──────┴─────┴──────┘    │
└──────────────────────────────────────────────────────────────────────┘

① 变长字段长度列表 (逆序存储)。对于 VARCHAR、VARBINARY、TEXT、BLOB 等变长数据类型,需要存储该字段实际占用的字节数。逆序存储的设计并非随意------当记录中有多个变长字段时,逆序可以让 CPU 从后向前解析时一次性获得所有变长字段的长度,与前向解析器完美适配,减少缓存行切换,是一种针对主流硬件的微优化。

② NULL 值列表 (逆序存储)。Compact/DYNAMIC 行格式将值为 NULL 的列统一管理,每个允许为 NULL 的列对应一个二进制位:1 表示 NULL,0 表示非 NULL。这解释了为什么 NULL 几乎不占用存储空间 ,也解释了为什么很多 DBA 建议"能用 NOT NULL 就用 NOT NULL"------减少 NULL 值列表的位数,让整行更紧凑。但请注意,NULL 值列表只对应允许为 NULL 的列,如果某列定义为 NOT NULL,它根本不会出现在 NULL 值列表中。

③ 记录头信息 (5 字节)。记录了该记录的删除标记 (DELETE_MASK,记录被删除时仅标记为 1,空间并不立即释放,而是变为"可重用"状态,解释了为什么删除大量数据后磁盘空间不立刻下降)、记录类型 (叶子节点记录 vs 目录页记录)、下一条记录的偏移量(在页内形成单向链表)等元信息。

④ 真实数据 。包含三个隐藏列和用户定义的列:

  • row_id(6 字节,可选):当表没有定义主键且没有非空唯一索引时,InnoDB 自动生成该列作为聚簇索引的主键。

  • trx_id(6 字节):最近一次修改该记录的事务 ID。这是 MVCC 的基石------每个事务通过事务 ID 判断记录版本是否可见。

  • roll_ptr (7 字节):指向 Undo Log 中该记录前一版本的指针。版本链通过 roll_ptr 串联,配合 trx_id 实现可重复读(RR)隔离级别下的无锁快照读。这 13 字节(6+7)是每条 InnoDB 记录都必须付出的"事务开销",理解了这一点,你就知道一个表有上亿条数据时,这 13 字节的放大效应有多惊人。

Compressed 行格式 在 DYNAMIC 的基础上引入了 zlib 压缩,在页级别对整页数据压缩存储,并通过压缩页缓存与解压缓存协同工作,在 SSD 容量受限或冷热数据分离场景中极具价值,但启用 ROW_FORMAT=COMPRESSED 必须同时设置 KEY_BLOCK_SIZE。


1.4 B+ 树与聚簇索引/二级索引------打通页与页的连接

前面讲了单页的内部结构,那页与页之间是怎么组织的?B+ 树出场了。

B+ 树通过"数据只存于叶子节点+叶子节点双向链表+非叶子节点仅存键+高扇出 "四大特性,完美适配磁盘 I/O 模型:单次 I/O 可加载更多键,显著降低树高(通常 3--4 层即可支撑千万到亿级记录);所有数据位于同一层叶子节点,范围查询只需遍历链表,无需回溯;叶子节点间的双向链表天然支持 ORDER BY、GROUP BY 及分页游标

在 InnoDB 中,聚簇索引就是整个表------聚簇索引的 B+ 树叶子节点中存储的是完整的行数据。一个表只能有一个聚簇索引,默认使用主键;如果没有主键,InnoDB 会选择第一个非空唯一索引;如果连这个都没有,InnoDB 会隐式创建一个 6 字节的 row_id 作为主键。

二级索引(辅助索引)也是一棵 B+ 树,但它的叶子节点中不存储完整行数据,只存储索引列的值和对应的主键值 。当我们通过二级索引查询 SELECT * 时,过程是"二级索引 B+ 树中找到主键 → 拿着主键回聚簇索引查询完整数据 ",这就是回表(Back to Table),两次 B+ 树查找,成本比直接走聚簇索引高一倍。

那么,联合索引在最左前缀原则之外,还有哪些底层细节?目录项记录的唯一性 是一个非常容易被忽视但极其重要的点。假设我们有联合索引 (c2, c3),在 B+ 树的非叶子节点(目录页)中,不仅要存储 (c2, c3) 组合值和子页页号。但问题来了:如果仅仅存储 (c2, c3),当 (c2, c3) 组合不唯一时,新插入的记录应该进哪个子页?目录项记录无法唯一指向一个子页。因此,InnoDB 在非叶子节点的目录项记录中,强制把主键也纳入 ,即目录项记录实际上是 (c2, c3, 主键) 三元组 + 页号。这是最左前缀原则在 B+ 树内部的底层原因------搜索从根页下钻时,每一步都必须能够唯一定位到下一个子页,所以索引的后面即使没出现在查询条件中,它们也要参与目录项的排序和定位。


1.5 Buffer Pool 缓冲池------InnoDB 的性能心脏

页在磁盘上,但磁盘 I/O 速度(毫秒级)远低于内存 I/O(纳秒级),如果每次读写都直接访问磁盘,性能会极差 。InnoDB 的解决方案是 Buffer Pool :在内存中开辟一块核心缓存区域,专门缓存磁盘上的数据页和索引页,是 InnoDB 性能的"生命线"------生产环境中,缓冲池命中率一般要求 ≥99%

Buffer Pool 的核心工作机制:

  • 读请求 :先从缓冲池找数据页,命中则直接返回(内存速度),未命中才从磁盘 .ibd 文件加载到缓冲池。

  • 写请求:先修改缓冲池中的页(此时该页称为"脏页"),由后台线程异步刷盘,避免每次写都同步写磁盘。

  • 预读机制:提前加载可能被访问的页到缓冲池(如顺序读时的线性预读、随机读时的随机预读),提升命中率。

  • 关键参数:innodb_buffer_pool_size ,生产环境建议设为物理内存的 50%-70%

Buffer Pool 的管理采用一种改进的 **LRU(最近最少使用)**算法,核心是针对两个典型问题的优化:

问题一:预读失效(Read Ahead Failure) 。当你访问一个数据页时,InnoDB 会预判你接下来可能会访问相邻的其他数据页,提前把这些页加载到 Buffer Pool 中。但预读的数据页可能永远都不会被真正访问,却占据了 LRU 链表的头部位置,把真正热的数据页挤出去。

问题二:缓存污染(Cache Pollution) 。当执行 SELECT * FROM 大表(全表扫描)时,一次性加载大量"冷页"(仅本次访问,后续不再用)到链表头部,把原本常用的"热页"挤到尾部被淘汰,导致缓冲池命中率骤降。

InnoDB 的改进 LRU 采用冷热数据分离策略:

  • 链表被分成 Young(热数据区)Old(冷数据区) 两部分,新加载的页不直接进入 Young 区 ,而是先放入 Old 区头部

  • 只有在 Old 区存活超过一定时间且再次被访问的页,才会被晋升到 Young 区热数据的头部。

  • 效果:全表扫描一次性加载的大量冷页,因为没有二次访问,始终停留在 Old 区,很快被淘汰,不会污染热数据区,缓冲池命中率始终保持稳定。

脏页刷新(Flush)机制 :写操作先修改 Buffer Pool 中的页并标记为"脏页",同时记录 redo log 保证崩溃恢复;脏页不会立即刷盘,而是由后台线程 批量、异步刷盘 ,减少磁盘 I/O 次数。但脏页堆积过多会导致崩溃恢复耗时过长,所以 InnoDB 通过 innodb_io_capacity 等参数控制刷新速率。


第二部分:查询执行生命周期 ------ 一条 SQL 的漫游记

数据存好了,可数据不会自己跳出来。一条 SQL 从客户端发出到结果返回,MySQL 内部究竟经历了什么?

MySQL 采用分层插件式架构 ,核心分为两大层级:Server 层 (包含连接器、分析器、优化器、执行器及内置函数、视图、触发器、binlog 等通用能力,负责 SQL 的全流程处理与权限管控)与存储引擎层 (插件式可插拔设计,负责数据的持久化存储与物理读写,提供事务、锁、索引、崩溃恢复等底层能力,Server 层通过统一的 Handler API 与存储引擎交互)。

下面我们逐一下钻每个组件。


2.1 连接器(Connector)------ 第一条防线

连接器是客户端与 MySQL Server 之间的 TCP 通信桥梁与权限管控入口,负责连接生命周期管理、身份认证、权限上下文加载、会话状态维护,是 SQL 语句进入 MySQL 的第一道关卡。

核心流程:

  1. 客户端发起 TCP 连接,完成 TCP 三次握手。

  2. 身份认证:校验用户名、密码、客户端主机地址。

  3. 认证通过后,全量加载用户权限上下文(加载到该连接的内存中)。

  4. 会话初始化,维持连接状态,然后转发 SQL 至后续组件。

权限上下文的一次性加载特性 非常关键:连接建立时加载的权限,在该连接生命周期内不会自动刷新。即使管理员后续修改了你的权限,已建立的连接仍按旧权限执行,只有新建立的连接才会加载新权限。

长连接内存溢出 是一个高频生产问题:MySQL 执行过程中使用的内存会管理在连接对象中,长连接累计会导致内存占用飙升,甚至被 OOM 杀死。优化方案:定期断开空闲长连接、执行大查询后通过 mysql_reset_connection 重置连接状态释放内存、使用数据库连接池做连接复用与生命周期管控。

wait_timeout(非交互式连接超时,默认 8 小时)和 max_connections(最大并发连接数,默认 151)是连接器层面最常调整的参数。


2.2 分析器(Parser)------ SQL 的"翻译官"

连接器把 SQL 递过来,分析器要做两件事:词法分析 + 语法分析

  • 词法分析 :将 SQL 字符串分解成一个个 token(关键字、标识符、运算符、常量等)。比如 SELECT name FROM users WHERE id = 1 会被分解成 SELECTnameFROMusersWHEREid=1 这些 token。

  • 语法分析 :根据 MySQL 的 SQL 语法规则,将 token 序列构建成一棵解析树(Parse Tree) 。如果 SQL 不符合语法规则(比如 SLECT 拼成了 SLECT),分析器会在这一步直接报错 You have an error in your SQL syntax

如果 SQL 中包含表名或列名不存在,或者使用 * 通配符时未指定表,这一部分的语义检查也在分析器的职责范围内。分析器会查询数据字典(Data Dictionary),验证表和列是否存在、用户对该表是否有权限。生成解析树后,将其交给预处理器做下一步的语义扩展(如视图展开)。


2.3 优化器(Optimizer)------ SQL 的"军师"

这是整个生命周期中最复杂、最智能的环节。

核心定位:分析解析树,考虑各种可能的执行计划(Execution Plan),估算不同执行计划的成本,选择成本最低的执行计划。

优化器的决策范围

  1. 索引选择:当表中有多个索引可用时,优化器根据统计信息估算使用不同索引的成本(成本模型考虑 CPU 成本 + I/O 成本 + 内存成本),选择最优索引。

  2. 多表连接顺序 :对于多表 JOIN,优化器会评估不同的连接顺序。最优连接顺序的计算复杂度是 O(N!),N 为表的个数(JOIN 顺序的全排列)。MySQL 通过动态规划(Dynamic Programming)和贪婪算法(Greedy Search) 两种方式剪枝,当表数超过 optimizer_search_depth 的设定值时,从全排列搜索切换为贪婪搜索,将搜索空间从指数级大幅压缩。

  3. 子查询重写 :将 IN 子查询转换为 EXISTSJOIN,或转换为 semi-join 等更高效的形式。

  4. 条件推导:利用等价关系和传递性推断 WHERE 条件,进行常量传播和条件化简。

MySQL 8.0 引入的 EXPLAIN FORMAT=JSONEXPLAIN ANALYZE(8.0.18+)让我们可以窥见优化器的"大脑"------输出每个步骤的实际耗时、循环次数、返回行数等,比传统 EXPLAIN 的估算值更精准,能真实验证优化器的选择是否正确。但在生产环境慎用 EXPLAIN ANALYZE 进行测试,因为它会真实执行 SQL。

为什么优化器有时会选择错误?优化器的成本估算依赖统计信息,而统计信息是通过随机采样计算得来的,存在误差;另外,成本模型中的很多参数是硬编码或基于经验值的,在特定负载下可能不准确。


2.4 执行器(Executor)------ 执行计划的"施工队"

优化器生成执行计划后,执行器负责按照计划执行。这是组件中代码最重的部分。

执行器通过 Handler API 与存储引擎交互。每种存储引擎都要实现 Handler API 中定义的一系列方法:ha_innobase::index_read(索引读)、ha_innobase::rnd_next(全表扫)、ha_innobase::update_row(更新行)、ha_innobase::delete_row(删除行)等。执行器不关心底层是 InnoDB、MyISAM 还是 Memory 引擎,它只是调用 Handler API,由存储引擎完成物理读写。

执行器的核心流程:

  1. 权限校验:在打开表之前,调用权限模块检查当前用户对该表的访问权限。

  2. 打开表:调用 Handler API 打开表,获取表的元数据信息和存储引擎句柄。

  3. 执行计划迭代:根据执行计划(索引扫或全表扫),每次调用 Handler API 读取一行或一批数据。

  4. 过滤:对读到的每一行,判断 WHERE 条件是否满足(包括索引条件下推 ICP 的场景,在存储引擎层完成部分过滤)。

  5. 输出:经过所有过滤后,将满足条件的数据行组装成结果集,返回给客户端。

执行器还有一个核心职责:临时表和文件排序 。当执行计划包含 GROUP BYORDER BY 且无法利用索引时,执行器会在内存(或磁盘上)构建临时表,进行排序操作,这就是 EXPLAIN 中 Using temporaryUsing filesort 的来源。


2.5 存储引擎与日志系统 ------ 事务的守护者

与执行器通过 Handler API 紧密配合的,是 InnoDB 存储引擎层的日志系统:binlog、redo log、undo log 。这三者共同支撑了 MySQL 的原子性、持久性、一致性、隔离性

① binlog(归档日志,Server 层) 。记录了所有 DDL 和 DML 语句的逻辑操作(如 INSERT INTO users VALUES (...), UPDATE users SET ...)。主要用于数据备份恢复主从复制

② redo log(重做日志,InnoDB 存储引擎层) 。是 48 GB(MySQL 8.0 支持动态调整大小)、循环写入的物理日志。记录了数据页的物理修改(如"在第 4 号表空间第 100 号页偏移量 1024 处将字节 0x00 改为 0x01")。核心作用 :保证事务的持久性(Durability),当事务提交后,即使数据库突然掉电,崩溃恢复时可以通过 redo log 重放已完成事务的修改,保证已提交的数据不丢失。

③ undo log(回滚日志,InnoDB 存储引擎层) 。逻辑日志,记录的是逆向操作 (即如何把数据恢复到修改前的状态),存储在回滚段(Rollback Segment)中。核心作用 :保证事务的原子性(Atomicity) ------当事务执行过程中遇到异常或用户执行 ROLLBACK 时,利用 undo log 将数据恢复到修改前的状态。此外,undo log 还支撑 MVCC :所有读操作(SELECT)看到的是 undo log 版本链中特定版本的数据,实现了"读不阻塞写,写不阻塞读"。

两阶段提交(2PC,Two-Phase Commit) :这是保证 redo log 和 binlog 逻辑一致 的核心机制。用最简单的方式说:事务执行过程中,redo log 先写入,状态标记为 prepare(预提交) ;事务提交时,先写 binlog,再把 redo log 状态改为 commit(正式提交)

如果先写 redo log 再写 binlog,假设写完了 redo log 但还没来得及写 binlog,数据库就宕机了。重启后,redo log 中记录的事务会被重放,恢复回数据,但 binlog 中没有对应的记录------这会导致主从复制丢失数据。两阶段提交保证了:无论崩溃发生在哪个阶段,总能通过 redo log 和 binlog 的对齐检查,判断该事务是否真的应该提交,从而保证主从数据最终一致。


2.6 EXPLAIN 执行计划:追溯 MySQL 的决策过程

理论说再多,不如看一下 MySQL 真实执行的计划EXPLAIN 是我们窥探优化器决策和评估 SQL 性能的核心工具。

以下列是按重要性排序的必看字段

字段 作用 优化建议
type 访问类型:system → const → eq_ref → ref → range → index → ALL ALL(全表扫描)必须优化
possible_keys 可能用到的索引 若为 NULL,表上无可用的有效索引
key 实际使用的索引 若为 NULL,代表未使用索引
key_len 使用的索引字节数 可推算索引中具体用了哪几列(例如 utf8mb4 每字符 4 字节,key_len=4 表示只用了索引的第一列)
rows 预估需要扫描的行数 数字越大越慢,需与 filtered 配合分析
filtered 经过 WHERE 过滤后剩余的比例 rows × filtered/100 ≈ 最终返回行数。filtered 很低说明需要回表过滤大量数据,考虑覆盖索引
Extra 附加信息 Using index(覆盖索引,好)、Using index condition(ICP,较好)、Using filesort(需优化)、Using temporary(需优化)

key_len 的实战价值 :假设有联合索引 (user_id, create_time, status)user_id 为 BIGINT 占 8 字节,create_time 为 DATETIME 占 8 字节,status 为 TINYINT 占 1 字节。如果执行计划中的 key_len 是 8,说明只用了 user_id 一列;如果是 16,说明用了 user_idcreate_time 两列;如果是 17,说明三列都用到了。这让你精确判断联合索引用到了哪几列,而不只是"走没走索引"。

MySQL 8.0 新特性:EXPLAIN ANALYZE 。它不仅输出执行计划,还真实执行 SQL ,输出每个步骤的实际耗时、循环次数、返回行数等。示例输出 Nested loop inner join (actual time=0.1..0.2 rows=10 loops=1)------前者是优化器估算,后者是实际运行的真实值。实测发现,优化器估算的 rows 可能和实际相差几个数量级,而 EXPLAIN ANALYZE 告诉我们真实发生了什么


第三部分:贯穿实战 ------ 存储与执行的两条主线是如何交织的

让我们用一条实际 SQL 来验证上面所有内容:

sql

复制代码
SELECT o.order_id, u.user_name
FROM orders o 
JOIN users u ON o.user_id = u.id
WHERE o.status = 1 AND o.amount > 1000
ORDER BY o.create_time DESC
LIMIT 10;

如果 orders 表有联合索引 (status, amount, create_time, user_id),并且在 users 表的 id 上有主键索引:

存储层的参与

  1. orders 表被存储在 orders.ibd 表空间中,数据按聚簇索引(主键 B+ 树)存放。

  2. orders 表的联合索引 (status, amount, create_time, user_id) 是另一棵 B+ 树,叶子节点存储的是这四列 + 主键值。

  3. users 表的聚簇索引是主键 id 的 B+ 树,叶子节点存储完整的行数据。

执行层的交织

  1. 连接器:建立连接,加载用户权限上下文。

  2. 分析器 :构建解析树,验证 ordersusers 表和列的存在性,验证权限。

  3. 优化器

    • 决定采用 orders 表的联合索引 (status, amount, create_time, user_id)

    • 估算成本:符合 status = 1 AND amount > 1000 条件的记录有多少?通过 status 过滤后再对 amount 做范围扫。

    • ordersusers 决定连接顺序:优化器会基于表数据量和索引分布,选择用小结果集驱动大结果集(通常是 orders 先过滤,再与 users 做 Nested Loop Join)。

  4. 执行器

    • 通过 Handler API 调用 InnoDB,走联合索引查找:先定位 status = 1,再判断 amount > 1000 的范围条件。

    • 联合索引本身按 (status, amount, create_time, user_id) 排序,所以 ORDER BY create_time 可以不额外排序直接利用索引(但要确认 create_time 在索引中的位置和排序方向)。

    • 从联合索引中获取 user_id 后,通过 Handler API 回 users 聚簇索引查主键对应的完整数据。

    • 每一行满足条件的数据,直接返回客户端,收集 10 条后停止扫描。

  5. 日志系统 :虽然这里是查询,但如果是 UPDATEDELETE,还会经历 redo log prepare、binlog 写入、redo log commit 的两阶段提交流程。

用 EXPLAIN 验证 。该 SQL 的 EXPLAIN 输出中,Extra 列应该显示 Using index 表示覆盖了部分列(order_id + user_id + status + amount + create_time 正好全在联合索引中,完全不需要回 orders 聚簇索引);如果没有 Using index 而是显示 Using index condition,说明触发了 ICP(索引条件下推),在存储引擎层就完成了部分 WHERE 条件的过滤,减少了回表次数;type 应该是 range(因为 amount > 1000 是范围条件),key_len 根据实际用了联合索引的几列来确定。


总结:打通"存储"与"执行"两条脉络

这一篇从磁盘最底层的数据页、行格式、B+ 树,到内存中的 Buffer Pool,再到客户端请求进入 MySQL 后的完整生命周期------连接器、分析器、优化器、执行器、存储引擎和日志系统,最后用实战 SQL 串起了两条主线。

希望你已经清晰认识到:存储结构是"数据在哪儿、长什么样"的答案,执行生命周期是"怎么找到它、怎么把它带回给你"的答案。两者相互制约、相互成就------索引结构决定了执行器能走多快,Buffer Pool 决定了查询能否命中缓存减少磁盘 I/O,优化器的成本模型依赖对存储结构的理解,日志系统在执行器的写操作中确保数据安全。

你的 SQL 优化、索引设计、参数调优,最终都是在这两条脉络的交汇点上寻找最佳平衡点。

正如上一篇结尾所说:优化不是一次性工作,而是持续的过程。而理解底层原理,就是让你在优化道路上不止于 copy 命令,而是真正理解 MySQL 的每一个字节的流向

相关推荐
电商API_180079052472 小时前
免 TOP 入驻,第三方淘宝商品详情 API 快速接入与代码示例
java·大数据·开发语言·数据库·爬虫·数据分析
RisunJan2 小时前
Linux命令-objdump(显示二进制文件信息)
linux·运维
Ameilide2 小时前
数据结构 算法解释,排序、查找
数据结构
小肥君2 小时前
docker镜像配置
运维·docker·容器
神龙斗士2402 小时前
增删改查操作
数据库·mysql
左心房的默白,,,2 小时前
33:HSMS over TCP/IP 通信原理与配置
运维·自动化
Elastic 中国社区官方博客2 小时前
13.7万人,零人工决策:使用 Elasticsearch 实现智能体驱动的灾害响应系统
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
yuzhiboyouye2 小时前
sql增删改查怎么写?有时会不会有联表查询的增删查改
数据库·sql