06 InnoDB 行格式与数据页结构——数据在磁盘上的真实样貌

摘要: 当你执行一条 INSERT 语句,MySQL 最终在磁盘上写下了什么?一行数据在 InnoDB 的 16KB 数据页中是如何编码存储的?NULL 值是怎么存的(答案不是用 0 或空字符串占位)?VARCHAR 的实际长度信息存在哪里?当一行数据超过页的大小限制时又会发生什么?本文深入 InnoDB 的行格式(Row Format)和数据页(Data Page)的物理结构,揭示数据在磁盘上的真实样貌。理解这些底层细节,不仅能解释为什么 NULL 列比 NOT NULL 列多占 1 字节、为什么 TEXT 类型会引入额外的 I/O,更能帮助你在表设计时做出更理性的决策。


第 1 章 InnoDB 支持的四种行格式

InnoDB 的行格式(Row Format)是数据行在磁盘页中的物理编码方式。不同的行格式在空间利用率、变长字段处理、大对象存储等方面有不同的取舍。

MySQL 5.7+ 默认行格式是 DYNAMIC,MySQL 5.6 默认是 COMPACT

-- 查看表的行格式
SHOW TABLE STATUS LIKE 'orders'\G
-- 或
SELECT TABLE_NAME, ROW_FORMAT FROM information_schema.TABLES WHERE ...;
 
-- 建表时指定行格式(通常不需要手动指定,使用默认值即可)
CREATE TABLE t (...) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
行格式引入版本溢出存储支持压缩适用场景
REDUNDANT早期 InnoDB768 字节前缀存页内,剩余存溢出页遗留格式,不推荐
COMPACTMySQL 5.0768 字节前缀存页内,剩余存溢出页通用场景
DYNAMICMySQL 5.7(插件格式)完整内容存溢出页,行中只有 20 字节指针推荐默认格式
COMPRESSEDMySQL 5.7(插件格式)同 DYNAMIC是(zlib)存储空间紧张场景

DYNAMIC 与 COMPACT 的核心区别在于大字段的溢出处理方式,后文详细解析。


第 2 章 COMPACT 行格式深度解析

COMPACT 格式是理解 InnoDB 行存储的最佳入口,DYNAMIC 格式在此基础上只有溢出处理的差异。

2.1 行记录的物理结构

一条 COMPACT 格式的行记录由以下部分组成:

┌─────────────────────────────────────────────────────────────────────┐
│                         行记录物理结构                                │
├──────────────────┬────────────────────────┬────────────────────────┤
│   变长字段长度列表  │    NULL 值列表(位图)   │       记录头(5字节)    │
│  (逆序,1-2字节)  │  (N列 → ceil(N/8)字节) │                        │
├──────────────────┴────────────────────────┴────────────────────────┤
│                         列值区域                                      │
│  [隐藏列: DB_ROW_ID(6B)] [隐藏列: DB_TRX_ID(6B)] [DB_ROLL_PTR(7B)] │
│  [列1的值] [列2的值] ... [列N的值]                                    │
└─────────────────────────────────────────────────────────────────────┘

2.1.1 变长字段长度列表

作用:记录每个变长列(VARCHAR、TEXT、BLOB 等)的实际存储长度。

为什么需要:对于 CHAR(10),知道它占 10 个字符,很容易定位下一列的起始位置。但对于 VARCHAR(100),这一行实际存了几个字节?如果不记录实际长度,就无法正确解析行中各列的边界。

编码规则

  • 每个变长列的实际长度用 1 或 2 字节存储(实际长度 ≤ 127 字节用 1 字节;> 127 字节用 2 字节)
  • 以逆序排列(最后一个变长列的长度在最前面)
  • 如果某个变长列的值为 NULL,不在长度列表中记录(由 NULL 位图处理)

逆序排列的设计原因:行记录是从左到右解析的,记录头和变长字段长度列表在开头,列值在后面。逆序排列使得第一列的长度紧挨着第一列的值,解析更高效(不需要先读完整个长度列表才能开始读列值)。

2.1.2 NULL 值列表(NULL 位图)

作用:用一个紧凑的位图记录哪些可为 NULL 的列的值为 NULL。

为什么需要:NULL 不等于 0 也不等于空字符串,它是”缺失值”的特殊语义。如果 NULL 值也占用与非 NULL 相同的存储空间,对于大量 NULL 的稀疏表来说是极大的浪费。InnoDB 通过位图来压缩 NULL 的存储:每个可为 NULL 的列对应位图中的 1 bit——0 表示非 NULL,1 表示 NULL。

