Skip to content
Go back

MySQL 存储引擎架构五层模型:表空间→段→区→页→行

一句话结论(30s)

InnoDB 存储引擎的本质是「表空间 → 段 → 区 → 页 → 行」五层映射——因为每层职责单一(逻辑容器 / 同类页集合 / 空间分配 / 最小 IO 单元 / 记录载体),才能让磁盘与内存管理各自高效。关键设计是「页 = 16KB 最小 IO 单元、区 = 1MB 连续空间分配基本单位」,页内用 Page Directory 槽 + 二分查找实现 O(log n);核心权衡落在 Extent 层——1MB(64 × 16KB)是逻辑管理与分配效率的最优折衷,太细则元数据爆炸、太粗则碎片浪费。

核心原理(2min)

一个索引占两个段:叶子段存数据页、非叶子段存索引页,段元数据存在 INODE 页,前 32 页按碎片页逐个分配、超过后才整区分配以节省小表空间。页是 16KB 的最小 IO 单元,内部结构 File Header → Page Header → Infimum/Supremum → User Records → Free Space → Page Directory → File Trailer;页内查找经 Page Directory 的槽(每组约 4-8 条指向最大记录)先二分定位槽、再组内扫描,故效率是 O(log n) 而非 O(n)。行格式 Compact 与 Dynamic 的唯一区别在溢出策略:Compact 在数据页留 768 字节前缀、其余溢出;Dynamic 只留 20 字节指针、全部溢出,索引更紧凑。单行数据超过约 8KB(16KB 页 / 2)触发行溢出。

底层深入(5-10min)

一、五层架构全景

InnoDB 将数据在磁盘和内存之间做了严格的五级映射。每一层承担不同的管理职责,从上到下逐级细化:

表空间 (Tablespace)        ← 逻辑容器,一个表一个 .ibd 文件
  └─ 段 (Segment)          ← 同一类页的集合(数据段 / 索引段 / 回滚段)
       └─ 区 (Extent)      ← 连续 64 个页 = 1MB,空间分配的基本单位
            └─ 页 (Page)   ← 16KB,最小 IO 单元,B+Tree 的基本节点
                 └─ 行 (Row) ← 用户记录的实际载体

为什么是五层而不是四层或六层?

核心权衡在 Extent:如果直接从段跳到页(1MB vs 16KB),段管理页的元数据成本过高(百万级页数);如果多一层 64KB 的”簇”,又增加了额外的碎片管理复杂度。1MB(64×16KB)是逻辑管理和分配效率的最优折衷。

💭 想一想:为什么偏偏是五层、而且纠结在”区”这层?——因为每一层都在解决一个具体问题:表空间是逻辑容器、段把同类页聚起来、页是 IO 最小单位、行是记录载体,而”区”夹在段和页之间承担”空间分配”——分配粒度太大浪费空间、太小则管理页的元数据爆炸。1MB 恰好是”元数据成本”和”碎片浪费”之间的平衡点。

二、表空间(Tablespace):独立表空间 vs 系统表空间

MySQL 数据目录/
├── ibdata1                          ← 系统表空间(共享)
├── mysql.ibd                        ← 数据字典表空间
├── undo_001, undo_002               ← Undo 表空间(8.0 默认 2 个)
├── #innodb_temp/                    ← 临时表空间
└── mydb/
    ├── users.ibd                    ← 独立表空间(每表一个文件)
    └── orders.ibd

独立表空间(file-per-table)innodb_file_per_table=ON(默认),每个表一个 .ibd 文件。优势是可收缩空间(OPTIMIZE TABLE 可释放磁盘)、可迁移表空间。

系统表空间(shared):存储数据字典(5.7)、Change Buffer、Doublewrite Buffer。8.0 后数据字典迁移到 mysql.ibd,系统表空间主要用于兼容。

三、段(Segment):同一类页的集合

一个索引占据两个段:

段类型管理内容页类型
叶子节点段 (Leaf Segment)B+Tree 叶子节点数据页(存储实际行记录)
非叶子节点段 (Non-Leaf Segment)B+Tree 内部节点索引页(存储键+子节点指针)

此外还有回滚段(Rollback Segment),管理 Undo Log 页。

段的元数据存储在 INODE 页(一种特殊页类型)中。INODE 页记录该段包含哪些区、哪些零散页(碎片页),Segment Header 结构如下:

FSEG Header(约 10 字节):
├── Segment ID
├── 碎片页的页号(最多 32 个零散页)
├── 完整区的页号列表
├── 首个空余页的偏移
└── 已用页数量

