一.页的结构
页是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。数据页代表的这块16KB 大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
名称 | 中文名 | 占用空间大小 | 简单描述 |
File Header | 文件头部 | 38 字节 | 页的一些通用信息 |
Page Header | 页面头部 | 56 字节 | 数据页专有的一些信息 |
Infimum + Supremum | 最小记录和最大记录 | 26 字节 | 两个虚拟的行记录 |
User Records | 用户记录 | 不确定 | 实际存储的行记录内容 |
Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
Page Directory | 页面目录 | 不确定 | 页中的某些记录的相对位置 |
File Trailer | 文件尾部 | 8 字节 | 校验页是否完整 |
二.记录在页中的存储
在页的7个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到User Records 部分。但是在一开 始生成页的时候,其实并没有User Records 这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就 是尚未使用的存储空间中申请一个记录大小的空间划分到User Records 部分,当 Free Space 部分的空间全部 被User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新 的页了,这个过程的图示如下:
三,记录结构中的记录头信息
记录头信息的5个字节的数据 意义如下表:
名称 | 大小 | 描述 |
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记该记录是否被删除 |
min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在记录堆的位置信息 |
record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3 表示最大记录 |
next_record | 16 | 表示下一条记录的相对位置 |
3.1 delete_mask
这个属性标记着当前记录是否被删除,占用1个二进制位,值为0的时候代表记录并没有被删除,为1的时候代表记录被删除掉了。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记 录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有 新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。
3.2 min_rec_mask
B+树的每层非叶子节点中的最小记录都会添加该标记。
3.3 n_owned
见 四
3.4 heap_no
这个属性表示当前记录在本页中的位置,但是没有0和1,因为0和1被mysql占用了,mysql自动给每个页里边儿加了两个记录,由于这两个记录 并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一 个代表最大记录。(记录的大小比较指的是主键的大小比较)。
3.5 record_type
这个属性表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶节点记录,2表 示最小记录,3表示最大记录。
3.6 next_record
表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的next_record 值为 32 ,意味着从第一条记录的真实数据的地址处向后找 32 个字节便是下一条记录的 真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表,可以通过一条记录找到它的下一 条记录。但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的下一条记录,而 是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum 记录(也就是最小记录) 的下一条记录就是 本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就 是最大记录),如下图:
假如我有如下一张test表
则偏移量指针应该如下:
删掉第2条记录后的示意图就是:
我们再次把这 条记录插入到表中:
四.Page Directory
当运行一条SQL
SELECT * FROM page_demo WHERE c1 = 3;
最笨的办法:从Infimum 记录(最小记录)开始,沿着链表一直往后找,总有一天会找到(或者找不到[摊 手]),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点 代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递 增。 这个方法在页中存储的记录数量比较少的情况用起来也没啥问题,比方说现在我们的表里只有4条自己插入的记 录,所以最多找4次就可以把所有记录都遍历一遍,但是如果一个页中存储了非常多的记录,这么查找对性能来 说还是有损耗的,所以我们说这种遍历查找这是一个笨办法。
MySQL 是这样做的:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned 属性表示该记录拥有多少条记 录,也就是该组内共有几条记录。
- 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所 谓的Page Directory ,也就是 页目录 (此时应该返回头看看页面各个部分的图)。页面目录中的这些地址 偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。
比如现在test表有六条记录,则:
分组是按照下边的步骤进行的:
- 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
- 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对 应的记录的n_owned 值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
- 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一 个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的 n_owned 和 next_record 属性,也省略了各个记录之间的箭头,我没画不等于没有啊!现在看怎么从这个 页目 录中查找记录。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行 快速查找。4个槽的编号分别是:0、1、2、3、4,所以初始情况下最低的槽就是low=0,最高的槽就是 high=4 。比方说我们想找主键值为 6 的记录,过程是这样的:
- 计算中间槽的位置: (0+4)/2=2 ,所以查看 槽2 对应记录的主键值为 8 ,又因为 8 > 6 ,所以设置 high=2 , low 保持不变。
- 重新计算中间槽的位置: (0+2)/2=1 ,所以查看 槽1 对应的主键值为 4 ,又因为 4 < 6 ,所以设置 low=1 , high 保持不变。
- 因为 high - low 的值为1,所以确定主键值为 5 的记录在 槽2 对应的组中。此刻我们需要找到 槽2 中主键 值最小的那条记录,然后沿着单向链表遍历槽2中的记录。但是我们前边又说过,每个槽对应的记录都是该 组中主键值最大的记录,这里槽2对应的记录是主键值为8的记录,怎么定位一个组中最小的记录呢?别忘 了各个槽都是挨着的,我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就 是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽 2 中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以 遍历一个组中的记录的代价是很小的。
所以在一个数据页中查找指定主键值的记录的过程分为两步
- 通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录。
- 通过记录的 next_record 属性遍历该槽所在的组中的各个记录。
五.Page Header
Page Header是 页结构的第二部分,这个部分占用固定的56个字节,专门存储各种状态信息
名称 | 占用空间大小 | 描述 |
PAGE_N_DIR_SLOTS | 2 字节 | 在页目录中的槽数量 |
PAGE_HEAP_TOP | 2 字节 | 还未使用的空间最小地址,也就是说从该地址之后就是Free Space |
PAGE_N_HEAP | 2 字节 | 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) |
PAGE_FREE | 2 字节 | 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链 表,这个单链表中的记录可以被重新利用) |
PAGE_GARBAGE | 2 字节 | 已删除记录占用的字节数 |
PAGE_LAST_INSERT | 2 字节 | 最后插入记录的位置 |
PAGE_DIRECTION | 2 字节 | 记录插入的方向 |
PAGE_N_DIRECTION | 2 字节 | 一个方向连续插入的记录数量 |
PAGE_N_RECS | 2 字节 | 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) |
PAGE_MAX_TRX_ID | 8 字节 | 修改当前页的最大事务ID,该值仅在二级索引中定义 |
PAGE_LEVEL | 2 字节 | 当前页在B+树中所处的层级 |
PAGE_INDEX_ID | 8 字节 | 索引ID,表示当前页属于哪个索引 |
PAGE_BTR_SEG_LEAF | 10 字节 | B+树叶子段的头部信息,仅在B+树的Root页定义 |
PAGE_BTR_SEG_TOP | 10 字节 | B+树非叶子段的头部信息,仅在B+树的Root页定义 |
六.File Header
File Header 针对各种类型的页都通用,也就是说不同类型的页都会以File Header 作 为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、 下一个页是谁,这个部分占用固定的38个字节
名称 | 占用空间大 小 | 描述 |
FIL_PAGE_SPACE_OR_CHKSUM | 4 字节 | 页的校验和(checksum值) |
FIL_PAGE_OFFSET | 4 字节 | 页号 |
FIL_PAGE_PREV | 4 字节 | 上一个页的页号 |
FIL_PAGE_NEXT | 4 字节 | 下一个页的页号 |
FIL_PAGE_LSN | 8 字节 | 页面被最后修改时对应的日志序列位置(英文名是: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_INDEX 的页)是有这两个属性的,所以所有的数据页其 实是一个双链表
七.File Trailer
InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处 理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一 半的时候中断电了咋办为了检测一个页是否完整(也就是在同步的时候有没有发生只同步 一半的尴尬情况),设计InnoDB 的大叔们在每个页的尾部都加了一个File Trailer 部分,这个部分由 8 个字 节组成,可以分成2个小部分
前4个字节代表页的校验和
- 这个部分是和File Header 中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校 验和算出来,因为File Header 在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也 会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电 了,那么在File Header 中的校验和就代表着已经修改过的页,而在 File Trialer 中的校验和代表着原先 的页,二者不同则意味着同步中间出了错。 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)
- 这个部分也是为了校验页的完整性的,只不过我们目前还没说LSN是个什么意思,所以大家可以先不用管这 个属性
我依稀记得之前学过的mysql的两次写好像也是为了防止掉电后数据不一致,在这里又复习了一下两次写。
八. MySQL 两次写
两次写应对的问题:
mysql一页的数据默认是16k,文件系统IO的最小单位是4K,所以如果数据写了一半掉点了则会发生数据不一致,如下图:
此时写了一半掉电了,另一半还没来得及写。
两次写流程:
- 当一系列机制触发数据缓冲池中的脏页刷新时,并不直接写入磁盘数据文件中,而是先拷贝至内存中的两次写缓冲区中,由于是在内存中,这一过程会非常快。
- 接着从两次写缓冲区分两次写入磁盘共享表空间中(连续存储,顺序写,性能很高),每次写1MB;
- 待第二步完成后,再将doublewrite buffer中的脏页数据写入实际的各个表空间文件(离散写);(脏页数据固化后,即进行标记对应doublewrite数据可覆盖)
- 如果操作系统在将页写入磁盘的过程中发生崩溃,在恢复过程中,innodb存储引擎可以从共享表空间的doublewrite中找到该页的一个最近的副本,将其复制到表空间文件,再应用redo log,就完成了恢复过程。