空间占用:假设表中有 N 个可为 NULL 的列,NULL 位图占用 ⌈N/8⌉ 字节(向上取整到整数字节)。

不可为 NULL 的列不占位图空间:这就是为什么将列定义为 NOT NULL 不仅有语义上的约束价值,还有存储层面的优化价值——减少 NULL 位图的大小,也减少了每次读取行时判断 NULL 的开销。

重要边界:如果一列的值为 NULL,它在 NULL 位图中对应 bit 为 1,并且在列值区域不占任何空间。这就是为什么 NULL 在存储上比非 NULL 实际上可能更省空间(前提是 NULL 比较常见)——一个 NULL 的 VARCHAR(256) 字段只占 1 bit,而非 NULL 的 VARCHAR(256) 字段至少占 1 字节(空字符串的长度值)+ 实际内容。

核心概念

NULL 不占列值空间,但占 NULL 位图空间(1 bit)。对于允许 NULL 的列,每行额外需要 ⌈nullable_columns/8⌉ 字节的 NULL 位图。另外,每个允许 NULL 的列在变长字段长度列表中也不需要记录(如果值为 NULL),这进一步节省了空间。所以:一个 VARCHAR(100) NULL 的列,在值为 NULL 时实际只占 1 bit;在值为空字符串时占 1 字节(长度记录) + 0 字节(内容)= 1 字节;在值为 “hello” 时占 1 字节(长度记录) + 5 字节(内容)= 6 字节。

2.1.3 记录头(Record Header)

记录头固定 5 字节,包含:

字段位数含义
delete_flag1 bit该记录是否被标记为删除
min_rec_flag1 bit该记录是否是非叶节点中的最小记录
n_owned4 bit该记录所在的 Page Directory 槽中,属于本记录管辖的记录数
heap_no13 bit该记录在页的堆(Heap)中的位置编号
record_type3 bit记录类型(0=普通行,1=B+Tree内部节点,2=最小伪记录,3=最大伪记录)
next_record16 bit指向下一条记录的偏移量(页内的单向链表指针)

next_record 是页内单向链表的关键——通过它,页内所有记录按键值顺序连接成一条链。n_owned 与 Page Directory 配合,支持页内二分查找。


第 3 章 行溢出:当一行太大时

3.1 行大小的上限

InnoDB 的一个数据页是 16KB(16384 字节)。但一个页不能只存一行数据——这会使得 B+Tree 退化(叶子节点只有一行,树高极高)。InnoDB 要求每个数据页至少能存放 2 行数据,因此一行数据的大小理论上不能超过 8KB。

实际上,InnoDB 还有一条约束:所有非大字段列(即非 TEXT、BLOB 类型的列)的总长度不能超过 65535 字节(这是 MySQL 协议层的限制,与行格式无关)。

3.2 COMPACT 格式的溢出处理

当行中存在 TEXT 或 BLOB 类型的大字段,且大字段的内容使行超过了页大小限制时,COMPACT 格式的处理方式是:

在数据页中保留大字段的前 768 字节(作为”前缀”),超出的部分存储在单独的溢出页(Overflow Page) 中,溢出页通过链表连接。数据页中的记录还存储一个 20 字节的指针,指向第一个溢出页。

这种方式的设计动机是:即使大字段内容很长,通过前 768 字节的前缀,很多简单的查询(如模糊搜索 LIKE 'xxx%',如果关键词在前 768 字节以内)也不需要读取溢出页。

缺点:当大字段的前 768 字节也需要访问时,数据页中存了 768 字节的冗余数据,又要去溢出页读剩余内容——白白占用了数据页的空间,降低了页的存储效率。

3.3 DYNAMIC 格式的溢出处理

DYNAMIC 格式对大字段溢出的处理更彻底:完全不在数据页中保留前缀,大字段的全部内容都存储在溢出页中,数据页中只保留 20 字节的指针

这是一个重要的设计改进:

  • 数据页不再被大字段的前缀数据”污染”,页的空间完全用于存储紧凑的行记录
  • 每个数据页可以容纳更多的行(因为每行更小了)
  • Buffer Pool 的缓存效率更高

设计哲学

