一、简介

为了避免一条一条读取磁盘数据,InnoDB采取的方式,作为磁盘内存之间交互基本单位。一个页的大小一般是16KB

InnoDB为了不同的目的而设计了多种不同类型的页

比如:存放表空间头部信息的页、存放undo日志信息的页等等。我们把存放表中数据记录的页,称为索引页or数据页。 

创建一张学生信息表

连MySQL的页都不知道,还谈什么索引呢?_数据

二、数据页结构概览

InnoDB数据页结构示意图

连MySQL的页都不知道,还谈什么索引呢?_MySQL_02

三、记录在页中的存储

我们平时都是以记录为单位向表中插入数据的,这些记录在磁盘上的存放形式也被称为行格式or记录格式

记录在页中的存储:

连MySQL的页都不知道,还谈什么索引呢?_MySQL_03

在一开始生成页的时候,没有UserRecords部分。当插入一条记录时,就会从Free Space中申请一个记录大小的空间,并将这个空间划分到User Records部分

Free Space部分的空间全部都被User Records部分替代掉后,则这个页使用完了,如果再有新的记录,则需要去申请新的页了。

四、记录头信息

COMPACT行格式示意图

连MySQL的页都不知道,还谈什么索引呢?_数据库_04

deleted_flag:删除标记(0:未删除 1:已删除 )。为什么被删除的记录还在页中?或者说,依然在磁盘上?

答:这些被删除的记录之所以没有从磁盘上删除,是因为如果移除了,还需要在磁盘上重新排列剩余的记录,这会带来一定的性能消耗,所以只是打了一个删除的标记就可以避免重排。然后所有的被删除掉的记录会组成一个垃圾链表,记录在这个链表中占用的空间被称为可重用空间。之后若是有新的记录插入到表中,它们就可以覆盖掉被删除的这些记录占用的存储空间了。

min_rec_flag:B+树中每层非叶子节点中的最小的目录项记录,都会添加该标记。

n_owned:一个页面被分若干组后,“带头大哥”用于保存组中所有的记录条数

heap_no:表示当前记录在页面堆中的相对位置。 什么叫页面堆?heap_no作用是什么?

我们向表中插入的记录都会放到User Record部分,这些记录一条条的连续排列着,InnoDB将此连续排列的结构称之为堆(heap)。为了方便管理,他们把一条记录在堆中的相对位置称之为heap_no。堆中记录的heap_no值在分配之后就不会发生改动了,即使删除了堆中的某条记录,这条被删除记录的heap_no值页仍然保持不变。

为什么用户记录的heap_no从2开始?见下图学生信息表

因为创建页时,每个页会自动添加两条记录,且都没有主键值
第一条代表页面中的最小记录(即:比任何用户记录都小)——Infimum记录,heap_no=0
第二条代表页面中的最大记录(即:比任何用户记录都大)——Supremum记录,heap_no=1
为了区分这两条默认记录和用户自己插入的记录,将着两条记录放到一个称为Infimum+Supremum的部分。

record_type:表示当前的记录类型

0:普通记录
1: B+树非叶子节点的目录项记录
2:表示Infimum记录  
3:表示Supremum记录

next_record:表示下一条记录的相对位置。就是链表。这个属性非常重要。它表示从当前记录的真实数据到下一条记录的真实数据的距离。如下所示:

连MySQL的页都不知道,还谈什么索引呢?_数据_05

为什么要指向「记录头信息」和「真实数据之间」 的位置呢?而不是指向整条记录的开头位置?

答:因为这个位置刚刚好,向左读取就是记录头信息;向右读取就是真实数据

该属性为正数——说明当前记录的下一条记录在它的后面

该属性为负数——说明当前记录的下一条记录在它的前面

比如:一条记录的next_record值为32,意味着从当前记录的真实数据的地址处向后找32字节便是下一条记录的真实数据。其中:「下一条记录」指的是按主键值由小到大的顺序排列的下一条记录。

通过下图,可以看出记录是按照主键从小到大的顺序形成了一个单向链表

记录被删除对next_record的影响,如下图所示:

连MySQL的页都不知道,还谈什么索引呢?_数据_06

deleted_flag变为了1,但是并没有从磁盘中删除。
next_record变为了0,意味着没有下一条记录了。
“bob”的next_record指向了“john”。
supremum记录的n_owned变为了4。
如果再次执行插入操作 insert into tb_student values(3, 300, "tom", 16);时,InnoDB 不会因此申请新的存储空间 ,而是直接恢复原来被删除记录的存储空间。

