一、页介绍
页是InnoDB管理存储空间的基本单位,一个页的基本大小是16KB。为了不同的实现各种功能,
InnoDB设计了多种不同类型的页。比如存储索引信息的页、存放undo日志的页,统称为数据页。
二、数据页的结构
名称 | 占用空间 | 描述 |
File Header | 38字节 | 文件头信息 |
Page Header | 56字节 | 页面头部 |
Infimum+Supremum | 26字节 | 页面中的最小记录和最大记录 |
User Records | 不确定 | 用户记录(存储的真实数据) |
Free Space | 不确定 | 空闲记录(用户尚未使用的空间) |
Page Directory | 不确定 | 页目录(页中某些记录的相对位置) |
File Trailer | 8字节 | 文件尾 |
整体结构如下所示:
Free Space空闲记录
Free Space表示一个页的尚未使用的存储空间,InnoDB花了一个小套路。刚开始生成一个数据页
的时候,默认是没有User Records用户记录的。在往数据页插入数据时,User Records会占用
Free Space一部分空间。当User Records把Free Space占满时,就意味着该页已使用完,需要申
请新的数据页。
为了更好的理解数据页的结构,创建一个表,并插入部分数据。
create table demo
(
c1 int primary key,
c2 char(10),
c3 nvarchar(255)
)
//字符集是ascii
//插入数据
insert into demo(c1,c2,c3)
values(1,'a','a'),(2,'aa','aa'),(3,'aaa','aaa'),(5,'aaaaa',NULL);
User Records和Infimum+Supremum
User Records存储的就是真实的数据,这里需要把上篇文章的行格式拿出来。
名称 | 位(bit) | 描述 |
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
deleted_flag | 1 | 记录是否被删除 |
min_rec_fllag | 1 | B+树的每层非叶子节点的 最小目录项都会添加该标记 |
n_owned | 4 | 当前记录拥有的记录数 |
heap_no | 13 | 当前记录在页面堆中的相对位置 |
record_type | 3 | 当前记录的类型: 0 普通记录、 1 B+树非叶子节点 的目录项记录、 2 表示Infimum记录、 3 Supremum记录 |
next_record | 16 | 下一条记录的相对位置 |
结合上一篇行格式的内容,那么数据大致格式如下
注:再重复一下,真实的数据是二进制格式,这里只是为了方便理解。
Infimum+Supremum
Infimum和Supremum是数据默认创建的两条数据。Infimum代表当前页最小的一条记录,并且
heap_no默认为0。Supremum代表当前页最大的一条记录,heap_no默认为1。
无论我们向数据页中插入多少数据,InnoDB规定任何一条数据都比Infimum大,同时任何数据都比
Supremum小。
Infimum和Supremum的heap_no为0和1,也就说明它们在堆中相对位置最靠前。堆中的heap_no
一旦确定,就不会发生改变。即使后面某条数据被删除。
大致如下
没错,Infimum和Supremum两条数据,后面的真实数据就是Infimum和Supremum两个单词。只不
过是二进制表示。
如果删除某条数据,会发生什么
delete from demo where c1 = 2;
被删除的数据不会立马从页中删除,因为删除之后,之后的数据要重新排列,很消耗性能。所有被
删除的数据会组成一个垃圾链表。也被称为"可重用空间"。
Page Directory页目录
根据页的User Records,我们知道页中的数据是根据主键值由小到大的顺序串连成一个单向链表。
如果我们想根据主键进行查找,最简单的方法是 ,从Infimum记录开始,沿着链表往下查找。如果
数据很少,是没问题的,但是如果数据很多呢,那就非常耗时。
所以InnoDB在User Records外又包了一层,这就是页目录。页目录里的每一个数据叫槽。
槽就是记录当前数据页第0字节与该真实数据之间的距离,大白话就是书中某章跟第一页相差的页
数。
每个槽占用2字节,页目录就是由多个槽组成的。并且每个槽是紧挨着的。
页目录大致操作过程如下:
1、将该数据页所有正常数据(包括Infimum和Supremum记录),但不包括已经移除到垃圾链表里
的数据,将这些数据分几个组。
2、每个组的最后一条记录中的n_owned,记录每个组有几条数据,该组中其他记录的n_owned为
0。
3、将每个组中最后一条记录,与当前数据页第0字节之间的距离,单独取出来,作为一个槽,放到
页目录中。
4、InnoDB规定,在分组时,Infimum所在的分组只能有一条数据。Supremum所在的分组,只能
有1~8条数据。剩下的分组记录条数范围只能在4~8条之间。
页目录分组大致过程如下:
1、在数据页刚初始化的时候,默认有Infimum和Supremum两条数据,所以也默认只有两个槽。
2、之后每插入一条数据,都会从页目录中找到对应记录的主键值比待插入记录的主键值大并且差
值最小的槽。然后把该槽对应的记录的n_owned值+1,直到该组的记录数等于8。
3、当一个组中的记录数等于8后,再插入一条记录,会将组中的记录拆分成两个组。其中一个组中
有4条记录,另一组5条数据。这个拆分的同时,也会增加一个槽。
根据以上数据,页目录大致如下
电脑太小了,画不了太多数据,槽中的数据就是偏移量。
那么在页中是如何查找的呢??
假如有20条真实数据(不包括Infimum和Supremum)主键id依次增长,那么分组大致情况如何:
槽0:Infimum记录
槽1:1~5
槽2:6~10
槽3:11~15
槽4:16~20,包含Supremum记录
那么我要查询id为12的数据,是如何查找的
1、最低槽是0,那么设置low=0,最高的槽是4,那么设置hight=4。
2、计算中间槽的位置:(0+4)/2=2,查看槽2的主键值是10,因为10<12,所以low设置为2,hight
保持不变。
3、重新计算中间槽位置:(2+4)/2=3,查看槽3的主键值是15,因为15>12,所以hight=3,low保
持不变。
4、因为hight-low=1,所以主键值id为12的数据在槽3中。因为槽中记录的是当前组最大的一条记
录,那么如何找到当前组的最小记录,沿着最小记录,往下查询。上面又说了,每个槽是紧挨着
的,所以很简单能找到上一个槽的最大值。上一个槽的最大值的下一条记录,就是当前槽3对应的
最小值。找到上个槽的最大值是10,就能找到槽3的最小值是11。沿着11往下找一条,就找到了,
主键值为12的数据。
总结就是:
1、通过二分法确定数据所在的槽,然后再找到槽对应的最小记录。
2、通过记录的next_record属性遍历该槽所在的组中所有数据。
Page Header页头部
Page Header存储数据页专有的一些信息,比如该页存储的槽数、Free Space在页面中的位置等。
Page Header占用固定的56字节。
具体结构如下:
名称 | 占用大小(字节) | 描述 |
page_n_dir_slots | 2 | 页目录中槽的数量 |
page_heap_top | 2 | 还未使用的空间最小地址,也就是从该地址之后就是Free Space |
page_n_heap | 2 | 第1位表示本记录是否为紧凑型的记录,剩余15位表示本页的堆中记录的数量,包括Infimum和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 | 该页中用户记录的数量,不包括Infimum和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+树的根页面中定义 |
page_direction:加入新插入的一条记录的主键值比上一条主键值大,则往右边,反之则往左边。
page_n_direction:假如连续插入的几条记录方向都是一致的,则会记录同一个方向插入的数量。
如果后一条方向发生改变,则归零重新计算。
File Header 文件头部
File Header记录页各种通用的类型。比如页的编号、上一个页下一个页是谁。
名称 | 占用大小(字节) | 描述 |
fil_page_offset | 4 | 页号 |
fil_page_prev | 4 | 上一个页的页号 |
fil_page_next | 4 | 下一个页的页号 |
fil_page_lsn | 8 | 页面最后被修改时对应的LSN值 |
fil_page_type | 2 | 该页的类型 |
fil_page_file_flush_lsn | 8 | 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 |
fil_page_arch_log_no_or_space_id | 4 | 该页属于哪个表空间 |
fil_page_space_or_chksum | 4 | 表示页的校验和 |
fil_page_space_or_chksum:它在数据页的开头存储,用于验证数据页的完整性。数据页的校验
和是通过对数据页的内容进行哈希计算得到的值。
fil_page_prev和fil_page_next:因为有这两个属性,所以存储记录的数据页其实是一个双向链表。
File Trailer 文件尾部
我们知道,InnoDB存储引擎会把数据存储在磁盘上,在用的时候,会将数据以页为单位同步到内
存中。在某个时间,会将内存中的页同步到磁盘中,这就是刷盘。但是如果在同步某个页时,同步
了一半,断电了,那么如何判断一个页数据是否是完整的。这时候就用到了File Header和File
Trailer中的校验和。
File Trailer部分共有8个字节组成,分为两个小部分。
1、前4字节,代表页的校验和。这部分与File Header的校验和保持一致。因为File Header的校验
和在页的前面,会先刷新到磁盘。当整个页完全同步到磁盘中,File Header的校验和会与File
Traile校验和一致。不一致说明数据出现了问题。
2、后4字节,代表页面被最后修改时对应的LSN的后4字节,正常情况下也是和File Header中的
fil_page_lsn后4字节,保持一致。
注:后续再介绍LSN到底是干啥的。