MySQL系列(4)— InnoDB数据页结构

6,487 阅读11分钟

系列文章:MySQL系列专栏

InnoDB 数据页

我们前面简单提到了的概念, 是InnoDB存储引擎管理数据库的最小磁盘单位,一个页的大小一般是16KB。一次至少读取一页的数据到内存,或者刷新一页的数据到磁盘。

我们这节主要来看存放数据记录的页,也就是 INDEX 类型的数据页。

数据页结构

数据页由 7 个部分组成,大致如下图所示:

image.png

其中 File Header、Page Header、File Trailer 的大小是固定的,分别为 38、56、8字节User Records、Free Space、Page Directory 这些部分为实际的行记录存储空间,因此大小是动态的。

记录头信息

前面的文章中简单提到过记录头信息,在介绍后面的内容前,再详细看下记录头信息中存储了哪些内容。

image.png

行记录看下来就像下面这样:

image.png

  • delete_mask

这个属性标记当前记录是否被删除,值为1的时候表示记录被删除掉了,值为0的时候表示记录没有被删除。

可以看出,当删除一条记录时,只是标记删除,实际在页中还没有被移除。这样做的主要目的是,以后如果有新纪录插入表中,可以复用这些已删除记录的存储空间。

  • min_rec_mask

B+树的每层非叶子节点中的最小记录都会添加该标记,并设置为1,否则为0。

看下图的索引结构,最底层的叶子节点是存放真实数据的,所以每条记录的 min_rec_mask 都为 0。上面两层是非叶子节点,那么每个页中最左边的最小记录的 min_rec_mask 就会设置为 1。

image.png

  • n_owned

表示当前记录拥有的记录数,页中的数据其实还会分为多个组,每个组会有一个最大的记录,最大记录的 n_owned 就记录了这个组中的记录数。在后面介绍 Page Directory 时会看到这个属性的用途。

  • heap_no

这个属性表示当前记录在本页中的位置。

  • record_type

记录类型:0 表示普通记录,1 表示B+树非叶子节点记录,2 表示最小记录,3 表示最大记录,1xx 表示保留

还是以前面索引结构图来看,上面两层的非叶子节点中的记录 record_type 都应该为 1。最底层的叶子节点应该就是普通记录,record_type 为 0。其实每个页还会有一个最小记录和最大记录,record_type 分别为 2 和 3,这个最小记录和最大记录其实就是后面要说的 Infimum 和 Supremum。

  • next_record

表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量,如果没有下一条记录就是 0。

数据页中的记录看起来就像下图这样,按主键顺序排列后,heap_no 记录了当前记录在本页的位置,然后通过 next_record 连接起来。

image.png

注意 next_record 指向的是记录头与数据之间的位置偏移量。这个位置向左读取就是记录头信息,向右读取就是真实数据,而且之前说过变长字段长度列表NULL值列表中都是按列逆序存放的,这时往左读取的标识和往右读取的列就对应上了,提高了读取的效率。

如果删除了其中一条记录,delete_mask 就设置为 1,标记为已删除,next_record 就会设置为 0。其实页中被删除的记录会通过 next_record 形成一个垃圾链表,供以后插入记录时重用空间。

image.png

File Header

File Header 用来记录页的一些头信息,由8个部分组成,固定占用38字节

image.png

主要先看下如下的一些信息:

  • FIL_PAGE_SPACE_OR_CHKSUM

这个代表当前页面的校验和(checksum),每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来。在一个页面被刷到磁盘的时候,首先被写入磁盘的就是这个 checksum。

  • FIL_PAGE_OFFSET

每一个页都有一个单独的页号,InnoDB 通过页号来唯一定位一个页。

如某独立表空间 a.ibd 的大小为1GB,页的大小默认为16KB,那么总共有65536个页。FIL_PAGE_OFFSET 表示该页在所有页中的位置。若此表空间的ID为10,那么搜索页(10,1)就表示查找表a中的第二个页。

  • FIL_PAGE_PREVFIL_PAGE_NEXT

InnoDB 是以页为单位存放数据的,InnoDB 表是索引组织的表,数据是按主键顺序存放的。数据可能会分散到多个不连续的页中存储,这时就会通过 FIL_PAGE_PREV 和 FIL_PAGE_NEXT 将上一页和下一页连起来,就形成了一个双向链表。这样就通过一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。

image.png

  • FIL_PAGE_TYPE

这个代表当前页的类型,InnoDB 为了不同的目的而设计了许多种不同类型的页。

InnoDB 有如下的一些页类型:

image.png

Page Header

Page Header 用来记录数据页的状态信息,由14个部分组成,共占用56字节

image.png

  • PAGE_N_DIR_SLOTS

页中的记录会按主键顺序分为多个组,每个组会对应到一个槽(Slot),PAGE_N_DIR_SLOTS 就记录了 Page Directory 中槽的数量。

  • PAGE_HEAP_TOP

PAGE_HEAP_TOP 记录了 Free Space 的地址,这样就可以快速从 Free Space 分配空间到 User Records 了。

  • PAGE_N_HEAP

本页中的记录的数量,包括最小记录(Infimum)和最大记录(Supremum)以及标记为删除(delete_mask=1)的记录。

  • PAGE_FREE

已删除的记录会通过 next_record连成一个单链表,这个单链表中的记录空间可以被重新利用,PAGE_FREE 指向第一个标记为删除的记录地址,就是单链表的头节点。

  • PAGE_GARBAGE

标记为已删除的记录占用的总字节数。

  • PAGE_N_RECS

本页中记录的数量,不包括最小记录和最大记录以及被标记为删除的记录,注意和 PAGE_N_HEAP 的区别。

Infimum 和 Supremum

