InnoDB是一个将表中的数据存储到磁盘上的存储引擎,需要根据客户端的请求将结果从内存写入到磁盘中,或者从磁盘读取到内存中。磁盘的读写速率相比内存差了好几个数量级,如果InnoDB存储引擎将记录一条一条从内存写入磁盘,或者从磁盘写入内存会很慢。InnoDB采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。
InnoDB中页的种类有很多,具体如下表:

类型名称 十六进制 描述
FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,还没使用
FIL_PAGE_UNDO_LOG 0x0002 Undo日志页
FIL_PAGE_INODE 0x0003 段信息节点
FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert Buffer空闲列表
FIL_PAGE_IBUF_BITMAP 0x0005 Insert Buffer位图
FIL_PAGE_TYPE_SYS 0x0006 系统页
FIL_PAGE_TYPE_TRX_SYS 0x0007 事务系统数据
FIL_PAGE_TYPE_FSP_HDR 0x0008 表空间头部信息
FIL_PAGE_TYPE_XDES 0x0009 扩展描述页
FIL_PAGE_TYPE_BLOB 0x000A 溢出页
FIL_PAGE_INDEX 0x45BF 索引页,也就是我们所说的数据页

本节我们介绍的页是InnoDB中最重要的页——索引页,该类型的页是存储用户数据的,因此为了方便本文后续将索引页用数据页代替。

1、数据页整体结构

数据页这个16KB大小的空间被划分为以下7个部分,各部分记录的数据和功能不一样,如下:
InnoDB的索引页结构 - 图1

名称 中文名 占用空间大小 简单描述
File Header 文件头部 38字节 页的一些通用信息
Page Header 页面头部 56字节 数据页专有的一些信息
Infimum + Supremum 最小记录和最大记录 26字节 两个虚拟的行记录
User Records 用户记录 不确定 实际存储的行记录内容
Free Space 空闲空间 不确定 页中尚未使用的空间
Page Directory 页面目录 不确定 页目录
File Trailer 文件尾部 8字节 校验页是否完整

2、记录在页中的存储

在页的7个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:
image.png

3、页目录(Page Directroy)

页目录是页中除了User Records部分最重要的部分了,尤其在MySQL查找记录时,通过页目录的数据结构可以帮我们避免从表中全量遍历,而是将遍历范围缩小到个位数的量级,提高了查询效率。

3.1 页目录的结构

页目录的制作过程如下:

  1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组;
  2. 每个组的最后一条记录(也就是组内主键值最大的那条记录)的头信息中的n_owned属性表示该记录对应的分组内共有几条记录;
  3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的页目录。页面目录中的这些地址偏移量被称为槽(**Slot**,所以这个页面目录就是由槽组成的。槽相当于指针,指向每个分组的最后一条记录。

InnoDB对每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。上面页目录制作过程的第一步分组具体的过程如下:

  1. 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组;
  2. 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个;
  3. 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。

槽和记录的示意图如下:
image.png
上图中共有5个分组,16条用户记录 + 2条最小/最大记录,其中最小记录占一个分组,最大记录跟最后4条记录是一个分组,其余的3个分组里每个分组都有4条记录。页目录在左侧(其实在页的下侧,这里是方便画图将页目录放到了左侧),因为有5个分组,所以页目录里有5个槽,每个槽指向每一个分组里的最后一条行记录。每一条行记录中,蓝色区域是记录的额外信息,包括记录头信息等,橙色区域是用户的真实数据,真实数据的第一列是主键列(即值是1~16的列)。

3.2 基于页目录的查询

记录在页中按照主键值从小到大的顺序串成一个单链表,我们如果想根据主键值查找页中的某条记录该怎么操作呢?如果按照这样的办法:从Infimum记录(最小记录)开始,沿着链表一直往后找,直到找到主键值等于待查找记录主键值的记录即可。但这种遍历整个页的搜索策略,当页中记录很多,且要搜索的记录在页中靠后位置时,搜索效率会很低。因此InnoDB提出了更高效的方法,通过页目录这个数据结构来缩小遍历的范围,从而提高搜索效率。
以3.1中16条用户记录的图为例,来讲解InnoDB是如何借助页目录来提高查询效率的。上图中五个槽的编号分别是0~4,初始情况下最低的槽low=0,最高的槽high=4。过程中会用到二分法查找,现在要查找主键值为6的记录:

  1. 计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 > 6,所以设置high=2,low保持不变(0);
  2. 重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4,又因为4 < 6,所以设置low=1,high保持不变(2);
  3. 因为high - low的值为1,所以确定主键值为6的记录在槽2对应的组中。此刻我们需要找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的(通过页目录将遍历范围缩小到一个组,即1~8条记录的范围)。

所以在一个数据页中查找指定主键值的记录的过程分为两步:

  1. 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录作为遍历的起始记录;
  2. 通过记录的next_record属性遍历该槽所在的组中的各个记录,直到找到目标主键值的记录。

    4、页面头部(Page Header)

    Page Header是专门针对数据页的,记录了数据页的元数据,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,具体各字节的用途如下:
名称 占用空间大小 描述
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页定义

5、文件头部(File Header)

上面介绍的Page Header是专门针对数据页的,只有数据页才有,而File Header是所有类型的页都有的,记录了页的通用元数据,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是哪个等,具体字段如下:

名称 占用空间大小 描述
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字节 页属于哪个表空间

下面介绍以下File Header中比较重要的几个字段。

5.1 FIL_PAGE_SPACE_OR_CHKSUM

即页的校验和。校验和是指对一个很长的字符串,通过某种算法来计算生成一个比较短的字符串来代表这个很长的字符串,这个比较短的字符串就是校验和,常见的校验和比如MD5、Etag等。校验和的作用是在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。

5.2 FIL_PAGE_OFFSET

即页在表空间中的页号,InnoDB可以通过页号确定表空间中唯一一个页。

5.3 FIL_PAGE_TYPE

即页的类型,在本文最开头部分介绍了InnoDB不是仅有索引页这一种类型的页,还有很多其他类型的页。

5.4 FIL_PAGE_PREV和FIL_PAGE_NEXT

InnoDB都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB不能一次性为这么多数据分配一个非常大的连续的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREVFIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过数据页是有这两个属性的,所以数据页其实是一个双链表,就像这样:
image.png

6、文件尾部(File Trailer)

File Trailer主要用来校验页的完整性的,试想一下这个场景:页中的数据在内存中被修改了,在修改后的某个时刻需要把页中的数据刷新到磁盘中,但是在同步了一半的时候MySQL宕机了,在这种场景下就需要校验页的完整性了(即是否发生仅同步了页的一半数据的情况)。
File Trailer共8个字节,分为两个部分:
(1)前4个字节代表页的校验和
这个部分是和File Header中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的,否则意味着同步中间出了错。
(2)后4个字节代表页面被最后修改时对应的日志序列位置(**LSN**)。