在上一篇文章中,我们了解了 InnoDB 表空间、段、区、页的层级关系,以及几种行格式的宏观区别。今天,我们将打开一个 16KB 的 InnoDB 数据页,解剖它的每一个"零件",并深入一行记录的内部构造------包括那些你看不到的隐藏列。理解了页与行的细节,你就真正掌握了 InnoDB 存储数据的 DNA,后续学习索引、MVCC 和崩溃恢复时将会如虎添翼。
本文将覆盖:
- 数据页的 7 大组成部分
- 文件头、页头、页目录、文件尾的职责
- LSN(日志序列号)与校验和在页中的位置
- 行记录的完整格式(COMPACT/DYNAMIC)
- 三个隐藏列:DB_ROW_ID、DB_TX_ID、DB_ROLL_PTR
- 实战:使用 hexdump 观察真实 .ibd 文件
1. InnoDB 数据页结构概览
InnoDB 中,数据存放的基本单位是页(Page) ,每个页默认 16KB 。不同类型的页(数据页、Undo 页、系统页等)结构差异很大,我们这里聚焦于最核心的数据页(Index Page)------即存放表行记录和索引节点的页。
一个数据页可以逻辑地划分为 7 个部分:
┌────────────────────────────────┐
│ File Header │ ← 文件头(38字节)
├────────────────────────────────┤
│ Page Header │ ← 页头(56字节)
├────────────────────────────────┤
│ Infimum + Supremum Records │ ← 最小记录与最大记录(虚拟行)
├────────────────────────────────┤
│ User Records │ ← 用户实际记录(行数据)
├────────────────────────────────┤
│ Free Space │ ← 空闲空间
├────────────────────────────────┤
│ Page Directory │ ← 页目录(槽 + 指针)
├────────────────────────────────┤
│ File Trailer │ ← 文件尾(8字节)
└────────────────────────────────┘
下面我们逐块认识它们。
2. 文件头(File Header)------ 跨页管理的基础
文件头固定占用 38 字节,存放的是该页自身的元信息以及在整个表空间中的定位信息。关键字段包括:
| 字段 | 大小 | 说明 |
|---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM |
4 字节 | 校验和(新版)或表空间 ID(旧版) |
FIL_PAGE_OFFSET |
4 字节 | 页号(表空间中第几页,从 0 开始) |
FIL_PAGE_PREV |
4 字节 | 上一页的页号(B+Tree 叶子层双向链表) |
FIL_PAGE_NEXT |
4 字节 | 下一页的页号 |
FIL_PAGE_LSN |
8 字节 | 该页最后被修改时对应的 LSN |
FIL_PAGE_TYPE |
2 字节 | 页类型(数据页、Undo 页、系统页等) |
FIL_PAGE_FILE_FLUSH_LSN |
8 字节 | 独立表空间中文件被刷盘时的 LSN |
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID |
4 字节 | 表空间 ID |
其中,FIL_PAGE_PREV 和 FIL_PAGE_NEXT 构成了同一层叶子节点之间的双向链表,这就是为什么 InnoDB 能高效进行全表扫描和范围扫描------沿着这个链表顺序读即可,无需每次都从根节点向下查找。
3. 页头(Page Header)------ 页内的状态信息
页头占用 56 字节,记录了当前页内部的状态。关键字段:
| 字段 | 说明 |
|---|---|
PAGE_N_DIR_SLOTS |
页目录中槽的数量(与用户记录数相关) |
PAGE_HEAP_TOP |
空闲空间的起始位置,新记录从这里开始分配 |
PAGE_N_HEAP |
堆中记录的总数(含虚拟最小/最大记录) |
PAGE_FREE |
被删除但尚未回收的记录链表头 |
PAGE_GARBAGE |
已删除记录占用的总字节数(碎片) |
PAGE_LAST_INSERT |
最后插入记录的位置 |
PAGE_DIRECTION / PAGE_N_DIRECTION |
用于记录插入方向(升序/降序),便于优化自增主键 |
PAGE_FREE 和 PAGE_GARBAGE 这两个字段很关键:当一行被 DELETE 后,InnoDB 并不会立即从物理页中抹掉它,而是打上"已删除"标记,并加入空闲链表。后续插入新记录时,可以复用这些空间。这也是为什么删除大量数据后磁盘空间不立即释放的原因之一。
4. 页目录(Page Directory)------ 页内索引
页目录是 InnoDB 在页内实现快速查找的精髓。它的结构类似于一个"稀疏索引":
- 页目录由若干个**槽(Slot)**组成,每个槽 2 字节,指向页内某条记录的偏移量。
- 并不是每条记录都有一个槽,而是每隔几条记录才分配一个槽(由
PAGE_N_DIR_SLOTS控制)。 - 查找某条记录时,先用二分查找定位到槽,然后在该槽指向的记录开始线性扫描,直到找到目标。
这种"槽 + 短程顺序扫描"的设计,让 16KB 页内的数据查找在 O(log n) 到 O(1) 级别完成,非常高效。
5. 文件尾(File Trailer)------ 完整性保障
文件尾只占 8 字节,分为两部分:
- 低 4 字节:校验和(与文件头中的校验和比对)
- 高 4 字节 :页最后修改的 LSN 的低 4 字节(与文件头
FIL_PAGE_LSN比对)
当 InnoDB 从磁盘读取一个页时,会计算页的校验和并与文件尾的值对比。如果不一致,说明发生了页断裂(如写入一半断电)。这就是**双写缓冲区(Doublewrite Buffer)**要解决的核心问题------我们将在后续日志篇详细展开。
6. 行结构:一行数据到底如何存储?
了解页结构后,我们来放大页内的用户记录区,看看一行记录究竟由哪些部分组成。以 COMPACT 行格式为例,一行记录的物理结构如下:
┌────────────────────────────┐
│ 变长字段长度列表(逆序) │ ← 仅变长列有,每列 1~2 字节
├────────────────────────────┤
│ NULL 标志位 │ ← 1 字节可标记 8 列为 NULL
├────────────────────────────┤
│ 记录头信息(5 字节) │ ← 类型、下一记录指针等
├────────────────────────────┤
│ 隐藏列 │ ← DB_ROW_ID, DB_TX_ID, DB_ROLL_PTR
├────────────────────────────┤
│ 用户列数据(实际值) │
└────────────────────────────┘
6.1 变长字段长度列表
对于 VARCHAR、TEXT、BLOB 等变长列,InnoDB 需要记录每个变长列实际存储的数据长度 (字节数)。这些长度信息以逆序排列在行数据的头部:即第一列变长列的长度存在列表的最后。如果某列为 NULL,则在 NULL 标志位中体现,此处不占空间。
6.2 NULL 标志位
NULL 标志位是一个位图,标识哪些列的值是 NULL(NULL 不占实际数据空间,只在这个位图中标记)。COMPACT 格式中最多可标记 8 列为 NULL(若列数更多,额外分配字节)。DYNAMIC 格式也类似。
6.3 记录头信息(Record Header)
固定 5 字节,关键位包括:
- deleted_flag:1 bit,标记该行是否已删除(逻辑删除)。
- min_rec_flag:1 bit,是否为 B+Tree 非叶子节点中的最小记录。
- n_owned:4 bit,当前记录所在的槽组中包含的记录数(页目录相关)。
- heap_no:记录在页堆中的位置(Infimum=0,Supremum=1,用户记录从 2 开始)。
- record_type :3 bit,表示记录类型(
0=叶子节点普通记录,1=非叶子节点索引项,2=Infimum,3=Supremum)。 - next_record :2 字节,指向下一条记录相对于当前记录起始处的偏移(不是绝对地址)。通过这个指针,记录在页内形成一条单链表。
7. 三个隐藏列:你看不到的功臣
每一行 InnoDB 记录(包括你定义的所有列),都会多出三个"隐藏列",它们在表结构中是看不见的,但对事务、回滚和索引起到关键作用。
| 隐藏列 | 大小 | 说明 |
|---|---|---|
DB_ROW_ID |
6 字节 | 行 ID。如果表未定义主键,InnoDB 自动生成它作为聚簇索引键。如果定义了主键,则不出现。 |
DB_TX_ID |
6 字节 | 最后修改该行的事务 ID。MVCC 通过它和 Undo Log 来判断版本可见性。 |
DB_ROLL_PTR |
7 字节 | 指向 Undo Log 中该行上一个版本的指针,用于回滚和构建多版本链。 |
重点理解:
- 如果表有显式主键,聚簇索引就用主键,
DB_ROW_ID不会存在。如果没有主键,也没有非空的唯一索引,InnoDB 才会用DB_ROW_ID作为隐式主键。 DB_TX_ID和DB_ROLL_PTR是实现 MVCC 的物理基础。每次更新一行时,旧的版本会通过DB_ROLL_PTR串成一个版本链,DB_TX_ID则用于判断哪个事务能看到哪个版本。这部分在第五阶段 MVCC 篇会详解。
8. 实战:使用 hexdump 观察 .ibd 文件
理论知识堆得够多了,我们来亲手"偷窥"一下 InnoDB 的物理存储。
8.1 准备测试表和数据
sql
USE library_db;
CREATE TABLE peek_test (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
age TINYINT UNSIGNED
) ENGINE=InnoDB ROW_FORMAT=COMPACT;
INSERT INTO peek_test (name, age) VALUES ('Alice', 25), ('Bob', 30);
8.2 找到 .ibd 文件
查看数据目录:
sql
SHOW VARIABLES LIKE 'datadir';
假设数据目录是 /var/lib/mysql/library_db/,找到文件 peek_test.ibd。
8.3 用 hexdump 分析文件头部
在 MySQL 服务器所在的终端中执行:
bash
hexdump -C peek_test.ibd | head -n 20
你会看到类似这样的输出:
00000000 36 c9 12 f7 00 00 00 03 ff ff ff ff ff ff ff ff |6...............|
00000010 00 00 00 00 44 27 5c 8b 45 bf 00 00 00 00 00 00 |....D'\.E.......|
00000020 00 00 00 00 00 02 00 00 00 10 00 0d 00 00 00 00 |................|
...
- 开头的 4 字节 (
36 c9 12 f7)是校验和。 - 紧接着是 页号 (
00 00 00 03),说明这是第 3 号页(通常第一个数据页从 3 号开始)。 - FIL_PAGE_PREV 和 FIL_PAGE_NEXT 是
ff ff ff ff,表示没有兄弟页。 - FIL_PAGE_LSN 出现在文件头中的位置可以参照 InnoDB 源码中
fil_header_t结构体来精确定位,这里不做深入。
8.4 找到行记录
在 hexdump 中找到 Infimum 和 Supremum 的标记通常比较困难,因为它们与具体数据混在一起。你可以搜索 ASCII 字符串:
bash
hexdump -C peek_test.ibd | grep -i alice
可能会看到:
00004000 03 00 00 00 10 00 1a 80 00 00 01 00 00 00 00 05 |................|
00004010 01 10 05 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00004020 41 6c 69 63 65 19 00 00 00 00 00 00 00 00 00 00 |Alice...........|
其中 41 6c 69 63 65 就是 Alice 的 ASCII 码,前面的一堆十六进制包含了变长字段长度列表、NULL 标志位、记录头信息和隐藏列。
8.5 借助工具:py_innodb_page_info
手动解析 hex 非常费力,社区有一些工具可以结构化分析 .ibd 文件,例如 py_innodb_page_info(Python 脚本)或 innotop。这里简单演示用法:
bash
# 下载 py_innodb_page_info 工具(项目地址可搜索 GitHub)
python py_innodb_page_info.py /var/lib/mysql/library_db/peek_test.ibd
工具会列出文件中每个页的类型(B-tree Node、Uncompressed BLOB 等)和基本信息。感兴趣的同学可以深入探索。
清理测试表:
sql
DROP TABLE peek_test;
9. 小结
今天的内容从宏观进入了微观,我们完成了对 InnoDB 数据页与行记录的"解剖":
- 页结构 7 大块:文件头(跨页双向链表、LSN、页类型)→ 页头(目录槽数、空闲空间、碎片)→ 最小/最大虚拟记录 → 用户记录 → 空闲空间 → 页目录(槽+二分查找)→ 文件尾(校验和、LSN)。
- 行结构:变长字段长度列表(逆序)→ NULL 标志位 → 记录头(删除标记、下条记录偏移等)→ 隐藏列(DB_ROW_ID、DB_TX_ID、DB_ROLL_PTR)→ 用户列数据。
- 三个隐藏列 中,
DB_TX_ID和DB_ROLL_PTR是 MVCC 的物理基石,DB_ROW_ID在没有主键时充当聚簇索引键。 - 通过 hexdump 实战,我们亲手看到了 .ibd 文件的二进制内容,验证了理论结构的真实存在。
理解页和行的物理结构,是理解索引分裂、页合并、MVCC 版本链、崩溃恢复 等高级主题的前提。下一篇文章,我们将从磁盘上到内存,探索 InnoDB 的内存架构,看看 Buffer Pool、Change Buffer 和 Log Buffer 是如何协同工作,让 MySQL 在高并发下保持高性能的。
思考题:
- 页目录中的槽和 B+Tree 的非叶子节点有何异同?
- 如果一行包含 10 个 VARCHAR 列,其中第 3 列和第 7 列是 NULL,变长字段长度列表和 NULL 标志位分别如何存储?
- 尝试用 hexdump 观察你系统中某个已有表的 .ibd 文件,找找文件头和 Infimum 标记(
69 6e 66 69 6d 75 6d)。
参考资料
- MySQL 8.0 Reference Manual - InnoDB Physical Row Structure
- MySQL Internals Manual - InnoDB Page Structure
- Jeremy Cole - InnoDB Page Structure