InnoDB 每个数据页中有两个虚拟的行记录,用来限定记录的边界。Infimum记录是比该页中任何主键值都要小的记录,Supremum记录 是比改页中何主键值都要大的记录。这两个记录在页创建时被建立,并且在任何情况下不会被删除。

并且由于这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分。

Infimum 和 Supremum 都是由5字节的记录头和8字节的一个固定的部分组成,最小记录的固定部分就是单词 infimum,最大记录的固定部分就是单词 supremum。由于不存在可变长字段或可为空的字段,自然就不存在可变长度字段列表和NULL值列表了。

Infimum和Supremum记录的结构如下图所示。需要注意,Infimum 记录头的 record_type=2,表示最小记录;Supremum 记录头的 record_type=3,表示最大记录。

image.png

加上 Infimum 和 Supremum 记录后,页中的记录看起来就像下图的样子。Infimum 记录头的 next_record 指向该页主键最小的记录,该页主键最大的记录的 next_record 则指向 Supremum,Infimum 和 Supremum就构成了记录的边界。同时注意,记录头中 heap_no 的顺序, Infimum 和 Supremum 是排在最前面的。

image.png

User Records 和 Free Space

User Records就是实际存储行记录的部分,Free Space明显就是空闲空间。

在一开始生成页的时候,并没有User Records这个部分,每当插入一条记录,就会从Free Space部分中申请一个记录大小的空间到User Records部分,当 Free Space 部分的空间用完之后,这个页也就使用完了。

Page Directory

首先我们要知道,InnoDB 的数据是索引组织的,B+树索引本身并不能找到具体的一条记录,只能找到该记录所在的页,页是存储数据的最小基本单位。

如下图,如果我们要查找 ID=32 的这行数据,通过索引只能定位到第 17 页。

image.png

定位到页之后我们可以通过最小记录Infimum的记录头的next_record沿着链表一直往后找,就可以找到 ID=32 这条记录了。

但是可以想象,沿着链表顺序查找的性能是很低的。所以,页中的数据其实是分为多个组的,这看起来就形成了一个子目录,通过子目录就能缩小查询的范围,提高查询性能了。

Page Directory 翻译过来就是页目录,这部分存放的就是一个个的槽(Slot),页中的记录分为了多个组,槽就存放了每个组中最大的那条记录的相对位置(记录在页中的相对位置,不是偏移量)。这个组有多少条记录,就通过最大记录的记录头中的 n_owned 来表示。

对于分组中的记录数是有规定的:Infimum记录 所在的分组只能有 1 条记录,Supremum记录 所在的分组中的记录条数只能在 1~8 条之间,中间的其它分组中记录数只能在是 4~8 条之间。

Page Directory 的生成过程如下:

  • 初始情况下一个数据页里只有InfimumSupremum两条记录,它们分属于两个组。Page Directory 中就有两个槽,分别指向这两条记录,且这两条记录的 n_owned 都等于 1

  • 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的 n_owned 值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8条

  • 在一个组中的记录数等于8条后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的相对位置。

  • 当记录被删除时,对应槽的最大记录的 n_owned 会减 1,当 n_owned 小于 4 时,各分组就会平衡一下,总之要满足上面的规定。

其实正常情况下,按照主键自增长新增记录,可能每次都是添加到 Supremum 所在的组,直到它的 n_owned 等于8时,再新增记录时就会分成两个组,一个组4条记录,一个组5条记录。还会新增一个槽,指向4条记录分组中的最大记录,并且这个最大记录的n_owned会改为4Supremumn_owned就会改为5

Page Directory 中槽(Slot)的数量就会记录到 Page Header 中的 PAGE_N_DIR_SLOTS

我们可以通过下图来理解下 Page Directory 中槽(Slot)和分组中最大记录的关系。

  • 首先,Slot0 指向 Infimum 记录,因为最小记录所在的分组只能有一条记录,它的 n_owned=1.
  • 接着 Slot1、Slot2、Slot3 分别指向各自分组中的最大记录,且 n_owned=4,可以想象其实就是 Supremum 组分组而来的。
  • 最后,Slot4 指向 Supremum,这是最大记录的组,经过分组后,它的 n_owned=5

image.png

可以看到,页中的数据经过分组后在 Page Directory 中就形成了一个目录槽,每个槽就指向了分组中的最大记录,最大记录的记录头中的 n_owned 就记录了这个组中的记录数。

有了目录槽之后,InnoDB就会利用二叉查找迅速确定记录所在的槽,并找到该槽所在分组中主键值最小的那条记录,再通过最小记录的 next_record 遍历记录,就能快速定位到匹配的那条记录了。

二叉查找的时间复杂度很低,同时在内存中的查找很快,因此通常会忽略这部分查找所用的时间。

File Trailer

前面介绍 File Header 时说过,在将页写入磁盘时,最先写入的便是 File Header 中的 FIL_PAGE_SPACE_OR_CHKSUM 值,就是页面的校验和。在写入的过程中,数据库可能发生宕机,导致页没有完整的写入磁盘。

为了校验页是否完整写入磁盘,InnoDB 就设置了 File Trailer 部分。File Trailer 中只有一个FIL_PAGE_END_LSN,占用8字节。FIL_PAGE_END_LSN 又分为两个部分,前4字节代表页的校验和;后4字节代表页面被最后修改时对应的日志序列位置(LSN),与File Header中的FIL_PAGE_LSN相同。

默认情况下,InnoDB存储引擎每次从磁盘读取一个页就会检测该页的完整性,这时就会将 File Trailer 中的校验和、LSN 与 File Header 中的 FIL_PAGE_SPACE_OR_CHKSUMFIL_PAGE_LSN 进行比较,以此来保证页的完整性。