Skip to content

数据页与行格式 ​

一条 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)的职责,就是把「只能顺序遍历的单链表」改造成「可以二分查找」的结构。它做了两件事:

  1. 把链表上的记录分组,每 4~8 条为一组,每个组叫一个槽(slot);
  2. 每个槽只记录一件事:这一组里最后一条记录在页内的偏移量(即指向它的指针)。

页目录是一个按主键有序的 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 列单独拆表」的依据——把它们留在主表里,会让主表的行频繁溢出,拖慢整行扫描。

sql
-- 查看表的行格式
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_INDEXB+ 树索引页,最常见
FIL_PAGE_UNDO_LOGUndo Log 页
FIL_PAGE_INODE段内索引节点页
FIL_PAGE_IBUF_FREE_LISTChange Buffer 空闲列表页
FIL_PAGE_TYPE_ALLOCATED已分配未使用

4. 最佳实践 ​

  1. 顺序主键(AUTO_INCREMENT)——避免页分裂,见 §3.1。
  2. 避免 UUID 主键——随机写入导致频繁页分裂。
  3. 大字段用 Dynamic 行格式——8.0 默认,溢出只留 20 字节指针。
  4. TEXT/BLOB 单独拆表——减少主表行溢出。
  5. 控制单行大小——单行尽量不超 8KB,避免动不动就走溢出页。

Released under the MIT License.