# 前言

之前说过,页是 InnoDB 管理磁盘空间的基本单位,一个页的大小基本上是 16KB。MySQL 规定一页至少存两条记录。我们从客户端写入表中的记录会被存在页中,其实还有一种记录,它存在于 B + 树的非叶子节点的页中,其头信息中的 record_type=1

目前你只需理解,页中存储的记录要么是我们从客户端写入的真实的记录 record_type=0 。这样的页称为索引页;另一个是 B + 树非叶子节点的目录项记录 record_type=1

# 页结构

本节内容主要是就要了解页的大致结构和其存储记录的方式,下图为一个页的基本结构。

image-20220726110822415

之后我们会对其中的结构一 一讲解。但是直接给出很多概念会让你失去阅读兴趣,所以我们先看一下记录怎么在页中存储。

# 记录在页中的存储

请看上图的 User RecordsFree Space ,我们的记录就存放在 User Records ,如果 User Records 的空间不够了,就向 Free Space 去申请空间。所以一个页刚开始创建的时候,是没有 User Records 的。当 Free Space 全部被 User Record 代替时,该页就满了。恭喜,页结构七个概念你已经懂了两个。

我们的记录在 User Record 里面是一条一条摆放进去的,但是我现在要提一下一条记录里的头信息(你不会已经忘了什么是头信息了吧,不会吧,不会吧)。

image-20220725112044762

record_type 可以是,0-- 普通记录,1--B + 树非叶子节点的目录项记录,2--Infimum,3--supremum。请看页结构图,红色区域就是 Infimum,supremum ,也就是说,每个页固定都有这两条记录,他们代表所有记录中的最小记录(链表头节点)以及所有记录中的最大记录(链表尾节点),** 尽管它们没有主键值,但它们就是最小和最大。** 这两条记录没有实际内容,但这是规定!这样每次查询记录时,都能从一个固定的起点查询。

image-20220726131306378

User Record 里面的记录排列紧密,一条紧接另一条。这个其实就是堆, heap_no 就是该记录在堆中的相对位置。比如我顺序写入了 a,b,c 三条记录,它们的 heap_no 就是 2,3,4。至于 0 和 1,它们是 Infimum,supremumheap_no 值。其实从图就可以看出, Infimum,supremumUser_record 上面。

# next_record 的计算

很好,进入到了喜闻乐见的计算环节,我会尽量让你懂的。

next_record 不就是指向下一条记录嘛,那它指向下一条记录的那个部位呢?我们知道,头信息右边是默认添加数据(就是 row_id,trx_id,roll_pointer),接着是真实信息。

image-20220726115106879

next_record 指向的是下一条记录的真实信息,其实存储的是一个以字节为单位的偏移量。比如第四条记录的 next_record=-111 , 就是指从第四条记录的真实数据的地址开始,上前找 111 个字节就是下一条记录的真实数据的地址。

所以,页中的记录因为 next_record 而形成了链表,在插入新的记录时,InnoDB 始终维护链表中节点的从小到大的顺序(维护顺序当然是为了二分查找)。

为了让读者感受一下 next_record 的计算过程,我们创建一个表玩一下

create table test_next_record (
	c1 int,
    c2 int,
    c3 varchar(10000),
    primary key (c1)
) character set acsii,row_format=COMPACT;
#使用 ascii字符集,一个字符用一个字节表示 

#插入一条数据
insert into test_next_record values(1,100,'aaaa');

那么在页中实际是这个样子的

image-20220726130854616

Infimum 中的 41 是如何计算的: Infimum 8 字节 +Supremum 整条记录 13 字节 + 变长字段 1 字节 + NULL 值列表 1 字节 + 头信息 5 字节 + trx_id 6 字节 + roll_pointer 7 字节 = 41 字节。

next_record 指向真实信息和头信息中间,这个位置刚刚好,向左是 NULL 值列表(逆序存放)和变长字段长度列表(逆序存放),向右是真实信息。逆序可以使得记录中靠前的字段和它们对应的字段长度列表在内存中靠得更近,可能会提高缓存命中率

# File Header

校验和:对于一个很长的字符串,通过算法计算出比较短的值代表这个长字符串,这个值就是校验和。

属性描述
FIL_PAGE_SPACE_OR_CHKSUM(4Byte) 空间或校验和,MySQL 版本低于 4.0.14 时,该属性表示本页面所在的表空间 ID;之后的版本表示页的校验和
FIL_PAGE_OFFSET页号,对于页号的分配,并不是连续的,也就是说使用的页在磁盘上可能并不挨着(当然是尽可能挨着)
FIL_PAGE_PREV上页的页号
FIL_PAGE_NEXT下页的页号(形成双向链表)
FIL_PAGE_TYPE页的类型
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID页属于哪个表空间
FIL_PAGE_LSN页面被最后修改时对应的 LSN(Log Sequence Number,日志序列号)

