InnoDB 记录的存储结构 中说到页是 InnoDB 中磁盘和内存交换的基本单位,大小为 16KB
InnoDB 为了不同目的设计了多种不同类型的页,如:存放表空间头部信息的页、存放 Change Buffer 信息的页、存放 INODE 信息的页、存放 undo 日志信息的页
不要慌,本片文章主要介绍存放用户记录的FIL_PAGE_INDEX
页,称为索引页,但是为了更贴合实际含义,本片文章将索引页换个称呼:数据页 (只要明白索引页和数据页是一个东西即可,仅限本文)
本部分先给出数据页的整体格式,也就是数据页中每个部分都是什么,至于每一部分具体有什么作用,后文慢慢介绍!!
上图中各部分含义如下表:
名称 | 中文名 | 占用空间大小 | 简单描述 |
---|---|---|---|
File Header | 文件头部 | 38 字节 | 页的一些通用信息,所有不同类型页共有的信息 |
Page Header | 页面头部 | 56 字节 | 数据页专用的一些信息 |
Infimum + Supremum | 页面中的最小记录 + 最大记录 | 26 字节 | 两个虚拟记录 |
User Records | 用户记录 | 不确定 | 用户存储的记录内容 |
Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
Page Directory | 页目录 | 不确定 | 页中每个分组中最大记录的相对位置 |
File Trailer | 文件尾部 | 8 字节 | 校验页是否完成 |
由于每次添加用户记录都是从空闲空间中分配一部分内存空间,所以这两部分的大小无法确定,此消彼长的关系,类似于 Java 中通过指针碰撞为对象分配内存
由于页目录中槽的数量需要根据记录中的分组情况而定,记录数据不确定,所以页目录的大小页不确定!!
数据页中的七个部分并不打算按顺序介绍,先挑核心内容说。既然数据页是专门用来存放记录的页,那么就先从 Infimum + Supremum 两个记录开始~
从数据页的结构上可以看到在 User Records 上面有两个特殊的记录 Infimum + Supremum,分别表示该数据页中最小记录和最大记录。这两个记录并非人为创建,而是 InnoDB 默认自动创建滴
它们俩的格式和普通的用户记录有一些差别,比用户记录更简单,只由两部分组成:5 字节大小记录头信息 (这一部分和用户记录相同)、8 字节大小存储一个固定单词,具体如下图所示:
后面 8 字节分别表示单词 infimum 和 supremum,Infimum 记录中的第 8 个字节没有用到,只用了前七个字节
用户记录在数据页中通过链表按照主键的大小递增排列,这也是为什么在没有定义主键时,需要用隐藏列 row_id 作为主键,详情可见 主键生成策略,而 Infimum + Supremum 正好表示最小和最大的记录
在一开始生成数据页的时候,其实是没有用户记录部分的,因为用户记录数量为 0,每次插入新的用户记录时,都会从 Free Space 中申请一个和插入记录大小相等的内存空间,然后将记录插入即可
当 Free Space 空间用完后,该数据页就无法再添加新的记录,需要申请新的页。下面给出插入 n 条用户记录的过程:(为了简化,数据页的其它部分就不画了)
注意:实际过程中,每个用户记录的长度是不一样的,而且也不像上图中那样规则排列,而是紧密排列,真实情况如下图所示:
到此为止介绍的记录在页中的存储还处于宏观层面,至于记录与记录之间到底是如何组织还一无所知。下面就深入底层,去领略一下微观世界的美丽~
在 InnoDB 行格式 中介绍了记录头信息,固定为 5 字节,它在组织记录的时候起到了很大的作用。本部分侧重记录头中的值,而非每部分的位数大小,所以都用十进制表示值,实际中是十六进制表示
记录头中各属性的详细信息如下表所示:
名称 | 大小 (bit) | 描述 |
---|---|---|
预留位 1 | 1 | 没有使用 |
预留位 2 | 1 | 没有使用 |
deleted_flag | 1 | 标记该记录是否被删除 |
min_rec_flag | 1 | B+ 树的每层非叶子节点中最小的目录项记录都会添加该标记 |
n_owned | 4 | 一个页面中的记录会被分为若干个组,每个组中都有一个记录是「带头大哥」,其余的记录都是「小弟」 「带头大哥」记录的 n_owned 值代表该组中所有的记录条数 「小弟」记录的 n_owned 值都为 0 |
heap_no | 13 | 表示当前记录在页面堆中的相对位置 |
record_type | 3 | 表示当前记录的类型,0 表示普通记录,1 表示 B+ 树非叶子节点的目录项记录 2 表示 Infimum 记录,3 表示 Supremum 记录 |
next_record | 16 | 表示下一条记录的偏移量 |
一条记录的简化图如下:
现在向一个只有三个字段的表中插入 4 条记录,那么该数据页中用户记录部分的详细布局如下图所示:(把 Infimum + Supremum 记录也加上)
下面所有的介绍都围绕上图展开!!!
deleted_flag 是记录头信息中除了预留位之外第一个属性,它用来标记该记录是否被删除,1 表示已删除,0 表示未删除
当一个记录被删除时,其实它并没有真正的从页中删除,仅仅做了三件事情:
将该记录的 deleted_flag 设为 1
修改 next_record 指针 (后文介绍 next_record 的作用)
被删除记录组成一个垃圾链表,被称为可重用空间
这样做的好处在于:如果真正的从页面中删除,需要对该页重新排列,也就是将被删除记录后面的记录整体向前移动,这样无疑带来性能上的消耗;仅仅标记为删除,但不对磁盘重新排列可以大大降低删除开销,而且如果后续有新纪录插入,就可能覆盖掉删除的这些记录所占用的存储空间,即:在新记录覆盖到已删除记录的位置
B+ 树的每层非叶子节点中最小的目录项记录都会添加该标记,1 表示添加了该标记,0 表示未添加该标记
(后续介绍索引时详细介绍该属性,到时候会更新该文章)
留到后面介绍,可见 Page Directory
记录在页中的存储 中介绍过,记录在数据页中是紧密排列的,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 保证记录在逻辑上有序
record_type 属性表示当前记录的类型,0 表示普通记录,1 表示 B+ 树非叶子节点的目录项记录,2 表示 Infimum 记录,3 表示 Supremum 记录
next_record 记录着下一条记录的偏移量,类似于链表中的指针,也是它保证着记录的有序性,在 heap_no 部分介绍了如何保持有序
从 Infimum 沿着 next_record 向后遍历的顺序就是主键递增的顺序,在数据库查询的时候常常都是根据主键查询某个记录,所以可以借助有序性的特点使用二分搜索,提高查询的速度
但由于 next_record 只记录了下一个记录的偏移量,所以不好实现二分搜索,就类似于有序链表中也不方便使用二分查找,这里就需要引出下一个 Page Directory 部分
虽然 Page Directory 不属于记录头的内容,但它和记录头息息相关,也和高效查询有很大的关系,所以就放一起介绍咯
数据页的记录之间有一个分组的概念,所有用户记录,包括 Infimum 和 Supremum 记录会被分为若干组,每一组中最大的记录是该组的「带头大哥」,负责存储组中记录的数量 (被删除的记录不会参与分组)
这个时候就可以很简单的解释清楚 n_owned 属性的含义,如果是一个分组中最大的记录,那么 n_owned 表示该分组内记录的数量;如果不是一个分组中最大的记录,那么 n_owned 为 0
注意:Infimum 和 Supremum 记录不会被分到一组,也就是如果数据页中只有这两个记录,那么会被分为两组
将每一组「带头大哥」相对数据页起始位置的偏移量按序存入到 Page Directory 中,越靠前的组存入到 Page Directory 越靠右的位置,如 记录分布图 所示,为了更清晰,将图重构一波~
下图为了清晰,将 Supremum 记录的位置调整了一波,但和实际物理上的布局有差别
槽 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 字节 (
InnoDB 对每个分组中记录的数量有如下规定:
Infimum 记录所在的分组只能有 1 个记录
Supremum 记录所在的分组只能有 1 ~ 8 个记录
其它分组只能有 4 ~ 8 个记录
从规定可以推断出其它分组只能从 Supremum 记录所在的分组中分裂而来,当 Supremum 记录所在的分组数量到达 9 个时,就会拆分成两个分组 (4 和 5),同时 Page Directory 会新增一个槽
基于这种设计可以使用二分搜索查询记录,首先对 Page Directory 中的槽进行二分搜索,找到一个大于待查询记录且主键差值最小的槽。如果待查询记录存在,那么一定在该槽对应的分组中,由于分组中记录数量少直接遍历即可
Infimum 是最小的记录,所以二分搜索时不可能搜到 Infimum 记录对应的槽,从而新插入记录也不可能接到 Infimum 记录后面,从这个角度也证明了 Infimum 记录所在的分组只会有 1 个记录
Page Header 存储了一些专属于这个类型页面的信息,也就是数据页的信息,由于属性有点多,这里就先罗列出来,方便后续查找
状态名称 | 占用空间大小 | 描述 |
---|---|---|
PAGE_N_DIR_SLOTS | 2 字节 | 在页目录中的槽数量 |
PAGE_HEAP_TOP | 2 字节 | 还未使用的空间最小地址,也就是从该地址后就是 Free Space |
PAGE_N_HEAP | 2 字节 | 第 1 位表示本记录是否为紧凑型,剩余 15 位表示本页的堆中记录的数量 (包括 Supremum、Supremum、标记为已删除的记录) |
PAGE_FREE | 2 字节 | 各个删除的记录通过 next_record 组成一个单向链表,这个单向链表中的记录所占用的存储空间可以被重新利用 PAGE_FREE 表示该链表头节点对应记录在页面中的偏移量 |
PAGE_GARBAGE | 2 字节 | 已删除记录占用的字节数 |
PAGE_LAST_INSERT | 2 字节 | 最后插入记录的位置 |
PAGE_DIRECTION | 2 字节 | 记录插入的方向,新插入记录比最后一条记录的大小关系,大于表示右边,小于表示左边 |
PAGE_N_DIRECTION | 2 字节 | 一个方向连续插入的记录数量 |
PAGE_N_RECS | 2 字节 | 该页中用户记录的数量 (不包括 Supremum、Supremum、标记为已删除的记录) |
PAGE_MAX_TRX_ID | 8 字节 | 修改当前页的最大事务 id,该值仅在二级索引页面中定义 |
PAGE_LEVEL | 2 字节 | 当前页在 B+ 树中所处的层级 |
PAGE_INDEX_ID | 8 字节 | 索引 ID,表示当前页属于哪个索引 |
PAGE_BTR_SEG_LEAF | 10 字节 | B+ 树叶子节点段的头部信息,仅在 B+ 树的根页面中定义 |
PAGE_BTR_SEG_TOP | 10 字节 | B+ 树非叶子节点段的头部信息,仅在 B+ 树的根页面中定义 |
File Header 存储了各种不同类型页的通用信息,不限于某种具体类型的页,所有的页都有这些属性,由于属性有点多,这里就先罗列出来,方便后续查找
状态名称 | 占用空间大小 | 描述 |
---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM | 4 字节 | 当 MySQL 版本低于 4.0.14 时,该属性表示本页面所在的表空间 ID 在之后的版本中,该属性表示页的校验和 (checksum) |
FIL_PAGE_OFFSET | 4 字节 | 页号 |
FIL_PAGE_PREV | 4 字节 | 上一个页的页号 |
FIL_PAGE_NEXT | 4 字节 | 下一个页的页号 |
FIL_PAGE_LSN | 8 字节 | 页面被最后修改时对应的 LSN (Log Sequence Number,日志序列号) 值 |
FIL_PAGE_TYPE | 2 字节 | 该页的类型 |
FIL_PAGE_FILE_FLUSH_LSN | 8 字节 | 仅在系统表空间的第一个页中定义,代表文件至少被刷新到了对应的 LSN 值 |
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4 字节 | 页属于哪个表空间 |
上面的属性有 FIL_PAGE_PREV (上一个页的页号) 和 FIL_PAGE_NEXT (下一个页的页号),类似于两个指针,将所有页连起来形成双向链表,如下图所示:
最后再介绍一下有哪些不同类的页面
类型名称 | 十六进制 | 描述 |
---|---|---|
FIL_PAGE_TYPE_ALLOCATED | 0x0000 | 最新分配,还未使用 |
FIL_PAGE_UNDO_LOG | 0x0002 | undo 日志页 |
FIL_PAGE_INODE | 0x0003 | 存储段信息 |
FIL_PAGE_IBUF_FREE_LIST | 0x0004 | Change Buffer 空闲列表 |
FIL_PAGE_IBUF_BITMAP | 0x0005 | Change Buffer 的一些属性 |
FIL_PAGE_TYPE_SYS | 0x0006 | 存储一些系统数据 |
FIL_PAGE_TYPE_TRX_SYS | 0x0007 | 事务系统数据 |
FIL_PAGE_TYPE_FSP_HDR | 0x0008 | 表空间头部信息 |
FIL_PAGE_TYPE_XDES | 0x0009 | 存储区的一个属性 |
FIL_PAGE_TYPE_BLOB | 0x000A | 溢出页 |
FIL_PAGE_INDEX | 0x45BF | 索引页,也就是本文中的数据页 |
File Trailer 主要的作用就是校验页的完整性,由于磁盘中的页必须加载到内存中才能进行操作,如果在内存中对页进行了修改,那么还需要将内存中的页刷新回磁盘
如果在刷新回磁盘的过程中 MySQL 服务器挂了,那么将出现只有一部分数据刷盘成功,导致磁盘中页的数据不完整,为了避免这种情况,在每个文件尾部都加了一个 File Trailer 部分 (8 字节)
前 4 个字节表示页的校验和,这部分和 File Header 中的第一个属性 FIL_PAGE_SPACE_OR_CHKSUM 相对应。如果刷新成功,那么页首和页尾的校验和应该是一致的
后 4 个字节表示页面最后被修改时对应的 LSN 的后 4 个字节,正常情况下应该和 File Header 中的 FIL_PAGE_LSN 属性的后 4 个字节相同