五、页目录Page Directory

记录在页中是按照主键值从小到大的顺序串联成为一个单向链表。那么如果我们要查询id=4的数据,用笨方法就是从记录的链表头开始,一直往下查找。但是如果数据量很大,那么性能就无法保证了。针对这个问题,InnoDB采取了图书目录的解决方案,即:Page Directory。生成Page Directory步骤如下:

首先,将非删除的数据(包含Infimum记录和Supremum记录)划分几个组。

分组规则如下:

对于Infimum记录所在的分组只能有1条记录
对于Supremum记录所在的分组只能在1~8条记录之间
剩下的记录所在的分组只能在4~8条记录之间

分组步骤如下:

初始情况下,一个数据页中只有Infimum记录和Supremum记录这两条,所以分为两个组。
之后每当插入一条记录时,都会从页目录中找到对应记录的主键值比待插入记录的主键值大,并且差值最小的槽,然后把该槽对应的n_owned加1。
当一个组中的记录数等于8时,当再插入一条记录的时候,会将组中的记录拆分成两个组(一个组中4条记录,另一个组中5条记录)。并在拆分过程中,会在Page Directory中新增一个槽,并记录这个新增分组中最大的那条记录的偏移量。

每个组的最后一条记录(即:也是这个组里,最大的那条记录)——“带头大哥”,其余的记录均为“组内小弟”;“大哥”记录的头信息中的n_owned属性表示该组内共有几条记录,而“小弟”的n_owned属性都为0

将“大哥”在页面中的地址偏移量取出来,按顺序存储到靠近Page Trailer的地方。这个地方就是Page Directory

Page Directory中的这些地址偏移量被称为Slot),每个槽占用2个字节

一个正常的页面为16KB,即:16384字节。而2个字节可以表示的地址偏移量范围是0~(2^16-1),即:0~65535。所以2个字节表示一个槽足够了。

Page Directory就是由多个槽组成的。数据记录和页目录的关系,如下所示,分为2组。

连MySQL的页都不知道,还谈什么索引呢?_主键_07

页目录生成完毕后,则可以通过二分法快速进行查找。

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

第一步】通过二分法确定该记录所在分组对应的Slot,然后找到该Slot所在分组中主键值最小的那条记录。每个槽对应的都是组内主键值最大的记录,那么怎么定位一个组中主键值最小的记录呢?答:由于每个槽都是挨着的,所以,我们可以通过找到前一个槽中的最大主键值记录,这个记录的下一条记录(next_record),就是本槽的最小主键值记录。
第二步】通过记录的next_record属性遍历该槽所在组中的各个记录。

六、页面头部Page Header

Page Header用于存储数据页中的记录的状态信息,该部分占用56个字节,专门用于存储记录的各种状态信息。详细信息如下表所示:

名称

大小

描述

PAGE_N_DIR_SLOTS

2bits

Page Directory中槽位的数量

PAGE_HEAP_TOP

2bits

未使用空间最小地址,从该地址之后就是Free Space

PAGE_N_HEAP

2bits

第1位:本记录是否为紧凑型记录

剩余15位:本页的堆中记录的数量(包含Infimum和Supremun和标记删除记录)

PAGE_FREE

2bits

每个已删除的记录通过next_record组成一个单向链表,他们的空间可以被重新利用,PAGE_FREE表示该链表头节点对应记录在页面中的偏移量

PAGE_GARBAGE

2bits

已删除记录占用的字节数

PAGE_LAST_INSERT

2bits

最后插入记录的位置

PAGE_DIRECTION

2bits

记录插入的方向【下面会有解释】

PAGE_N_DIRECTION

2bits

一个方向连续插入的记录数量【下面会有解释】

PAGE_N_RECS

2bits

该页中用户记录的数量(不包含Infimum和Supremum和被删除记录)

PAGE_MAX_TRX_ID

8bits

修改当前页的最大事务id,该值仅在二级索引页面中定义

PAGE_LEVEL

2bits

当前页在B+树中所处的层级,从0层开始

PAGE_INDEX_ID

8bits

索引ID,表示当前页属于哪个索引

PAGE_BTR_SEG_LEAF

10bits

B+树叶子节点段的头部信息,仅在B+树的根页面中定义

PAGE_BTR_SEG_TOP

10bits

B+树非叶子节点段的头部信息,仅在B+树的根页面中定义

PAGE_DIRECTION:

