一句话结论(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)
连续性的价值在于:
- 减少随机 IO:顺序区内的 1MB 数据可通过一次或少数几次磁盘 IO 完成。
- 预读(Read-Ahead)优化:检测到区内顺序访问时,InnoDB 自动预加载后续页。
- 空间管理效率:区描述符(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 条记录一组),槽按主键顺序排列。查找步骤:
- 二分查找定位目标所在的槽(O(log n_slots))
- 从槽指向的记录开始,向前扫描直到找到目标(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数据 | ... |
- 变长字段长度列表:逆序存储,如 VARCHAR(10) 存 “ab” → 2 字节
- NULL 标志位:BITMAP,每列占 1 bit
- 记录头信息(5 字节):包含下一条记录偏移、记录类型、是否删除、所属堆号
Compact 对行溢出的处理:当变长列超出页剩余空间时,在数据页保留 前 768 字节(prefix),其余溢出到 Uncompress BLOB Page,通过 20 字节的指针引用。
6.2 Dynamic 行格式(默认)
Dynamic 与 Compact 的唯一区别在于行溢出策略:
| 特性 | Compact | Dynamic |
|---|---|---|
| 溢出前缀 | 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 现象变得清晰:
- 为什么
SELECT COUNT(*)慢? 因为 InnoDB 不能像 MyISAM 那样在表级别维护行数——遍历的是 B+Tree 的叶子页,需要逐个页读取。行级别的数据没有汇总到页/区/段级别。 - 为什么大页(64KB page)适合顺序扫描? 64KB 页使 Extent 变为 4MB,一次 IO 吞吐更大。但小事务随机读写时,页内修改量增大,Checkpoint 压力上升。
- 为什么碎片页(前 32 页)散落? 因为按需分配比预分配 1MB 更节约空间——小表只用几十 KB 时不需要整区。
- 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。