InnoDB 数据页的结构

InnoDB 记录的存储结构 中说到页是 InnoDB 中磁盘和内存交换的基本单位,大小为 16KB

InnoDB 为了不同目的设计了多种不同类型的页,如:存放表空间头部信息的页、存放 Change Buffer 信息的页、存放 INODE 信息的页、存放 undo 日志信息的页

不要慌,本片文章主要介绍存放用户记录的FIL_PAGE_INDEX页,称为索引页,但是为了更贴合实际含义,本片文章将索引页换个称呼:数据页 (只要明白索引页和数据页是一个东西即可,仅限本文)

数据页结构

本部分先给出数据页的整体格式,也就是数据页中每个部分都是什么,至于每一部分具体有什么作用,后文慢慢介绍!!

3

上图中各部分含义如下表:

名称中文名占用空间大小简单描述
File Header文件头部38 字节页的一些通用信息,所有不同类型页共有的信息
Page Header页面头部56 字节数据页专用的一些信息
Infimum + Supremum页面中的最小记录 + 最大记录26 字节两个虚拟记录
User Records用户记录不确定用户存储的记录内容
Free Space空闲空间不确定页中尚未使用的空间
Page Directory页目录不确定页中每个分组中最大记录的相对位置
File Trailer文件尾部8 字节校验页是否完成

由于每次添加用户记录都是从空闲空间中分配一部分内存空间,所以这两部分的大小无法确定,此消彼长的关系,类似于 Java 中通过指针碰撞为对象分配内存

由于页目录中槽的数量需要根据记录中的分组情况而定,记录数据不确定,所以页目录的大小页不确定!!

Infimum + Supremum 记录

数据页中的七个部分并不打算按顺序介绍,先挑核心内容说。既然数据页是专门用来存放记录的页,那么就先从 Infimum + Supremum 两个记录开始~

从数据页的结构上可以看到在 User Records 上面有两个特殊的记录 Infimum + Supremum,分别表示该数据页中最小记录和最大记录。这两个记录并非人为创建,而是 InnoDB 默认自动创建滴

它们俩的格式和普通的用户记录有一些差别,比用户记录更简单,只由两部分组成:5 字节大小记录头信息 (这一部分和用户记录相同)、8 字节大小存储一个固定单词,具体如下图所示:

7

后面 8 字节分别表示单词 infimum 和 supremum,Infimum 记录中的第 8 个字节没有用到,只用了前七个字节

用户记录在数据页中通过链表按照主键的大小递增排列,这也是为什么在没有定义主键时,需要用隐藏列 row_id 作为主键,详情可见 主键生成策略,而 Infimum + Supremum 正好表示最小和最大的记录

记录在页中的存储

在一开始生成数据页的时候,其实是没有用户记录部分的,因为用户记录数量为 0,每次插入新的用户记录时,都会从 Free Space 中申请一个和插入记录大小相等的内存空间,然后将记录插入即可

当 Free Space 空间用完后,该数据页就无法再添加新的记录,需要申请新的页。下面给出插入 n 条用户记录的过程:(为了简化,数据页的其它部分就不画了)

4

注意:实际过程中,每个用户记录的长度是不一样的,而且也不像上图中那样规则排列,而是紧密排列,真实情况如下图所示:

5

到此为止介绍的记录在页中的存储还处于宏观层面,至于记录与记录之间到底是如何组织还一无所知。下面就深入底层,去领略一下微观世界的美丽~

InnoDB 行格式 中介绍了记录头信息,固定为 5 字节,它在组织记录的时候起到了很大的作用。本部分侧重记录头中的值,而非每部分的位数大小,所以都用十进制表示值,实际中是十六进制表示

记录头中各属性的详细信息如下表所示:

名称大小 (bit)描述
预留位 11没有使用
预留位 21没有使用
deleted_flag1标记该记录是否被删除
min_rec_flag1B+ 树的每层非叶子节点中最小的目录项记录都会添加该标记
n_owned4一个页面中的记录会被分为若干个组,每个组中都有一个记录是「带头大哥」,其余的记录都是「小弟」
「带头大哥」记录的 n_owned 值代表该组中所有的记录条数
「小弟」记录的 n_owned 值都为 0
heap_no13表示当前记录在页面堆中的相对位置
record_type3表示当前记录的类型,0 表示普通记录,1 表示 B+ 树非叶子节点的目录项记录
2 表示 Infimum 记录,3 表示 Supremum 记录
next_record16表示下一条记录的偏移量

一条记录的简化图如下:

6

现在向一个只有三个字段的表中插入 4 条记录,那么该数据页中用户记录部分的详细布局如下图所示:(把 Infimum + Supremum 记录也加上)

8

下面所有的介绍都围绕上图展开!!!

deleted_flag

deleted_flag 是记录头信息中除了预留位之外第一个属性,它用来标记该记录是否被删除,1 表示已删除,0 表示未删除

当一个记录被删除时,其实它并没有真正的从页中删除,仅仅做了三件事情:

