数据页与行格式
一条
WHERE id = 500的主键查询,B+ 树经过 3 次磁盘 IO 定位到某个叶子页后,接下来要在这一页里几百条记录中找到id = 500的那一条。这一页内怎么找?如果顺序遍历,几百条就是几百次内存比较;InnoDB 用的是二分查找。但记录是靠单向链表串联的,单链表怎么二分?答案藏在页目录里。
1. 页的内部结构
1.1 一个页里装了什么
InnoDB 的磁盘 I/O 单位是页(Page),默认 16KB。所有数据——表数据、索引、Undo Log——都组织成 16KB 的页。一个数据页从前往后分成七段:
两段「头」和一段「尾」是元数据,User Records 与 Page Directory 从两端向中间增长,中间夹着的 Free Space 是尚可使用的空间。理解这一页内部怎么组织记录、怎么快速定位,是理解 B+ 树性能的最后一块拼图——B+ 树索引 讲的是「页与页之间怎么导航」,这一章讲「页内部怎么定位一条记录」。
1.2 记录怎么串联:next_record 单链表
页内的用户记录不按物理顺序排,而是靠每条记录头里的 next_record 指针串成一条单向链表,链表的逻辑顺序按主键从小到大:
链表的逻辑顺序为:Infimum → 记录1(id=1)→ 记录2(id=5)→ … → 记录N(id=500)→ Supremum。
两个边界各是一条虚拟记录:
- Infimum:逻辑上比页内任何记录都小,是链表的起点。
- Supremum:逻辑上比页内任何记录都大,是链表的终点。
关键点在于:这条链是逻辑有序、物理可能乱序的。删除一条记录后,它占的空间被标记为可复用;新插入的记录会落到 Free Space 里的某个空闲位置,物理上可能插在页的任意偏移,靠 next_record 指针把它接回正确的主键顺序。所以「记录按主键有序」是链表的逻辑顺序,不是它们在页内的物理排布。
这正是麻烦的起点:单链表只能从头顺序遍历。要在几百条记录里找 id = 500,顺着 next_record 一条条走,平均要走一半。内存里走几百步并不慢,但页目录提供了一条更快的路。
1.3 页目录:让单链表也能二分
页目录(Page Directory)的职责,就是把「只能顺序遍历的单链表」改造成「可以二分查找」的结构。它做了两件事:
- 把链表上的记录分组,每 4~8 条为一组,每个组叫一个槽(slot);
- 每个槽只记录一件事:这一组里最后一条记录在页内的偏移量(即指向它的指针)。
页目录是一个按主键有序的 slot 数组:slot0 指向 Infimum,slotN 指向 Supremum,中间每个 slot 指向对应分组的最后一条记录(如 slot1 → id=4、slot2 → id=8、slot3 → id=12)。
slot 数组本身按主键严格有序(因为每组最后一条是递增的),所以可以在这个数组上做二分查找:先确定目标记录落在哪个 slot,再进入该 slot 对应的组内,顺序遍历最多 8 条就能找到。
这就是「单链表也能二分」的全部秘密:链表本身没有随机访问能力,但页目录把链表抽样成了一组有序指针,二分查找发生在 slot 数组上,而不是链表上。 查找 id = 500 的过程是:二分 slot 数组定位到某个 slot → 在该 slot 覆盖的 4~8 条记录里顺序比较 → 命中。
页目录的大小随记录数增长,但每个 slot 只存一个 2 字节偏移量,几百条记录对应几十个 slot,开销可忽略。组的大小取 4~8 是折中:定得太小,目录本身变大、挤占页内空间;定得太大,组内顺序扫描变长,二分省下的比较又被摊薄。这是「用一点点冗余的目录,换页内查找从 O(n) 降到 O(log n)」的典型设计。
2. 行格式与行溢出
2.1 一条记录怎么存:Compact 行格式
页里的每条记录,按行格式组织。MySQL 8.0 之前默认 Compact,之后默认 Dynamic,两者主体一致,只有大字段处理不同。
Compact 格式的一条记录从前往后:
一条记录从前往后依次是:变长字段长度列表(逆序存放各变长字段的实际字节数)、NULL 标志位(位图,1 位对应 1 列)、记录头信息(5 字节)、隐藏列(DB_ROW_ID / DB_TRX_ID / DB_ROLL_PTR)、用户数据(你定义的各列)。
记录头里的 next_record 就是 §1.2 中串起链表的那个指针——它存的是「下一条记录相对本记录的偏移量」,指向逻辑上的下一条,而非物理相邻的下一条。隐藏列 DB_TRX_ID 与 DB_ROLL_PTR 是 MVCC 版本链的载体,见 事务与 MVCC §2.2。
2.2 行溢出:一行太大怎么办
一个页 16KB,其中可放用户数据的部分远小于 16KB(要扣掉头尾、页目录、Free Space 余量)。InnoDB 约定:一行记录超过页大小的一半(约 8KB)时,触发行溢出,把超大的列搬到独立的溢出页。
两种行格式对溢出的处理不同:
| 格式 | 溢出策略 | 当前页保留 |
|---|---|---|
Compact | 大字段前 768 字节留在本页,其余进溢出页 | 768 字节 + 20 字节指针 |
Dynamic(8.0 默认) | 大字段整体进溢出页 | 仅 20 字节指针 |
Dynamic 把「留 768 字节」这一步也去掉了,只在本页存 20 字节指针。收益是当前页能容纳更多行,代价是读这条大字段要多一次「跳溢出页」的 IO。这也是「TEXT/BLOB 列单独拆表」的依据——把它们留在主表里,会让主表的行频繁溢出,拖慢整行扫描。
-- 查看表的行格式
SELECT table_name, row_format
FROM information_schema.tables
WHERE table_schema = 'mydb';3. 页分裂与页类型
3.1 页分裂:从页内部看
插入记录时,如果页内 Free Space 装不下新记录,就要页分裂:申请一个新页,把原页里约一半的记录挪过去,再在父节点登记新页。
以原页 [1, 3, 5, 7, 9] 插入 6 为例:Free Space 已满时,申请新页,把后半部分记录移到新页(原页剩 [1, 3, 5],新页为 [6, 7, 9]),再在父节点登记指向新页的指针。
页分裂的代价不在「搬记录」本身,而在三处:
- 随机 IO:新页在表空间文件里的位置与旧页不相邻,磁头要跳到新位置;
- 空间利用率下降:分裂后两个页各只满一半,约 50% 空间被浪费;
- 树增高:若父节点也满了,分裂会向上传导,最坏让 B+ 树多一层。
顺序主键(AUTO_INCREMENT)插入时,新记录永远落在「最后」一页,装满才开新页,几乎不分裂;随机主键(UUID)插入时,新记录落在任意页的任意位置,频繁触发分裂。这个选择在 B+ 树索引 §3.2 从主键选型角度展开,本章从页内部解释「为什么随机主键伤性能」。
3.2 页类型
同一套 16KB 页结构,靠 File Header 里的类型字段区分用途:
| 页类型 | 用途 |
|---|---|
FIL_PAGE_INDEX | B+ 树索引页,最常见 |
FIL_PAGE_UNDO_LOG | Undo Log 页 |
FIL_PAGE_INODE | 段内索引节点页 |
FIL_PAGE_IBUF_FREE_LIST | Change Buffer 空闲列表页 |
FIL_PAGE_TYPE_ALLOCATED | 已分配未使用 |
4. 最佳实践
- 顺序主键(
AUTO_INCREMENT)——避免页分裂,见 §3.1。 - 避免 UUID 主键——随机写入导致频繁页分裂。
- 大字段用
Dynamic行格式——8.0 默认,溢出只留 20 字节指针。 - TEXT/BLOB 单独拆表——减少主表行溢出。
- 控制单行大小——单行尽量不超 8KB,避免动不动就走溢出页。