碎片页:段的前 32 个页以”零散页”形式逐个分配(不构成完整区),超过 32 个后开始整区分配。这是为了避免小表浪费 1MB 的区空间。

四、区(Extent):1MB = 64 个连续页

区是 InnoDB 空间分配的基本单位。一个区由磁盘上 连续 的 64 个 16KB 页组成,总大小正好 1MB。

// InnoDB 源码中 Extent 大小的定义
#define FSP_EXTENT_SIZE   (64 * UNIV_PAGE_SIZE)  // 1MB (16KB page)
// 或 4MB (64KB page)

连续性的价值在于:

  1. 减少随机 IO:顺序区内的 1MB 数据可通过一次或少数几次磁盘 IO 完成。
  2. 预读(Read-Ahead)优化:检测到区内顺序访问时,InnoDB 自动预加载后续页。
  3. 空间管理效率:区描述符(XDES Entry)以区为单位维护状态(空闲/半满/全满)。

区状态管理:每个区可处于 FREE(完全空闲)、FREE_FRAG(属于碎片区的空闲页)、FULL_FRAG(碎片区已满)、FSEG(已分配给段)四种状态之一。

💭 想一想:为什么区要维护这么多”状态”?——因为区是分配单位,系统得随时知道哪些区是空的、哪些被碎片占着、哪些已经划给了某个段,才能在新空间请求到来时快速找到可用区。状态机 + XDES 描述符本质是一张”空间库存表”,让分配不用线性扫描整个文件。

五、页(Page):16KB 最小 IO 单元

页是 InnoDB 磁盘与内存交互的最小单位。所有 IO 操作至少读写一个完整的 16KB 页。InnoDB 页的内部结构:

+----------------------------+
| File Header (38 bytes)     | ← 页类型、页号、校验和、LSN
+----------------------------+
| Page Header (56 bytes)     | ← 记录数、槽数、空闲空间偏移、最后插入位置
+----------------------------+
| Infimum + Supremum (26B)   | ← 虚拟最小/最大记录(边界哨兵)
+----------------------------+
| User Records               | ← 实际数据行(从 Infimum 往下生长)
+----------------------------+
| Free Space                 | ← 空闲区域(中间收缩)
+----------------------------+
| Page Directory             | ← 槽(Slot)+ 二分查找索引
+----------------------------+
| File Trailer (8 bytes)     | ← 校验和 + LSN(与 Header 对比保证完整性)
+----------------------------+

5.1 Page Directory:槽 + 二分查找

页内记录的查找并非线性扫描,而是通过 Page Directory 进行二分查找:

Infimum  │ Slot0 │ Record1 │ Record2 │ Slot1 │ Record3 │ Record4 │ Slot2 │ Supremum

每个槽(Slot)指向一组记录中的最大记录(约 4-8 条记录一组),槽按主键顺序排列。查找步骤:

  1. 二分查找定位目标所在的槽(O(log n_slots))
  2. 从槽指向的记录开始,向前扫描直到找到目标(O(group_size))

这就是为什么即使页内 16KB 数据,查找效率也是 O(log n) 而非 O(n)。

💭 想一想:既然都能二分,为什么页内不用一棵微型 B+Tree、而是”槽 + 组内扫描”?——因为页只有 16KB,维护一棵小 B+Tree 的元数据和分裂合并开销,远比”每组 4-8 条、最多跨 7 条”的线性扫描贵。这里的取舍是”常数因子”压倒”渐进复杂度”:组够小,线性那几步几乎免费。

为什么不用类似 B+Tree 的页内索引? 因为页只有 16KB,槽 + 组扫描的常数因子远小于维护一颗微型 B+Tree。当每个组约 4-8 条记录时,组内扫描最多只需跨过 7 条记录,代价微乎其微。

5.2 页与 B+Tree 的关系

B+Tree 的每个节点对应一个页面(InnoDB 页)。叶子节点页存储数据行,非叶子节点页存储键 + 子页编号。

InnoDB 的 B+Tree 在逻辑上没有统一的根节点——每棵 B+Tree 的根页号记录在 SYS_INDEXES 系统表中,查询引擎首先定位根页,加载到 BufferPool,然后逐层二分定位目标叶子页。

六、行格式:Compact vs Dynamic

6.1 Compact 行格式

| 变长字段长度列表 | NULL 标志位 | 记录头(5B) | 列1数据 | 列2数据 | ... |