这样做的好处在于:如果真正的从页面中删除,需要对该页重新排列,也就是将被删除记录后面的记录整体向前移动,这样无疑带来性能上的消耗;仅仅标记为删除,但不对磁盘重新排列可以大大降低删除开销,而且如果后续有新纪录插入,就可能覆盖掉删除的这些记录所占用的存储空间,即:在新记录覆盖到已删除记录的位置

min_rec_flag

B+ 树的每层非叶子节点中最小的目录项记录都会添加该标记,1 表示添加了该标记,0 表示未添加该标记

(后续介绍索引时详细介绍该属性,到时候会更新该文章)

n_owned

留到后面介绍,可见 Page Directory

heap_no

记录在页中的存储 中介绍过,记录在数据页中是紧密排列的,InnoDB 将紧密排列的记录称之为堆 (heap),heap_no 就是记录在堆中的编号,从 0 开始

但是编号 0 和 1 已经被 Infimum 和 Supremum 记录使用,所以用户记录是从编号 2 开始,物理位置越靠后记录的编号越大,当前记录的 heap_no 是前一个记录的 heap_no + 1

而且一个记录被分配了 heap_no 后就不会再改变,即使该记录被删了,也仅仅只是将 deleted_flag 置为 1,heap_no 依旧保持不变

文章 Infimum + Supremum 记录 部分最后提到:用户记录在数据页中通过链表按照主键的大小递增排列,下面好好解释一下这句话!

当插入一个用户记录时,在物理磁盘中,其实是直接从 Free Space 中划分了一部分空间,然后加入到了 User Records 中的最后面,那么思考:即然物理上无序,怎么保证数据页中记录按主键递增呢?

关键点就在于 next_record 属性,它存储了下一条记录的偏移量,可以看作是一个指针,通过 next_record 属性,可以保证用户记录在物理上无序,但在逻辑上有序

假设依次插入了三条记录,主键分别是 5、6、1,这三条记录以及 Infimum 和 Supremum 记录在磁盘中的物理位置关系就是紧密的从上到下排列,然后通过 next_record 保证记录在逻辑上有序

9

record_type

record_type 属性表示当前记录的类型,0 表示普通记录,1 表示 B+ 树非叶子节点的目录项记录,2 表示 Infimum 记录,3 表示 Supremum 记录

next_record

next_record 记录着下一条记录的偏移量,类似于链表中的指针,也是它保证着记录的有序性,在 heap_no 部分介绍了如何保持有序

从 Infimum 沿着 next_record 向后遍历的顺序就是主键递增的顺序,在数据库查询的时候常常都是根据主键查询某个记录,所以可以借助有序性的特点使用二分搜索,提高查询的速度

但由于 next_record 只记录了下一个记录的偏移量,所以不好实现二分搜索,就类似于有序链表中也不方便使用二分查找,这里就需要引出下一个 Page Directory 部分

Page Directory

虽然 Page Directory 不属于记录头的内容,但它和记录头息息相关,也和高效查询有很大的关系,所以就放一起介绍咯

数据页的记录之间有一个分组的概念,所有用户记录,包括 Infimum 和 Supremum 记录会被分为若干组,每一组中最大的记录是该组的「带头大哥」,负责存储组中记录的数量 (被删除的记录不会参与分组)

这个时候就可以很简单的解释清楚 n_owned 属性的含义,如果是一个分组中最大的记录,那么 n_owned 表示该分组内记录的数量;如果不是一个分组中最大的记录,那么 n_owned 为 0

注意:Infimum 和 Supremum 记录不会被分到一组,也就是如果数据页中只有这两个记录,那么会被分为两组

将每一组「带头大哥」相对数据页起始位置的偏移量按序存入到 Page Directory 中,越靠前的组存入到 Page Directory 越靠右的位置,如 记录分布图 所示,为了更清晰,将图重构一波~

下图为了清晰,将 Supremum 记录的位置调整了一波,但和实际物理上的布局有差别

10

槽 0 的偏移量为 99,因为 Infimum 记录前面有 File Header (38 字节) 和 Page Header (56 字节),而且 Infimum 记录头信息占 5 字节,所以 38 + 56 + 5 = 99

槽 1 的偏移量为 112,上面已经算过槽 0 的偏移量,只需要在此基础上加 (8 + 5) 字节即可,正好 99 + 8 + 5 = 112

由于数据页的大小为 16KB,所以偏移量最多只有 16384 字节,2 字节 (216=65536) 完全可以表示该数量,所以 Page Directory 每个槽 2 字节就够了

InnoDB 对每个分组中记录的数量有如下规定:

从规定可以推断出其它分组只能从 Supremum 记录所在的分组中分裂而来,当 Supremum 记录所在的分组数量到达 9 个时,就会拆分成两个分组 (4 和 5),同时 Page Directory 会新增一个槽