表格中内容看起来比较多,但是梳理一下就很清晰。

首先,MySQL 具有良好的健壮性,比如内存中的页正在被刷新到磁盘中,但是此时突然断电了,导致刷新时只刷了一部分。我们就需要使用校验和来判断一个页是否被完整的刷新到磁盘中,这个在 File Trailer 部分会讲解到。

其实,B + 树的节点就是页,页与页之间形成一个多叉树的同时,同一层的节点又形成双向链表。

image-20220726112224302

这样,我们就记住了中间三个属性。最后的 FIL_PAGE_TYPE 表示页的类型,肯定啊,MySQL 这么大,肯定不可能只有 1 种页。

# Page Directory

现在记录通过 next_record 连成了一个有序单向链表,对链表使用二分查找过程大致如下:

  • 将记录分为若干组,每个组最大的那个记录,其 n_owned 表示该组记录数。
  • 将每组最大记录的地址偏移量拿出来,按顺序排列在 Page Dierctory 中。
  • 这些偏移量被称为槽 -- slot ,每个槽位 2 字节, Page Dierctory 就是由若干个槽组成。
  • 你可以把一个个顺序排列的槽看作一个数组,槽对应的记录越小,就越接近 File Trailer

关键的问题是怎么分组,机制为:

  • 对于 Infimum 记录所在的分组,只能有一条记录。即最靠近 File Trailer 的槽始终是 Infimum
  • 对于 supermum 记录所在的分组,可以有 1~8 条记录。
  • 其余组只能有 4~8 条记录,如果组中记录大于 8 条,会 4,5 分为两组,并生成一个新的 slot
  • 每次插入一条记录,该记录都会从页目录中寻找对应记录的主键值比待插入记录主键值大且差值最小的 slot (然后找到前一个槽 A,A 对应的记录开始遍历插入即可)

配合这种机制,哪怕记录插入表中,记录在每个槽中也会保持有序,并且每个组中记录的主键逐渐紧凑。

下图省略了记录之间的连接:

image-20220727133511829

上图相当于是: slot_0=Infimumslot_1=4slot_2=Supremum 。假设要查询记录 7,就相当于在数组 [Min,4,Max] 里面进行二分查找,最后找到 4,记录 4 的下一条记录就是槽 2 的组的记录,就相当于遍历槽 2 的组,直到找到目标记录。

总共 56 字节,该结构是存储数据页中记录的状态信息,比如页中的记录数, Free Space 的偏移量,页目录有多少个槽等。下图只是部分结构,没有写完,记不住问题也不大,但是看到这里,下面的部分属性你肯定很熟悉。

属性长度描述
PAGE_HEAP_TOP2Byte还未使用的空间的最小地址,向后走就是 Free Space
PAGE_N_HEAP2Byte第 1 位表示是否为紧凑型记录,剩余的 15 位表示本页的堆中的记录(包括 Infimumu,supermum 和已删除记录)
PAGE_FREE2Byte垃圾链表头节点对应记录在页面中的偏移量
PAGE_GARBAGE2Byte已删除记录占用的字节数
PAGE_DIRECTION2Byte记录插入的方向,向后是 right
PAGE_N_DIRECTION2Byte记录连续插入同一个方向的记录条数,当插入方向不同时,马上重置刷新
PAGE_N_RECS2Byte该页用户记录的数量(不包括 Infimumu,supermum 和已删除记录)
PAGE_MAX_TRX_ID8Byte修改当前页的最大事务 id,该值仅在二级索引页面中定义
PAGE_LEVEL2Byte2Byte, 该页在 B + 树中的层级

# File Trailer

File Header 提到过校验和。如果页从内存刷新到磁盘中断电了,导致之刷新了一部分到磁盘中该怎么办? File Trailer 就是为了检验一个页是否刷新完整(至于如何恢复,那就的学到日志那才知道了)

该部分由 8 个字节组成:

  • 前四个字节代表页的校验和,该部分与 File Header 的校验和相对应。 每次页在内存中发生修改时,在刷新前就要把校验和算出来。以页 A(假设校验和为 1)刚从磁盘读到内存为例,修改之后校验和为 2,那么此时磁盘中的页 A 和内存中的页 A 校验和不一样。刷新到磁盘时, File Header 的磁盘先被刷新到磁盘中,如果断电导致 File Trailer 的校验和没有刷新进去,那么磁盘中的 File HeaderFile trailer 的校验和就不一样。
  • 后四个字节是页面最后被修改时对应的 LSN 的后 4 个字节,与 File Header 相对应,也是检验页完整性。

# 引用

本文总结于《从根上理解 MySQL》第五章 p72-p89,作者:小孩子 4919。对于有能力或者兴趣的读者,鄙人强烈推荐这本书,如果您能够通过正规渠道购买,支持作者,支持正版,支持每一颗热爱技术的心,我将感激不尽。