Compact 对行溢出的处理:当变长列超出页剩余空间时,在数据页保留 前 768 字节(prefix),其余溢出到 Uncompress BLOB Page,通过 20 字节的指针引用。

6.2 Dynamic 行格式(默认)

Dynamic 与 Compact 的唯一区别在于行溢出策略:

特性CompactDynamic
溢出前缀768 字节0 字节(全部溢出)
溢出页Uncompress BLOB Page专门的溢出页
更新性能差(768 字节变更需跨页)好(直接操作溢出页)

Dynamic 溢出:变长列超出页空间时,数据页 仅保留 20 字节指针,列的全部数据存到溢出页。这意味着数据页的有效记录密度更高,B+Tree 索引更紧凑。

6.3 行溢出触发条件

一页至少要有两条记录(InnoDB 保证)。当插入单行数据超过 约 8KB(16KB 页 / 2)时,触发行溢出。计算公式:

页可用空间 = 16KB - FileHeader(38) - PageHeader(56) - Infimum+Supremum(26) - FileTrailer(8) - PageDirectory(动态)
            ≈ 16384 - 约 200 = 约 16184 字节 / 2 ≈ 8092 字节

这解释了为什么 VARCHAR(65535) 实际最大存储约 65533 字节(取决于行格式和字符集),但单行数据 > 8KB 时会发生行溢出(数据在 BLOB 页,索引页只留指针或前缀)。

💭 想一想:为什么溢出阈值是”约 8KB”这么个怪数字?——因为它来自”一页至少放两条记录”的约束:16KB 页扣掉各 Header/Trailer/目录后可用约 16KB,再除以 2 就得到约 8KB 的单行上限。一旦某行超过它,就塞不进”一行一页的一半”,必须把长列挪到溢出页、主索引页只留前缀或指针。

七、五层模型的核心价值

理解五层模型后,许多 MySQL 现象变得清晰:

  1. 为什么 SELECT COUNT(*) 慢? 因为 InnoDB 不能像 MyISAM 那样在表级别维护行数——遍历的是 B+Tree 的叶子页,需要逐个页读取。行级别的数据没有汇总到页/区/段级别。
  2. 为什么大页(64KB page)适合顺序扫描? 64KB 页使 Extent 变为 4MB,一次 IO 吞吐更大。但小事务随机读写时,页内修改量增大,Checkpoint 压力上升。
  3. 为什么碎片页(前 32 页)散落? 因为按需分配比预分配 1MB 更节约空间——小表只用几十 KB 时不需要整区。
  4. Doublewrite Buffer 为什么需要? 因为页是 16KB 的原子 IO 单元,但 OS 文件系统页通常 4KB。16KB 写入可能被中断(部分写),Doublewrite Buffer 先写一个完整副本到共享表空间,再写入数据文件——两阶段确保持久性。

章末提问

追问 1:为什么说”页是 16KB 最小 IO 单元”,而”区是 1MB 分配单位”?为什么需要两层?

回答思路:结论先行——因为”读写的粒度”和”分配的粒度”是两个不同的优化目标。因为页对应磁盘/内存一次 IO 的最小单位、也是 B+Tree 节点,定 16KB 平衡了 IO 次数和页内查找开销;而区是连续 64 页的批量分配单位,用它分配能减少碎片、利于预读和顺序 IO。直接”段→页”分配会让百万级页的元数据管理爆炸,“区”把分配粒度抬到 1MB、降低了管理成本。

追问 2:页内查找为什么是 O(log n) 而不是 O(n)?

回答思路:结论先行——因为页内有 Page Directory 槽位,先二分定位槽、再组内扫描。因为每条记录不直接线性排,而是每 4-8 条一组、由槽指向组内最大记录,槽按主键顺序排列;查找先 O(log n_slots) 二分找到槽,再在组内最多跨 7 条扫描。页小,所以用”槽 + 组内扫描”比维护微型 B+Tree 更划算。

追问 3:Compact 和 Dynamic 行格式的区别是什么?为什么 Dynamic 更优?

回答思路:结论先行——唯一区别在行溢出策略:Compact 留 768 字节前缀、Dynamic 全部溢出只留 20 字节指针。因为 Dynamic 不把长列数据塞在数据页,数据页有效记录密度更高、索引更紧凑,且更新长列时直接操作溢出页、不用跨页改 768 字节前缀,更新性能更好;所以 8.0 默认用 Dynamic。


Share this post on:

Previous Post
MySQL并行回放MTS
Next Post
MySQL半同步复制——主库宕机后数据不丢失的保障