在 InnoDB 的世界里,最小的 I/O 单位是数据页,而不是行。无论是从磁盘加载数据到内存,还是将内存中的脏页刷新回磁盘,InnoDB 始终以整页为单位操作。数据页默认大小为 16KB(由 innodb_page_size 控制,也可以设置为 8KB、32KB 等,但 16KB 是最常用、最均衡的选择)。
理解数据页的内部结构,会直接帮助你在设计表和处理性能问题时做出更准确的判断。比如:
- 为什么主键自增比随机 UUID 更利于写入?
- 为什么索引不建议在很长的字符串列上?
- 为什么全表扫描比索引扫描“重”那么多?
这些问题的答案,都藏在这一页 16KB 的结构里。
4.3.1 数据页的整体布局
一个 InnoDB 数据页的内部结构可以理解为一块划分了多个功能区的内存块。从头部到尾部依次排列如下:
| 区域 | 大小 | 说明 |
|------|------|------|
| File Header(文件头) | 38 字节 | 页的通用信息,如页号、类型、上一页/下一页指针等 |
| Page Header(页头) | 56 字节 | 页的专有信息,如记录数、空闲槽位等 |
| Infimum + Supremum | 26 字节 | 两条虚拟记录,代表页面中的最小记录和最大记录 |
| User Records(用户记录) | 动态增长 | 实际存放的行数据或索引项,按主键顺序通过单链表串联 |
| Free Space(空闲空间) | 动态缩减 | 尚未使用的空间,随着记录插入而缩小 |
| Page Directory(页目录) | 动态增长 | 由若干槽(slot)组成的稀疏目录,用于二分查找页内记录 |
| File Trailer(文件尾) | 8 字节 | 校验和与页号,用于检测页是否完整写入磁盘 |
图景:如果把一个数据页比作一本小册子,那么 File Header 是封面上的馆藏编号和目录页码,Page Directory 是内页边缘的字母索引标签(比如 A、B、C...),User Records 是真正的内容条目,Free Space 是书后留的空白页,File Trailer 是封底校验码。
4.3.2 各部分的结构与作用
File Header(文件头)—— 页的“身份证”
File Header 是所有类型页(索引页、undo 页、系统页等)都通用的头部,包含:
- 页号(Page Number):该页在表空间中的唯一编号,占 4 字节。通过页号可以直接计算出页在磁盘文件中的物理偏移量(页号 × 16KB)。
- 页类型(Page Type):表示本页存储的内容类型,例如索引页(叶子节点/非叶子节点)、undo 日志页、系统页等。
- 上一个页号、下一个页号:在索引的同一层节点之间,页通过双向链表连接,方便范围扫描时的顺序读取。这也就是为什么在同一个 B+ 树层级上,全表扫描可以根据叶子页的链表顺序高效读取,而不需要反复从非叶子节点定位。
- 校验和(Checksum,部分版本在此处):页的“指纹”,用于检测页损坏。
Page Header(页头)—— 页的管理信息
Page Header 存储了该页的细节统计数据,常见字段有:
- 记录数量:页内当前存放了多少条用户记录。
- 空闲空间起始偏移量:指向 Free Space 区域的起始位置,用于快速分配空间。
- 最后插入记录的位置:如果新插入的记录按顺序落在页末尾附近,可以帮助加速插入操作。
- 垃圾空间大小:当记录被删除或更新(旧版本标记为删除)时,空间不会被立即回收,而是计入垃圾空间。后续新插入的记录可以复用这些空间。这也是为什么 DELETE 操作并不立即释放表空间的原因之一。
- 槽数量:页目录中包含的槽个数。
这些信息主要用于 InnoDB 引擎的内部管理,比如判断页填充率是否适合分裂、是否需要做碎片整理等,开发者一般不会直接操作它们,但它们的统计值会通过 information_schema 等途径间接反映出来。
Infimum 和 Supremum —— 页内记录链表的哨兵
每个数据页在初始化时就预先写入了两条特殊的虚拟记录:
- Infimum(最小记录):比页内任何实际记录都要“小”,作为记录链表的开头。
- Supremum(最大记录):比页内任何实际记录都要“大”,作为记录链表的结尾。
这两条记录将页内所有实际记录形成一个逻辑上的有序链表:链表头是 Infimum,链表尾是 Supremum,中间按主键大小顺序串联所有 User Records。无论是查找、插入还是删除,引擎都可以沿着这个链表进行遍历或维护。
User Records(用户记录)—— 真正存放数据的地方
用户记录区存放我们插入的每一行数据或索引项。这部分内容自然是数据页中最占空间的区域。每个记录由额外信息和各列数据组成,额外信息主要包括:
- 记录头信息:包含指向下一记录的指针(链表中的 next_record)、记录类型(普通记录、非叶子节点指针等)、删除标记等。删除标记使得 InnoDB 可以通过标记删除而非物理清除来实现 MVCC。
- 行格式信息:取决于表定义时选择的行格式(Compact、Dynamic、Compressed 等),会影响变长字段长度、NULL 值的存储方式。例如 Dynamic 行格式下,对于长字段的数据可能会单独存储在溢出页中,而行记录里只保留一个 20 字节的指针。
用户记录按照主键顺序存储,但物理上并不必须连续,而是通过记录头中的指针维持逻辑顺序。当插入一条新记录时,它会根据主键值被插入到链表的对应位置,可能需要移动其他记录的相对偏移,但不会真正的大规模搬移数据,实际的行为由 Page Directory 协助完成。
Free Space(空闲空间)—— 页内的“预留地”
位于用户记录区和页目录区之间,是尚未分配的空白区域。随着记录不断插入,空闲空间会逐渐缩小。当空闲空间不足以容纳一条新记录(或者页目录需要增加槽位)时,就可能触发页分裂,产生一个新的数据页来分担数据。
显然,如果一行记录因为包含非常大的 TEXT/BLOB 字段而过于肥大,一个页可能只存放几条甚至一条记录,页内空闲空间几乎没有,这对 B+ 树的效率是不利的。这也是建议频繁查询的大字段考虑独立存储或压缩的原因之一。
Page Directory(页目录)—— 页内快速检索的秘密
如果每次在数据页内查找一条记录都要沿着 User Records 链表从头到尾遍历,那查找效率将是 O(N)。实际上,InnoDB 利用页目录实现了页内的二分查找。
页目录由一组“槽”构成,每个槽指向一个记录(称为该槽的“所属记录”)。这些槽是按照记录的主键顺序排列的,因此槽本身也大致有序。槽并不是为每一条记录都分配一个,而是稀疏的,通常每隔 4~8 条记录才有一个槽。查找某一主键值的过程如下:
- 先通过页目录的槽进行二分查找,快速确定目标记录落在哪个槽所指向的记录“附近”。
- 定位到相应的槽后,再沿着记录链表顺序向前或向后查找,最终锁定目标记录。
这种设计平衡了空间开销(如果每一条记录一个槽,页目录本身就会占用大量空间)和查找速度,是典型的空间换时间优化。
结合实际查询:当 SELECT * FROM t WHERE id = 1000 的主键查询发生时,引擎先通过 B+ 树定位到包含 id=1000 的数据页,然后在该页的页目录中进行二分查找,迅速定位到记录所在区域,再顺序扫描最多几条记录即可找到目标。效率远高于全页遍历。
File Trailer(文件尾)—— 防损坏的最后一道防线
位于页的最尾部 8 字节,通常包含:
- 校验和(checksum)的重复值:与 File Header 中的校验和应当一致。
- 页号的重复值:与 File Header 中的页号应当一致。
当 InnoDB 将内存中的页刷写到磁盘时,会同时计算页的内容生成校验和,并写入头尾。如果磁盘上的页因为硬件故障、写入中断等原因出现损坏,下次读取时校验和不匹配,InnoDB 就能发现页损坏并报错,防止将脏数据读取到查询结果中。这是保障数据完整性的一个简单但有效的设计。
4.3.3 页结构对日常开发的启示
数据页的原理看似底层,实则影响着我们日常开发中的很多决策:
- 主键顺序写入 vs 随机写入
如果主键是自增的,新插入的记录总是落在 B+ 树的最后一个叶子页的末尾,填满一个页后才分裂下一个,数据页的空间利用率高,页分裂开销小。如果主键是 UUID 等无序值,新记录会随机插入到任意页的中间,导致频繁的页分裂、页内数据搬迁、产生碎片,进而降低写入性能和空间利用率。
- 单行记录大小与页容量
每个页除去头尾、页目录等固定开销,大概只有 15KB 左右可用于用户记录。如果一行记录因为包含很多大字段而超过约 8KB(页容量的一半),InnoDB 会将超出的部分存放到溢出页,主记录只保留一个指针。这在行格式为 Dynamic/Compressed 时尤其明显。因此,设计表时尽量避免把较大的文本、二进制直接堆在频繁查询的行中,可以考虑垂直拆分。
- 页分裂对性能的影响
当插入或更新导致页内空间不足时,会发生页分裂:分配新页,将原页中约一半的记录搬运到新页,并更新上层索引节点。这是一系列同步的磁盘操作,在高并发写入时可能成为瓶颈。这也是为什么建议避免使用过长字符串作为主键,或者对频繁更新的列创建索引时要谨慎。
- 碎片与 OPTIMIZE TABLE
大量删除或随机更新后,页内会产生垃圾空间,形成磁盘碎片。碎片会使同样的数据占用更多的页,增加扫描成本。当觉得某个表空间过大但实际数据量不大时,可以考虑 OPTIMIZE TABLE 来重建表,回收碎片,提高页密度。
最后,如果想直观地观察数据页的内部结构,有多种工具可以用:innodb_ruby 可以解析表空间文件并打印出每个页的详细信息;hexdump 配合 InnoDB 内部结构文档也可以“裸看”页字节。当然,多数开发者并不需要深入到字节级别,但了解页的结构,会让你更透彻地理解那些优化建议背后的原理:并不是 MySQL 要做刁难,而是数据结构决定了什么行为更友好、什么行为更费资源。