开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情
上篇文章InnoDB数据页我们介绍了记录是如何保存在存储引擎中的,但是在读取的时候并不是一行一行地读,这样效率太低了,因此在数据行之上还有一层结构——数据页。它是 InnoDB 管理存储空间的基本单位,一个页的大小一般是 16KB 。数据库在读取数据时是按「数据页」为单位来读写的,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。
数据页代表的这块 16KB 大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
File Header
File Header占用固定的 38 个字节,是由下边这些内容组成的:
| 名称 | 占用空间(字节) | 描述 |
|---|---|---|
| FIL_PAGE_SPACE_OR_CHKSUM | 4 | 页面校验和 |
| FIL_PAGE_OFFSET | 4 | 页号,InnoDB 通过页号来可以唯一定位一个 页 |
| 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_PREV 和 FIL_PAGE_NEXT是两个比较重要的属性,通过这两个指针可以建立一个双向链表把许许多多的页就都串联起来。
采用链表的结构是让数据页之间不需要是物理上的连续的,而是逻辑上的连续。
Page Header
为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,在页中定义了一个叫 Page Header 的部分,它是页结构的第二部分,这个部分占用固定的 56 个字节,专门存储各种状态信息:
行记录
在介绍之前,我们需要回顾一下InnoDB数据行中记录头中包括哪些信息
| 名称 | 大小(bit) | 描述 |
|---|---|---|
| 预留位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 | 表示下一条记录的相对位置 |
- delete_mask:删除标记,某一条记录被删除,只是逻辑上删除,实际上还在原来位置,所有被删除掉的记录都会组成一个所谓的 垃圾链表 ,在这个链表中的记录占用的空间称之为所谓的 可重用空间 ,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。
- min_rec_mask:B+树的每层非叶子节点中的最小记录都会添加该标记。
- n_owned:为了加快每页中查找某一条记录的效率,会将页面中的记录分组,每个分组的最大记录的n_owned会标记为当前组的记录个数。
- heap_no:表示当前记录在页面中的位置,一般会从2开始编号,因为0,1分别表示了最小记录和最大记录。
- record_type:这个属性表示当前记录的类型,一共有4种类型的记录, 0 表示普通记录,1 表示B+树非叶节点记录,2 表示最小记录,3 表示最大记录。
- next_record:链表指针,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的 next_record 值为 32 ,意味着从第一条记录的真实数据的地址处向后找 32 个字节便是下一条记录的 真实数据。
下面我们画出简化的记录行格式,只标注出记录头和字段的信息
在数据页中,每一页都有两个虚拟记录,分别表示这一页的最小记录和最大记录。
- 在分组中,最小记录单独一个分组,所以n_owned为1
- 最小记录的record_type为2,最大记录的record_type为3
- 最小记录的next_record指向本页中主键值最小的用户记录,最大记录的next_record值为0,不指向任何记录,而本页中主键值最大的用户记录的next_record指向最大记录。
Page Directory
现在我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,在检索时可以直接一次遍历,但这样时间复杂度就是O(n)级别,效率太低。因此,数据页中有一个页目录,起到记录的索引作用。
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
- 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近 页 的尾部的地方,这个地方就是所谓的 Page Directory ,也就是页目录。页面目录中的这些地址偏移量被称为槽(英文名: Slot ),所以这个页面目录就是由槽组成的。
现在假设又四个分组,每组有四条数据,页内记录组织就如下图所示:
从图可以看到,页目录就是由多个槽组成的,槽相当于分组记录的索引。然后,因为记录是按照「主键值」从小到大排序的,所以我们通过槽查找记录时,可以使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到对应的记录,无需从最小记录开始遍历整个页中的记录链表。
InnoDB 对每个分组中的记录条数都是有规定的,槽内的记录就只有几条:
- 第一个分组中的记录只能有 1 条记录;
- 最后一个分组中的记录条数范围只能在 1-8 条之间;
- 剩下的分组中记录条数范围只能在 4-8 条之间。
以上面那张图举个例子,4个槽的编号分别为 0,1,2,3,想查找主键为 11 的用户记录:
- 先二分得出槽中间位是 (0+3)/2=1 ,1号槽里最大的记录为 5。因为 11 > 5,所以需要从 2 号槽后继续搜索记录;
- 再使用二分搜索出 2 号和 3 槽的中间位是 (2+3)/2= 2,2 号槽里最大的记录为 9。因为 11 > 9,所以主键为 11 的记录在 3 号槽里;
- 最后从三号槽最小记录10开始遍历,下一个记录即为11。