假如新插入的一条记录的主键值比上一条记录的主键值大,我们就说这条记录插入方向是右边,反之则是左边。

PAGE_N_DIRECTION:

假如连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就是PAGE_N_DIRECTION,如果最后一条记录的插入方向发生了改变,这个状态值就会被清零后重新统计

七、文件头部File Header

File Header部分是在所有类型的页中通用的。

File Header的详细信息如下表所示:

名称

大小

描述

FIL_PAGE_SPACE_OR_CHKSUM

4bits

表示页的“校验和”(checksum)【下面会有解释】

FIL_PAGE_OFFSET

4bits

页号【下面会有解释】

FIL_PAGE_PREV

4bits

上一个页的页号【下面会有解释】

FIL_PAGE_NEXT

4bits

下一个页的页号【下面会有解释】

FIL_PAGE_LSN

8bits

页面被最后修改时对应的LSN值

FIL_PAGE_TYPE

2bits

该页的类型【下面会有解释】

FIL_PAGE_FILE_FLUSH_LSN

8bits

仅在系统表空间的第一个页中定义,代表文件至少被刷新到了对应的LSN值

FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID

4bits

该页属于哪个表空间

FIL_PAGE_SPACE_OR_CHKSUM:

MySQL4.0.14以下,该值表示本页所在的表空间ID,之后的版本,代表着checksum值。

什么是checksum呢?

就是将一个很长的字节串通过某种算法转变为较短的值来代表这个长的字节串,这个比较短的值就称为校验和checksum)。这样在比较两个很长的字节串之前,先比较他们的checksum,如果不相同,则说明这两个字节串肯定是不同的,这样就省去了直接比较两个长字节串的时间损耗。

FIL_PAGE_OFFSET:

每个页都有一个独一无二的页号,如身份证一样。InnoDB通过页号来唯一定位一个页

FIL_PAGE_TYPE:

InnoDB为了不同的目的而把页分为不同的类型,我们前面存储记录的页类型是数据页/索引页。页的类型如下所示:

图片

FIL_PAGE_PREV和FIL_PAGE_NEXT:

通过这两个参数,就可以建立一个双向链表把许多的页串联起来,而无须这些页在物理上是真正紧挨在一起的。这里需要注意的是,并不是所有类型的页都有这两个属性的

八、文件尾部File Trailer

我们知道,InnoDB会把数据最终持久化到磁盘上,但是磁盘的读取速度太慢了,所以,读取的时候,就会把数据放到内存中进行缓存,如果对缓存中的数据进行修改后,也会定时或者根据某些触发条件将修改后的内容刷新到磁盘上。那么,如果这个过程中出现了异常或者断电了怎么办?为了检测一个页是否完整,File Trailer就应运而生了,它与File Header类似,都通用于所有类型的页。它是由8个字节组成,可以分为如下两个小部分。

第一部分:页的checksum(占前4个字节)

当一个页在内存中被修改时,在刷新到磁盘之前首先是要计算出checksum值的。由于File Header在页面的前边,所以File Header中的checksum会被优先刷新到磁盘,当完全写完后,checksum的值再被写到File Trailer。如果页面刷新成功,那么File Header和File Trailer的checksum值应该是一致的。否则,就意味着刷新期间发生了错误。

第二部分:页被最后修改时对应的LSN的后4字节(占后4个字节)

正常情况下File Trailer的这部分值应该与File Header的FIL_PAGE_LSN的后4的字节相同。这部分也是用于校验页的完整性的

九、总结

InnoDB的数据页有7个组成部分。

每个数据页在物理结构上可以不是相连的,但是可以通过一个双向链表在逻辑上相互关联。

通过File Header文件头部中的FIL_PAGE_PREVFIL_PAGE_NEXT构成双向链表。

FIL_PAGE_PREV:记录上一个页的页号。
FIL_PAGE_NEXT:记录下一个页的页号。

每个数据页中的记录,会按照主键值从小到大的顺序组成一个单向链表

每个数据页都会为存储在它里面的记录生成一个页目录

在通过主键查找某条记录的时候,可以在页目录中使用二分法快速定位到对应的(Slot),然后再遍历该槽对应分组中的记录,就可以快速找到指定的记录了。

今天的文章内容就这些了:

写作不易,笔者几个小时甚至数天完成的一篇文章,只愿换来您几秒钟的 点赞 & 分享

更多技术干货,欢迎大家关注公众号“爪哇缪斯” ~ \(^o^)/ ~ 「干货分享,每天更新」