DYNAMIC 格式体现了”职责分离”的设计思想:数据页专注于存储结构化的行数据(键值、ID、状态等),大对象的内容存储在溢出页中,两者完全隔离。这使得针对结构化字段的扫描查询可以完全在数据页上完成,不会因为大字段而被迫读取溢出页。这也是为什么”不在数据库中存大文件”不只是经验之谈,而是有物理存储层面的理论依据。


第 4 章 数据页(Data Page)的完整结构

4.1 Page 的七个组成部分

上一篇文章(InnoDB 索引结构)已经介绍了页的基本组成,这里深入几个关键结构。

File Header(文件头,38 字节) 包含:

  • FIL_PAGE_SPACE_ID:页所属的表空间 ID
  • FIL_PAGE_OFFSET:页在表空间中的编号(页号)
  • FIL_PAGE_PREVFIL_PAGE_NEXT:前后相邻页的页号(构成叶子节点的双向链表)
  • FIL_PAGE_LSN:页最后一次被修改时的 LSN(用于崩溃恢复中的幂等判断)
  • FIL_PAGE_TYPE:页类型(数据页、索引页、Undo 页、系统页等)
  • FIL_PAGE_CHECKSUM:页的校验和

File Trailer(文件尾,8 字节) 包含:

  • 与 File Header 相同的校验和

InnoDB 在刷脏页时,先写数据,最后写 File Trailer 的 Checksum。如果 MySQL 在写页的中途崩溃,File Header 的 Checksum 和 File Trailer 的 Checksum 不匹配——InnoDB 在重启时检测到这个不一致,会从 Redo Log 中重新应用这个页的修改,而不是使用磁盘上的损坏版本。这是 InnoDB 对抗”部分写(Partial Write)“问题的机制。

核心概念

双写缓冲(Doublewrite Buffer) 是 InnoDB 处理部分写问题的另一个机制:在将脏页刷写到数据文件之前,先将其写入一块专用的双写缓冲区(位于系统表空间中)。如果在写数据文件时崩溃,可以从双写缓冲区中恢复完整的页,再重新应用 Redo Log。MySQL 8.0.20 将双写缓冲区从系统表空间独立出来,成为独立文件。

4.2 Page Directory:页内二分查找的索引

上一篇文章中提到,Page Directory 将页内记录分为若干槽(Slot),每个槽覆盖 4-8 条记录,槽中记录的最大键值作为槽的”代表”。

查找页内某条记录的流程:

  1. 通过二分查找在 Page Directory 的槽中定位目标槽(槽覆盖的键范围包含目标键)
  2. 从目标槽覆盖的第一条记录开始,通过 next_record 链表顺序遍历,找到目标记录

这将页内查找从 O(n) 优化到 O(log n + c),其中 c 是每个槽的记录数(通常 4-8,是常数)。

4.3 Free Space 的管理

页的 Free Space 是连续的未使用空间。新插入的记录直接追加到 Free Space 中(使用 PAGE_HEAP_TOP 指针跟踪 Free Space 的起始位置)。

当一条记录被删除时,它的空间变成碎片——不能简单地回归 Free Space(因为它前后的记录还在,不能移动)。InnoDB 维护一个空闲列表(Free List),将被删除记录的空间链接起来。当新插入的记录大小 ≤ 某个空闲槽的大小时,可以重用这个空闲槽。

如果页中的碎片空间和 Free Space 的总和足够但都是碎片,InnoDB 会对页做重组(Reorganize)——将所有有效记录紧凑地排列,清理碎片,恢复连续的 Free Space。这个操作代价不高,但会导致短暂的页独占。


第 5 章 行格式对性能的影响

5.1 行变大导致页存储效率下降

一个 16KB 的数据页,每行越小,可以容纳的行越多:

平均行大小每页行数(约)B+Tree 叶层高度(1亿行)
100 字节~1632-3 层
500 字节~323 层
2000 字节~83-4 层
8000 字节~24-5 层

每层增加意味着每次查找多一次 I/O。行越大,树越高,查找越慢。这是表设计时”列不要太宽”的物理层面原因。

5.2 NULL 位图的性能影响

允许 NULL 的列越多,NULL 位图越大,每行的额外开销越多:

  • 64 个可为 NULL 的列 → 8 字节的 NULL 位图
  • 每行 8 字节不多,但对于亿级别的表:1 亿行 × 8 字节 = 800MB 的 NULL 位图开销

