InnoDB 页结构与行结构揭秘

在上一篇文章中,我们了解了 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_PREVFIL_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_FREEPAGE_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 变长字段长度列表

对于 VARCHARTEXTBLOB 等变长列,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_IDDB_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_PREVFIL_PAGE_NEXTff ff ff ff,表示没有兄弟页。
  • FIL_PAGE_LSN 出现在文件头中的位置可以参照 InnoDB 源码中 fil_header_t 结构体来精确定位,这里不做深入。

8.4 找到行记录

在 hexdump 中找到 InfimumSupremum 的标记通常比较困难,因为它们与具体数据混在一起。你可以搜索 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 NodeUncompressed BLOB 等)和基本信息。感兴趣的同学可以深入探索。

清理测试表

sql 复制代码
DROP TABLE peek_test;

9. 小结

今天的内容从宏观进入了微观,我们完成了对 InnoDB 数据页与行记录的"解剖":

  • 页结构 7 大块:文件头(跨页双向链表、LSN、页类型)→ 页头(目录槽数、空闲空间、碎片)→ 最小/最大虚拟记录 → 用户记录 → 空闲空间 → 页目录(槽+二分查找)→ 文件尾(校验和、LSN)。
  • 行结构:变长字段长度列表(逆序)→ NULL 标志位 → 记录头(删除标记、下条记录偏移等)→ 隐藏列(DB_ROW_ID、DB_TX_ID、DB_ROLL_PTR)→ 用户列数据。
  • 三个隐藏列 中,DB_TX_IDDB_ROLL_PTR 是 MVCC 的物理基石,DB_ROW_ID 在没有主键时充当聚簇索引键。
  • 通过 hexdump 实战,我们亲手看到了 .ibd 文件的二进制内容,验证了理论结构的真实存在。

理解页和行的物理结构,是理解索引分裂、页合并、MVCC 版本链、崩溃恢复 等高级主题的前提。下一篇文章,我们将从磁盘上到内存,探索 InnoDB 的内存架构,看看 Buffer Pool、Change Buffer 和 Log Buffer 是如何协同工作,让 MySQL 在高并发下保持高性能的。

思考题

  1. 页目录中的槽和 B+Tree 的非叶子节点有何异同?
  2. 如果一行包含 10 个 VARCHAR 列,其中第 3 列和第 7 列是 NULL,变长字段长度列表和 NULL 标志位分别如何存储?
  3. 尝试用 hexdump 观察你系统中某个已有表的 .ibd 文件,找找文件头和 Infimum 标记(69 6e 66 69 6d 75 6d)。

参考资料


相关推荐
Amnesia0_04 小时前
MYSQL表的约束
数据库·mysql
渣渣盟5 小时前
MySQL DQL全面解析:从入门到精通
数据库·sql·mysql·dql
这个DBA有点耶5 小时前
InnoDB架构深潜:从磁盘到内存,一条SQL的生命周期
数据库·mysql·程序员
Fanta丶7 小时前
17.MySql 联合索引 左前缀法则和范围查询
mysql
C137的本贾尼9 小时前
深入 ACID 与事务隔离级别
mysql
CodeStats9 小时前
从JDBC时代到MyBatis封神:SQL全流程手写ORM实战
sql·mysql·mybatis
Lyyaoo.9 小时前
【MySQL】存储引擎
数据库·mysql
曾瑞铭Raymond10 小时前
【侄女零基础升级打怪】Vibe Coding氛围编程 AI编程之MySQL 新手学习指引
mysql·ai编程·零基础学ai·瑞铭进阶升级练习稿·ai氛围编程思维
AOwhisky10 小时前
学习自测与解析:MySQL 系列第三期与第四期
linux·运维·数据库·学习·mysql·云计算