基于这种设计可以使用二分搜索查询记录,首先对 Page Directory 中的槽进行二分搜索,找到一个大于待查询记录且主键差值最小的槽。如果待查询记录存在,那么一定在该槽对应的分组中,由于分组中记录数量少直接遍历即可

Infimum 是最小的记录,所以二分搜索时不可能搜到 Infimum 记录对应的槽,从而新插入记录也不可能接到 Infimum 记录后面,从这个角度也证明了 Infimum 记录所在的分组只会有 1 个记录

Page Header (页面头部)

Page Header 存储了一些专属于这个类型页面的信息,也就是数据页的信息,由于属性有点多,这里就先罗列出来,方便后续查找

状态名称占用空间大小描述
PAGE_N_DIR_SLOTS2 字节在页目录中的槽数量
PAGE_HEAP_TOP2 字节还未使用的空间最小地址,也就是从该地址后就是 Free Space
PAGE_N_HEAP2 字节第 1 位表示本记录是否为紧凑型,剩余 15 位表示本页的堆中记录的数量 (包括 Supremum、Supremum、标记为已删除的记录)
PAGE_FREE2 字节各个删除的记录通过 next_record 组成一个单向链表,这个单向链表中的记录所占用的存储空间可以被重新利用
PAGE_FREE 表示该链表头节点对应记录在页面中的偏移量
PAGE_GARBAGE2 字节已删除记录占用的字节数
PAGE_LAST_INSERT2 字节最后插入记录的位置
PAGE_DIRECTION2 字节记录插入的方向,新插入记录比最后一条记录的大小关系,大于表示右边,小于表示左边
PAGE_N_DIRECTION2 字节一个方向连续插入的记录数量
PAGE_N_RECS2 字节该页中用户记录的数量 (不包括 Supremum、Supremum、标记为已删除的记录)
PAGE_MAX_TRX_ID8 字节修改当前页的最大事务 id,该值仅在二级索引页面中定义
PAGE_LEVEL2 字节当前页在 B+ 树中所处的层级
PAGE_INDEX_ID8 字节索引 ID,表示当前页属于哪个索引
PAGE_BTR_SEG_LEAF10 字节B+ 树叶子节点段的头部信息,仅在 B+ 树的根页面中定义
PAGE_BTR_SEG_TOP10 字节B+ 树非叶子节点段的头部信息,仅在 B+ 树的根页面中定义

File Header (文件头部)

File Header 存储了各种不同类型页的通用信息,不限于某种具体类型的页,所有的页都有这些属性,由于属性有点多,这里就先罗列出来,方便后续查找

状态名称占用空间大小描述
FIL_PAGE_SPACE_OR_CHKSUM4 字节当 MySQL 版本低于 4.0.14 时,该属性表示本页面所在的表空间 ID
在之后的版本中,该属性表示页的校验和 (checksum)
FIL_PAGE_OFFSET4 字节页号
FIL_PAGE_PREV4 字节上一个页的页号
FIL_PAGE_NEXT4 字节下一个页的页号
FIL_PAGE_LSN8 字节页面被最后修改时对应的 LSN (Log Sequence Number,日志序列号) 值
FIL_PAGE_TYPE2 字节该页的类型
FIL_PAGE_FILE_FLUSH_LSN8 字节仅在系统表空间的第一个页中定义,代表文件至少被刷新到了对应的 LSN 值
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID4 字节页属于哪个表空间

上面的属性有 FIL_PAGE_PREV (上一个页的页号) 和 FIL_PAGE_NEXT (下一个页的页号),类似于两个指针,将所有页连起来形成双向链表,如下图所示:

11

最后再介绍一下有哪些不同类的页面

类型名称十六进制描述
FIL_PAGE_TYPE_ALLOCATED0x0000最新分配,还未使用
FIL_PAGE_UNDO_LOG0x0002undo 日志页
FIL_PAGE_INODE0x0003存储段信息
FIL_PAGE_IBUF_FREE_LIST0x0004Change Buffer 空闲列表
FIL_PAGE_IBUF_BITMAP0x0005Change Buffer 的一些属性
FIL_PAGE_TYPE_SYS0x0006存储一些系统数据
FIL_PAGE_TYPE_TRX_SYS0x0007事务系统数据
FIL_PAGE_TYPE_FSP_HDR0x0008表空间头部信息
FIL_PAGE_TYPE_XDES0x0009存储区的一个属性
FIL_PAGE_TYPE_BLOB0x000A溢出页
FIL_PAGE_INDEX0x45BF索引页,也就是本文中的数据页

File Trailer (文件尾部)

File Trailer 主要的作用就是校验页的完整性,由于磁盘中的页必须加载到内存中才能进行操作,如果在内存中对页进行了修改,那么还需要将内存中的页刷新回磁盘

如果在刷新回磁盘的过程中 MySQL 服务器挂了,那么将出现只有一部分数据刷盘成功,导致磁盘中页的数据不完整,为了避免这种情况,在每个文件尾部都加了一个 File Trailer 部分 (8 字节)