更重要的是,MySQL 的某些优化无法在允许 NULL 的列上应用

  • COUNT(column) 与 COUNT(*) 的语义不同(NULL 值不计入 COUNT(column)),不能用简单的行数代替
  • 优化器对 NULL 的处理增加了额外的条件判断
  • 某些索引利用场景在 NULL 值存在时无法触发

建议:尽量将列设计为 NOT NULL,使用业务上有意义的默认值(DEFAULT 0DEFAULT ''DEFAULT CURRENT_TIMESTAMP)代替 NULL


第 6 章 表空间结构:页的组织方式

6.1 表空间 → 段 → 区 → 页的层级

InnoDB 的磁盘存储从大到小有四个层级:

表空间(Tablespace):存储一张或多张表的数据。InnoDB 默认为每张表创建一个独立的 .ibd 文件(innodb_file_per_table=ON,MySQL 5.6.6+ 默认开启)。

段(Segment):表空间内的逻辑划分,分为:

  • 数据段(Leaf Segment):B+Tree 叶子节点所在的段
  • 索引段(Non-Leaf Segment):B+Tree 内部节点所在的段
  • 回滚段:Undo Log 所在的段

区(Extent):连续的 64 个页,共 1MB。InnoDB 在申请空间时以区为单位,保证分配的页在磁盘上是连续的,有利于顺序 I/O。

页(Page):InnoDB I/O 的最小单位,16KB,B+Tree 的一个节点。

6.2 碎片区(Fragment Extent)的优化

对于小表(行数不多),如果直接分配整个 1MB 的区,大部分空间会被浪费。InnoDB 的优化是:表刚创建时,先在”碎片区(Fragment Extent)“中分配独立的页,而不是立刻分配整个区。只有当表的数据量增长到一定程度(通常是 32 个独立页之后),InnoDB 才开始以”完整的区”为单位分配空间。

这个设计使得大量小表共享同一个区的空间,避免了”表空间碎片”问题。


第 7 章 小结

本文从物理存储的角度构建了 InnoDB 数据组织的完整认知:

  1. 四种行格式:REDUNDANT(遗留)、COMPACT(通用)、DYNAMIC(推荐默认)、COMPRESSED(空间优化)。DYNAMIC 与 COMPACT 的核心差异是大字段溢出的处理方式
  2. COMPACT 行结构三段式:变长字段长度列表(逆序)+ NULL 位图(位图压缩)+ 记录头(5 字节链表指针)+ 列值区域
  3. NULL 的物理语义:NULL 值不占列值空间,只占 NULL 位图中的 1 bit;NOT NULL 可减小 NULL 位图开销,并使优化器更高效
  4. 行溢出处理:COMPACT 在页内保留 768 字节前缀;DYNAMIC 完全将大字段移至溢出页,数据页中只存 20 字节指针——页更紧凑,缓存效率更高
  5. 数据页的保护机制:File Header + File Trailer 双 Checksum 检测部分写;双写缓冲区(Doublewrite Buffer)提供完整的页备份
  6. Page Directory:页内槽索引,将页内查找从 O(n) 优化到 O(log n + c)
  7. 行大小对 B+Tree 性能的影响:行越大,每页容纳行数越少,树越高,I/O 越多;表设计要控制行宽
  8. 表空间四层结构:表空间 → 段 → 区(1MB)→ 页(16KB),理解这个层级有助于理解 InnoDB 的空间分配策略

思考题

  1. EXPLAIN 输出中 type 列的值从好到差:system > const > eq_ref > ref > range > index > ALLALL 表示全表扫描——通常是性能问题的信号。但在什么场景下全表扫描反而比使用索引更快(如表很小、查询返回大部分行)?优化器如何判断’用索引’和’全表扫描’哪个更快(基于统计信息的行数估算)?
  2. EXPLAINrows 列是优化器估算的扫描行数——但这个估算可能不准确。如果实际扫描行数远大于估算,说明统计信息过期。ANALYZE TABLE 更新统计信息——但在大表(亿级行)上 ANALYZE TABLE 需要多长时间?是否会阻塞正常查询?
  3. MySQL 8.0 引入了直方图(Histogram)统计信息——帮助优化器更准确地估算列值的分布。在没有直方图时,优化器假设数据均匀分布——对于倾斜分布的列(如 status 列 90% 是’正常’)可能选择错误的执行计划。在什么列上创建直方图收益最大?直方图与索引的关系是什么——有了索引还需要直方图吗?