页
是MySQL
管理存储空间的基本单位,一个页的大小一般是 16KB
,记录是被存放在 页
中的,如果记录占用的空间太大还可能造成 行溢出
现象,这会导致一条记录被分散存储在多个页中。页
的本质就是一块 16KB
大小的存储空间,InnoDB
为了不同的目的而把 页
分为不同的类型,其中用于存放记录的页也称为 数据页
,我们先看看这个用于存放记录的页长什么样。数据页代表的这块16KB
大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
记录在页中的存储
在页的 7 个组成部分中,存储的记录会按照指定的 行格式
存储到 User Records
部分。但是在一开始生成页的时候,其实并没有 User Records
这个部分,每当插入一条记录,都会从 Free Space
部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records
部分,当 Free Space
部分的空间全部被 User Records
部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:
记录头信息的秘密
delete_mask
这个属性标记着当前记录是否被删除,占用 1 个二进制位,值为 0
的时候代表记录并没有被删除,为 1
的时候代表记录被删除掉了。
这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打个删除
标记而已,而且这部分存储空间之后还可以重用,也就是说之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空
间覆盖掉。`optimize table 表名;` 执行这个命令后服务器会重新规划表中记录的存储方式,把被标记为删除的记录从磁盘上移除。
min_rec_mask 这个属性标记该记录是否为 B+
树的非叶子节点中的最小记录。
n_owned 这个暂时保密,稍后它是主角~
heap_no 这个属性表示当前记录在本 页
中的位置。InnoDB
自动给每个页里加了两个记录,由于这两个记录并不是自己插入的,所以有时候也称为 伪记录
或者 虚拟记录
。这两个伪记录一个代表 最小记录
,一个代表 最大记录
。对于一条完整的记录来说,比较记录的大小就是比较 主键
的大小。比方说插入的 4 行记录的主键值分别是:1
、2
、3
、4
,这也就意味着这 4 条记录的大小依次递增。
但是不管我们向 页
中插入了多少自己的记录,InnoDB
都定义的两条伪记录分别为最小记录与最大记录。这两条记录的构造十分简单,都是由 5 字节大小的 记录头信息
和 8 字节大小的一个固定的部分组成的
由于这两条记录不是我们自己定义的记录,所以它们并不存放在 页
的 User Records
部分,他们被单独放在一个称为 Infimum + Supremum
的部分,如图所示:
从图中我们可以看出来,最小记录和最大记录的 heap_no
值分别是 0
和1
,也就是说它们的位置最靠前。
record_type 这个属性表示当前记录的类型,一共有 4 种类型的记录,0
表示普通记录,1
表示 B + 树非叶节点记录,2
表示最小记录,3
表示最大记录。从图中可以看出来,自己插入的记录就是普通记录,它们的 record_type
值都是 0
,而最小记录和最大记录的record_type
值分别为 2
和3
。至于 record_type
为1
的情况,索引的时候会重点强调。
`next_record 这个非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的
next_record 值为
36,意味着从第一条记录的真实数据的地址处向后找
36 个字节便是下一条记录的真实数据。这其实是个
链表 ,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,
下一条记录 指得并不是按照插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 **_最小记录_** 的下一条记录就本页中主键值最小的记录,而本页中主键值最大的记录的下一条记录就是 **_最大记录_**,为了更形象的表示一下这个
next_record 起到的作用,用箭头来替代一下
next_record` 中的地址偏移量:
记录按照从小到大的顺序形成了一个单链表。最大记录
的next_record
的值为 0
,这也就是说最大记录是没有 下一条记录
了,它是这个单链表中的最后一个节点。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如把第 2 条记录删掉:
从图中可以看出来,删除第 2 条记录前后主要发生了这些变化:
所以,不论我们怎么对页中的记录做增删改操作,InnoDB 始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。
第 2 条记录并没有从存储空间中移除,而是把该条记录的 delete_mask
值设置为 1
。
第 2 条记录的 next_record
值变为了 0,意味着该记录没有下一条记录了。
第 1 条记录的 next_record
指向了第 3 条记录。最大记录
的n_owned
值从 5
变成了 `4 主键值为
2` 的记录被我们删掉了,但是存储空间却没有回收
mysql> INSERT INTO page_demo VALUES(2, 200, ‘bbbb’); 存储情况:
从图中可以看到,InnoDB
并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间。
页目录
记录在页中按照主键值由小到大顺序串联成一个单链表,比如查询语句:SELECT * FROM … WHERE c = 3; 从最小记录开始,沿着链表一直往后找,总会找到(或者找不到),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,就可以停止查找了,因为该节点后边的节点的主键值依次递增。从一本书中查找某个内容的时候,一般会先看目录,找到需要查找的内容对应的书的页码,然后到对应的页码查看内容。InnoDB 为记录也制作了一个类似的目录,制作过程是这样的:
将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
每个组的最后一条记录的头信息中的 n_owned 属性表示该组内共有几条记录。
将每个组的最后一条记录的地址偏移量按顺序存储起来,每个地址偏移量也被称为一个槽(英文名:Slot)。
这些地址偏移量都会被存储到靠近页的尾部的地方,页中存储地址偏移量的部分也被称为 Page Directory(可以看前边数据页的组成示意图)。比方说现在的表中正常的记录共有 6 条,InnoDB 会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的 5 条记录,看下边的示意图:
现在 Page Directory
部分中有两个槽,也就意味着我们的记录被分成了两个组,槽 0
中的值是 112
,代表最大记录的地址偏移量; 槽 1
中的值是 99
,代表最小记录的地址偏移量。注意最小和最大记录的头信息中的n_owned
属性 最小记录的 n_owned
值为 1
,这就代表着以最小记录结尾的这个分组中只有1
条记录,也就是最小记录本身。最大记录的 n_owned
值为 5
,这就代表着以最大记录结尾的这个分组中只有5
条记录,包括最大记录本身还有自己插入的 4
条记录。
InnoDB
对每个分组中的记录条数是有规定的,对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。所以分组是按照下边的步骤进行的:初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。之后每插入一跳记录都把这条记录放到最大记录所在的组,直到最大记录所在组中的记录数等于 8 个。在最大记录所在组中的记录数等于 8 个的时候再插入一条记录时,将最大记录所在组平均分裂成 2 个组,然后最大记录所在的组就剩下 4 条记录了,然后就可以把即将插入的那条记录放到该组中了。
在一个数据页中查找指定主键值的记录的过程分为两步:
通过二分法确定该记录所在的槽。通过记录的 next_record 属性组成的链表遍历查找该槽中的各个记录。
Page Header InnoDB
为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫 Page Header
的部分,它是 页
结构的第二部分,这个部分占用固定的 56
个字节,专门存储各种状态信息。
PAGE_DIRECTION
假如新插入的一条记录的主键值比上一条记录的主键值比上一条记录大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是 PAGE_DIRECTION
。
PAGE_N_DIRECTION
假设连续几次插入新记录的方向都是一致的,InnoDB
会把沿着同一个方向插入记录的条数记下来,这个条数就用 PAGE_N_DIRECTION
这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
File Header
如果说 Page Header
描述的是 页
内的各种状态信息,File Header
描述的就是 页
外的各种状态信息,比如页的编号,它的上一个页、下一个页是谁。File Header
是 InnoDB
页的第一部分,这个部分占用固定的 38
个字节。
FIL_PAGE_SPACE_OR_CHKSUM
这个代表当前页面的校验和(checksum)。啥是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。FIL_PAGE_OFFSET
每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB 通过页号来可以唯一定位一个页。FIL_PAGE_TYPE
这个代表当前页的类型,我们前边说过,InnoDB 为了不同的目的而把页分为不同的类型,本集中介绍的其实都是存储记录的数据页,其实还有很多别的类型的页:
我们存放记录的数据页的类型其实是 FIL_PAGE_INDEX
FIL_PAGE_PREV 和 FIL_PAGE_NEXT
一张表中可以有成千上万条记录,一个页只有 16KB,所以可能需要好多页来存放数据,FIL_PAGE_PREV 和 FIL_PAGE_NEXT 就分别代表本页的上一个和下一个页的页号。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,所以所有的数据页其实是一个双链表
File Trailer
InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),InnoDB 在每个页的尾部都加了一个 File Trailer 部分,这个部分由 8 个字节组成,可以分成 2 个小部分:
前四个字节代表页的校验和
这个部分是和 File Header 中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为 File Header 在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的,反之意味着同步中间出了错。
后四个字节代表日志序列位置(LSN)
这个部分也是为了校验页的完整性的。总结
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 值校验不成功的话,就说明同步过程出现了问题。