MySQL索引进阶-InnoDB数据页
MySQL进阶-InnoDB数据页
一、页的概念
InnoDB 会把存储的数据划分为若干个「页」,以页作为磁盘和内存交互的基本单位,一个页的默认大小为 16KB。可以把页理解为一个容器,这个容器时用来存储记录的。
查看页的大小命令:show status like 'innodb_page_size';
二、页的分类
InnoDB 中的「页」并非只有一种,比如有存放 Insert Buffer 的页、存放 undo log 的页、存放数据的页等等。其中我们最关注的还是存放我们表数据的页,又称「索引页」,或者数据页。
下面我们来介绍和分析数据页结构。
三、数据页结构
1. InnoDB数据页的结构
示意图如下:
2. 上图中各个参数的含义
如下图:
后面会对这些参数进行详细的介绍。
四、记录插入过程
在数据页中,当记录为空时,User Records 是不存在的。随着记录的一条条插入,会不断从 Free Space 开辟空间(alloc)分配给记录。随着插入数据的增多,User Records存储的用户记录越来越多,Free Space的空间越来越少(像海绵一样自由压缩),直到没有了Free Space,用户数据记录插入时,申请新的数据页进行插入。这个过程的图示如下:
这里要重点理解一下:
1)next_record 存储的是当前记录到下一条真实数据的偏移量而不是到下一条记录的偏移量,因为记录还包含有额外的信息。记录在数据页的User Records区按照主键大小排序储存,这样通过记录的next_record信息,数据页中的数据记录之间就形成了一条单向链表
2)数据记录在物理空间存储上是连续的
五、页结构分析
1. 记录头信息
为了便于描述记录是如何在页中存储的,这里要提到记录头信息。具体细节可以参照上一篇文章《MySQL进阶-行格式》
各个参数的含义分别为:
2. Infimum+Supremum
这部分存储的是固定的两条记录,分别为数据页中的「最小记录」和「最大记录」
由于这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分,如图所示:
从图中我们可以看出来,最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前。
1) record_type
这个属性表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录。从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type值都是0,而最小记录和最大记录的record_type值分别为2和3。
2) next_record
表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。
比方说第一条记录的next_record值为36,意味着从第一条记录的真实数据的地址处向后找36个字节便是下一条记录的真实数据。从数据结构的角度来说,记录与记录之间的数据结构是单向链表,可以通过一条记录找到它的下一条记录。
这里需要注意的是:
下一条记录指的并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定最小记录的下一条记录就本页中主键值最小的记录,而本页中主键值最大的记录的下一条记录就是最大记录 。
为了更形象的表示一下这个next_record起到的作用,我们用箭头来替代一下next_record中的地址偏移量:
从图中可以看出来,我们的记录按照从小到大的顺序形成了一个单链表。最大记录的 next_record 的值为0,这也就是说最大记录是没有下一条记录了,它是这个单链表中的最后一个节点。
1)删除记录
如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:
DELETE FROM page_demo WHERE c1 = 2;
删掉第2条记录后的示意图就是:
从图中可以看出来,删除第2条记录前后主要发生了这些变化:
a. 第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1。
b. 第2条记录的next_record值变为了0,意味着该记录没有下一条记录了。
c. 第1条记录的next_record指向了第3条记录。
d. 最大记录的n_owned值从5变成了4。
所以,不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。
2)再次插入之前删除的记录
再来看一个有意思的事情,因为主键值为2的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,会发生什么事呢?
INSERT INTO page_demo VALUES(2, 200, 'bbbb');
我们看一下记录的存储情况:
从图中可以看到,InnoDB并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间。
3. Page Directory 页目录
上边我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?
比如说这样的查询语句: SELECT * FROM page_demo WHERE c1 = 3;
1)最笨的办法
从最小记录开始,沿着链表一直往后找,总有一天会找到(或者找不到[摊手]),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。
这个方法在页中存储的记录数量比较少的情况用起来也没啥问题,比方说现在我们的表里只有4条自己插入的记录,所以最多找4次就可以把所有记录都遍历一遍,但是如果一个页中存储了非常多的记录,这么查找对性能来说还是有损耗的,所以我们说这是一个笨办法。
2) 书的目录
InnoDB的设计者从书的目录中找到了灵感。我们平常想从一本书中查找某个内容的时候,一般会先看目录,找到需要查找的内容对应的书的页码,然后到对应的页码查看内容。
InnoDB的设计者为我们的记录也制作了一个类似的目录,他们的制作过程是这样的:
a. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
b. 每个组的最后一条记录的头信息中的n_owned属性表示该组内共有几条记录。
c. 将每个组的最后一条记录的地址偏移量按顺序存储起来,每个地址偏移量也被称为一个槽(英文名:Slot)。这些地址偏移量都会被存储到靠近页的尾部的地方,页中存储地址偏移量的部分也被称为Page Directory。
比方说现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录,看下边的示意图:
从这个图中我们需要注意这么几点:
现在Page Directory部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量;槽0中的值是99,代表最小记录的地址偏移量。
注意最小和最大记录的头信息中的n_owned属性:
a. 最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。
b. 最大记录的n_owned值为5,这就代表着以最大记录结尾的这个分组中只有5条记录,包括最大记录本身还有我们自己插入的4条记录。
像 99 和 112 这样的地址偏移量很不直观,我们用箭头指向的方式替代数字,这样更易于我们理解,所以修改后的示意图就是这样:
我们暂时先不管各条记录在存储设备上的排列方式,单纯从逻辑上看一下这些记录和页目录的关系:
InnoDB分组规定:
将所有正常的记录划分为几个组。
每个组的最后一条记录头信息中的n_owned属性表示所在组的记录数。
对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。
所以分组是按照下边的步骤进行的:
a. 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
b. 之后每插入一条记录都把这条记录放到最大记录所在的组,直到最大记录所在组中的记录数等于8个。
c. 在最大记录所在组中的记录数等于8个的时候再插入一条记录时,将最大记录所在组平均分裂成2个组,然后最大记录所在的组就剩下4条记录了,然后就可以把即将插入的那条记录放到该组中了。
为了演示添加了页目录之后加快查找速度的过程,所以我们再往page_demo表中添加一些记录:
因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以这里画的时候只保留了头信息中的n_owned和next_record属性,也省略了各个记录之间的箭头。
现在我们将重点放在:如何从这个页目录中查找记录?
因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行快速查找。4个槽的编号分别是:0、1、2、3、4,所以初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为5的记录,过程是这样的:
a. 计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 > 5,所以设置high=2,low保持不变。
b. 重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4。所以设置low=1,high保持不变。
c. 因为high - low的值为1,所以确定主键值为5的记录在槽1和槽2之间,接下来就是遍历链表的查找了。
总结,一个数据页中查找指定主键值的记录的过程分为两步:
1) 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
2)通过记录的next_record属性遍历该槽所在的组中的各个记录。
页目录非常重要,它决定了InnoDB了如何快速地查找记录。
这里有几个需要注意的地方:
1) Page Directory中存放了记录的相对位置(注意:这里存放的是页相对位置,而不是偏移量),有些时候这些记录指针称为Slots(槽)或目录槽(Directory Slots)。与其他数据库系统不同的是,在InnoDB中并不是每个记录都拥有一个槽,InnoDB的存储引擎的槽是一个稀疏目录(sparse directory),即一个槽中可能包含多个记录。伪记录Infimum的n_owned值总是为1,记录Supermumd n_owned的取值范围为[1,8],其他用户记录n_owned的取值范围为[4,8]。当记录被插入或删除时需要对槽进行分裂或平衡的维护操作。
2) 由于在InnoDB存储引擎中Page Directory是稀疏目录,二叉查找的结果只是一个粗略的结果,因此InnoDB存储引擎必须通过record header中的next_record来继续查找相关记录。同时,Page Directory很好地解释了recorder header中的n_owned值的含义,因为这些记录并不包括在Page Directory中。
3)B+树索引查找的是该记录所在的页
需要牢记的是,B+树索引本身并不能找到一个给定键值的具体行,能找到的只是被查找数据行所在的页。数据库把页载入到内存,然后通过对Page Directory进行二分查找,只不过二分查找的时间复杂度很低,同时在内存中的查找很快,因此通常忽略这部分查找所用的时间。
4. Page Header
InnoDB设计者为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,它是页结构的第二部分,这个部分占用固定的56个字节,专门存储各种状态信息。下面看下Page Header中各个参数的含义:
这里重点提两个参数的含义:
1)PAGE_DIRECTION
假如新插入的一条记录的主键值比上一条记录的主键值比上一条记录大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION。
2)PAGE_N_DIRECTION
假设连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
5. File Header
如果说Page Header描述的是页内的各种状态信息,比方说页里头有多少个记录,有多少个槽,那么File Header描述的就是页外的各种状态信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁之类的。File Header是InnoDB页的第一部分,这个部分占用固定的38个字节。下面看一下File Header中各个参数的含义:
6、 FIle Trailer
为了检测页是否已经完整地写入磁盘(如可能发生的写入过程中磁盘损坏、机器关机等),InnoDB存储引擎的页中设置了File Trailer部分。
四、记录和页的组织关系
不同的页之间在物理结构上可能不连续,通过页File Header中的“上一页”和“下一页”形成有序双向链表。同一页的不同记录在物理结构上是连续的,通过记录头中的next_record形成有序单向链表。
总结:
1)数据页基于File Header中记录的上一页、下一页信息构成一个双向链表
2)记录在数据页的User Records区按照主键大小排序储存,这样通过记录的next_record信息,数据页中的数据记录之间就形成了一条单向链表
3)分组的时候,在组与组之间也是形成了一条单向链表
五、B+树是如何进行记录检索的?
如果通过B+树的索引查询行记录,首先是从B+树的根开始,逐层检索,直到找到叶子节点,也就是找到对应的数据页为止,将数据页加载到内存中,页目录中的槽(slot)采用二分查找的方式先找到一个粗略的记录分组,然后再在分组中通过链表遍历的方式查找记录。
参考链接:
https://my.oschina.net/thinwonton/blog/4956237
https://juejin.cn/post/6844903970989670414