第5章 盛放记录的大盒子——InnoDB数据页结构
5.1 不同类型的页简介
页是InnoDB管理存储空间的一个基本单位,一个页的大小一般是16KB。InnoDB为不同的目的而设计了许多种不同类型的页。那些存放我们表中记录的那种类型的页,叫做索引页。
5.2 数据页结构快览
数据页这块16KB大小的存储空间可以划分为多个部分,不同部分有不同功能:
一个InnoDB数据页的存储空间大致被分成7个部分:
名称 | 中文名 | 占用空间大小 | 简单描述 |
---|---|---|---|
File Header | 文件头 | 38字节 | 页的一些通用信息 |
Page Header | 页头部 | 56字节 | 数据页专有的一些 |
Infimum + SupreMum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 |
User Records | 用户记录 | 不确定 | 实际存储的行记录内容 |
Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
Page Directory | 页目录 | 不确定 | 页中的记录相对位置 |
File Trailer | 文件结尾 | 8字节 | 校验页是否完整 |
5.3 记录在页中的存储
在页的7个组成部分中,我们自己的存储记录会按照我们指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间申请一个几粒大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,意味着这个也试用完了,还有新纪录插入,就需要申请新的页。
记录头信息的秘密
CREATE TABLE page_demo(
c1 INT,
c2 INT,
c3 VARCHAR(10000),
PRIMARY KEY (c1)
) CHARSET=ascii ROW_FORMAT=Compact;
新建的page_demo表有3个列其中c1和c2列是用来存储整数的,c3列是用来存储字符串的。我们把c1列指定为主键,所以在具体的行格式中InnoDB就没必要去创建row_id这个隐藏列。而我们为这个表指定了ASCII字符集以及Compact的行格式。
插入4条数据
INSERT INTO page_demo VALUES(1, 100, 'aaaa'),
(2, 200, 'bbbb'),
(3, 300, 'cccc'),
(4, 400, 'dddd');
为了方便分析这些记录在页的User Records部分中怎么表示,头信息和实际的列数据都用十进制表示(其实是一堆二进制位)。
-
delete_mask
标记当前记录是否被删除,占用1个二进制位,值为0的时候代表记录没有被删除,1则被删除了; 所有被删的记录会组成一个垃圾链表,这个链表中的记录占用的空间称为可重用空间。新记录插入到表时可以覆盖这些被删除的记录占用的存储空间。
-
min_rec_mask
B+树的每层非叶节点中的最小记录都会添加该标记,我们 插入的4条记录的min_rec_mask值都是0,意味着它们都不是B+树的非叶节点中的最小记录
-
n_owned
-
heap_no
表示当前记录在本页中的位置,从上图中可以看出,插入的4条记录在本页中的位置分别是:2、3、4、5。
InnoDB自动给每个页里添加2条记录,由于这两条记录不是我们自己插入的,所以也被成为伪记录或虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录。
记录也可以比较大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。
但不管我们向页中插入了多少自己的记录,InnoDB规定定义的两个伪记录分别为最小记录,和最大记录它们的构造都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成它们并不存放在页的User Records部分,而是单独放在一个被称为Infimum + SupreMum的部分
最小记录和最大记录的heap_no分别为0、1,也就是说它们位置最靠前
-
record_type
记录类型,共4种:
0:普通记录
1:B+树非叶节点记录 (或目录项记录,见目录项)
2:最小记录
3:最大记录
-
next_record
表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。例如第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。记录按照主键从小到大的顺序形成了一个单链表。这里,下一条记录不是指按照插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。
如果删除第二条记录
DELETE FROM page_demo WHERE c1 = 2;删除第2条记录前后的主要变化:
- 第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1;
- 第2条记录的next_record值变为了0,意味着该记录没有下一条记录了;
- 第1条记录的next_record指向了第3条记录;
- 最大记录的n_owned值从5变成了4。
不论怎么对页中的记录做增删改查操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序链接起来的。
next_record指针指向的位置在记录头信息和真实数据之间的位置,这个位置刚好向左读取记录头信息,向右读取就是真实数据,之前还说过变长字段长度列表、NULL值列表中的信息都是逆序存放的,这样可以是记录中的位置靠前的字段与他们对应的字段长度信息在内存中的距离更近可能会提高命中率
添加一条记录
INSERT INTO page_demo VALUSE(2,200,'bbbb');
可看出,InnoDB并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除的存储空间。
5.4 Page Directory(页目录)
设计页目录是为了方便快速查找记录,就像书的目录那样
创建 page directory 的步骤:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组;
- 每个组内最大的那条记录的头信息的 n_owned 表示组内记录的数量;
- 将每个组的最大的那条记录的地址偏移量(也称槽,slot)单独提取出来按照顺序存储到靠近页尾部的地方,这个地方就是所谓的 Page Directory,也就是页目录
现在页目录部分中有两个槽,意味着记录被分成了两个组,槽1中的值是112,代表最大纪录的地址偏移量(就是从0字节开始数,数112个字节);槽0中的值是99,代表最小纪录的地址偏移量
最小记录的n_owned值为1,代表以最小纪录结尾的这个分组中只有一条记录,也就是记录本身
最大记录的n_owned值为5,代表以最大记录结尾的分组中只有5条记录,包括做大记录本身和自己插入的4条数据
逻辑上的关系图
InnoDB规定最小记录所在的分组只能有 1 条记录,最大记录所在的分组的记录条数在 1~8 条之间,其它分组中记录的条数则在是 4~8 条之间。
分组步骤:
初始情况下一个数据页只有最小记录和最大记录,他们属于两个分组
之后每插入一条记录,都会从页目录中找到主键值比本记录大并且差值最小的值,然后把该槽对应的记录的n_owned值加1,表示本组又加一条记录直到该组中的记录数等于8
在一个组中记录数等于8后,在插入一条记录时,会将组内记录拆分成两个组,一个组4条记录。这个过程会在页目录中新增一个槽来记录这个新增分组最大的那条记录的偏移量
利用页目录查找指定主键的记录的过程分为两步:
- 通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录;
- 通过记录的 next_record 属性遍历组中的各个记录。
5.5 Page Header(页面头部)
数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等。它是页结构的第二部分,这部分占用固定的56个字节
-
PAGE_DIRECTION
假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是
PAGE_DIRECTION
。 -
PAGE_N_DIRECTION
假设连续几次插入新记录的方向都是一致的,
InnoDB
会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION
这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
5.6 File Header(文件头部)
Page Header是专门针对数据页记录的各种状态信息,
File Header针对各种类型的页都通用,也就是说不同类型的页都会 以File Header作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁等,这个部分占用固定的38个字节
-
FIL_PAGE_SPACE_OR_CHKSUM
这个代表当前页面的校验和(checksum)。啥是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为
校验和
。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。 -
FIL_PAGE_OFFSET
每一个
页
都有一个单独的页号,就跟你的身份证号码一样,InnoDB
通过页号来可以唯一定位一个页
。 -
FIL_PAGE_TYPE
这个代表当前
页
的类型,我们前边说过,InnoDB
为了不同的目的而把页分为不同的类型,我们上边介绍的其实都是存储记录的数据页
,其实还有很多别的类型的页,具体如下表
-
我们存放记录的数据页的类型其实是
FIL_PAGE_INDEX
,也就是所谓的索引页
。 -
FIL_PAGE_PREV
和FIL_PAGE_NEXT
我们前边强调过,
InnoDB
都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB
可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV
和FIL_PAGE_NEXT
就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过我们本集中唠叨的数据页
(也就是类型为FIL_PAGE_INDEX
的页)是有这两个属性的,所以所有的数据页其实是一个双链表,就像这样:
5.7 File Trailer(文件尾部)
我们知道InnoDB
存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页
为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办,这不是莫名尴尬么?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),设计InnoDB
的大叔们在每个页的尾部都加了一个File Trailer
部分,这个部分由8
个字节组成,可以分成2个小部分:
-
前4个字节代表页的校验和
这个部分是和
File Header
中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header
在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header
中的校验和就代表着已经修改过的页,而在File Trialer
中的校验和代表着原先的页,二者不同则意味着同步中间出了错。 -
后4个字节代表页面被最后修改时对应的日志序列位置(LSN)
这个部分也是为了校验页的完整性的,只不过我们目前还没说
LSN
是个什么意思,所以大家可以先不用管这个属性。
这个File Trailer
与File Header
类似,都是所有类型的页通用的。
5.8 总结
-
InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页叫做
数据页
。 -
一个数据页可以被大致划分为7个部分,分别是
File Header
,表示页的一些通用信息,占固定的38字节。Page Header
,表示数据页专有的一些信息,占固定的56个字节。Infimum + Supremum
,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的26
个字节。User Records
:真实存储我们插入的记录的部分,大小不固定。Free Space
:页中尚未使用的部分,大小不确定。Page Directory
:页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。File Trailer
:用于检验页是否完整的部分,占用固定的8个字节。
-
每个记录的头信息中都有一个
next_record
属性,从而使页中的所有记录串联成一个单链表
。 -
InnoDB
会为把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个槽
,存放在Page Directory
中,所以在一个页中根据主键查找记录是非常快的,分为两步:-
通过二分法确定该记录所在的槽。
-
通过记录的next_record属性遍历该槽所在的组中的各个记录。
-
-
每个数据页的
File Header
部分都有上一个和下一个页的编号,所以所有的数据页会组成一个双链表
。 -
为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和页面最后修改时对应的
LSN
值,如果首部和尾部的校验和和LSN
值校验不成功的话,就说明同步过程出现了问题。