mysql优化十三:InnoDB 引擎底层存储原理和结构体系

InnoDB 引擎底层存储原理和结构体系

到目前为止,MySQL 对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL 是以什么方式来访问的这些数据?这些问题我们统统不知道。要搞明白查询优化背后的原理,就必须深入 MySQL 的底层去一探究竟。

InnoDB 记录存储结构和索引页结构

InnoDB会把数据存储在系统磁盘上,所以每次关机重启数据还是会存在。而真正处理数据的过程是在内存当中的,所以需要把磁盘的数据加载到内存当中,如果处理的是写入或者修改的话,还需要把内存的数据刷新到磁盘中。我们知道读写磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,InnoDB 存储引擎不会一条一条数据的从磁盘读取出来。

InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB 中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取 16KB 的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。
我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。InnoDB 存储引擎设计了 4 种不同类型的行格式,分别是 Compact、Redundant、Dynamic 和 Compressed 行格式。

行格式

我们可以在创建或修改表的语句中指定行格式:CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
行格式分为四种:Compact、Redundant、Dynamic 和 Compressed 。这几种的行格式会有差别,但差别也不打。
Redundant行格式
Redundant 行格式是 MySQL5.0 之前用的一种行格式,不予深究。
Compact行格式
主要以Compact行格式为例,在通过Compact和其他行格式进行比较。Compact行格式如下图
在这里插入图片描述
从列1到列N是真实数据的值。当我们存储类型是定长的例如 bigint(8个字节)、int(4个字节)、datetime(8个字节)等等。mysql可以知道从开始位置读取对应长度的字节就能获取到数据。但是如果是变长的类型呢,例如VARCHAR(M)、VARBINARY(M)、各种 TEXT 类型,各种 BLOB 类型。我们存储一个VARCHAR(500)的类型数据,真实长度可能只有50,mysql如何知道从开始位置读取多少个字节能把这个数据读完。因此我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。保存的地方就在变长字段长度列表里面。

表中的某些列可能存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中存储会很占地方,所以 Compact 行格式把这些值为 NULL 的列统一管理起来,存储到 NULL 值列表。每个允许存储 NULL 的列对应一个二进制位,二进制位的值为1时,代表该列的值为NULL。二进制位的值为0时,代表该列的值不为NULL。

还有一个用于描述记录的记录头信息,它是由固定的 5 个字节组成。5 个字节也就是 40 个二进制位,不同的位代表不同的意思。
数字表示占用的二进制的位数

预留位 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 表示下一条记录的相对位置

记录的真实数据除了我们自己定义的列的数据以外,MySQL 会为每个记录默认的添加一些列(也称为隐藏列),包括:

DB_ROW_ID(row_id):非必须,6 字节,表示行 ID,唯一标识一条记录
DB_TRX_ID:必须,6 字节,表示事务 ID
DB_ROLL_PTR:必须,7 字节,表示回滚指针

DB_ROW_ID当创建表的时候没有指定主键,mysql会选取唯一性索引的列或者没有重复的列作为主键,如果找不到,InnoDB 会为表默认添加一个名为 row_id 的隐藏列作为主键。
其他行格式的区别
MySQL5.7 的默认行格式就是 Dynamic,Dynamic 和 Compressed 行格式和Compact 行格式差别不大,只不过在处理行溢出数据时有所不同。Compressed 行格式和 Dynamic 不同的一点是,Compressed 行格式会采用压缩算法对页面进行压缩,以节省空间。
数据溢出
之前说过一个数据页是16kb,如果定义一个字段为varchar(60000),然后往这个字段插入 60000 个字符,这样就造成一个页存放不了一条记录的情况。
在 Compact 和 Redundant 行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的该列的前 768 个字节的数据,然后把剩余的数据分散存储在几个其他的页中,记录的真实数据处用 20 个字节存储指向这些页的地址。这个过程也叫做行溢出,存储超出 768 字节的那些页面也被称为溢出页。
Dynamic 和 Compressed 行格式,不会在记录的真实数据处存储字段真实数据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。

索引页格式

前边我们简单提了一下页的概念,它是 InnoDB 管理存储空间的基本单位,一个页的大小一般是 16KB。InnoDB 为了不同的目的而设计了许多种不同类型的页,对于存放表中记录的那种类型的页自然也是其中的一员,官方称这种存放记录的页为索引(INDEX)页。索引页的结构如下图:
在这里插入图片描述
一个 InnoDB 索引页的存储空间大致被划分成了 7 个部分:

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

User Records和Free Space
我们真实的数据会按照我们指定的行格式记录到User Records中,但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records部分,当 Free Space 部分的空间全部被 User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
当前记录被删除时,则会修改记录头信息中的 delete_mask 为 1,也就是说被删除的记录还在页中,还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗。
所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。

Infimum + Supremum
之前说过在行格式中有个heap_no 这个信息,用来表示当前记录在页的位置信息。当我们插入记录的时候会在记录当前数据在该页的位置,向记录头信息中heap_no 写入相应信息。heap_no 值为 0 和 1 的记录是 InnoDB 自动给每个页增加的两个
记录,称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录,这两条存放在页的 User Records 部分,称为 Infimum + Supremum
记录头信息中 next_record 记录了从当前记录的真实数据到下一条记录的真实数据的地址偏移量。这其实是个链表,可以通过一条记录找到它的下一条记录。但是需要注意的一点是,下一条记录指得并不是按照我们插入顺序的记录,而是按照主键值由小到大的顺序排列的记录。而且规定 Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就是最大记录)
在这里插入图片描述
我们的记录按照主键从小到大的顺序形成了一个单链表,记录被删除,则从这个链表上摘除。

Page Directory
Page Directory 主要是解决记录链表的查找问题。如果我们想根据主键值查找页中的某条记录该咋办?按链表查找的办法:从 Infimum 记录(最小记录)开始,沿着链表一直往后找,总会找到或者找不到。但是时间复杂度不低。
InnoDB 的改进是,为页中的记录再制作了一个目录,定义为槽,他们的制作过程是这样的:

  1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个槽。
  2. 每个槽的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned 属性表示该记录拥有多少条记录,也就是该槽内共有几条记录。
  3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的 PageDirectory
    这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。
  4. 每个槽中的记录条数是有规定的:对于最小记录所在的槽只能有 1 条记录,最大记录所在的槽拥有的记录条数只能在 1~8 条之间,剩下的槽中记录的条数范围只能在是 4-8 条之间。

在这里插入图片描述
这样,一个数据页中查找指定主键值的记录的过程分为两步:

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

Page Header
InnoDB 为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫 Page Header 的部分,它是页结构的第二部分,这个部分占用固定的 56 个字节,专门存储各种状态信息。

File Header
File Header 针对各种类型的页都通用,也就是说不同类型的页都会以 File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说页的类型,这个页的编号是多少,它的上一个页、下一个页是谁,页的校验和等等,这个部分占用固定的 38 个字节。
页的类型,包括 Undo 日志页、段信息节点、Insert Buffer 空闲列表、Insert Buffer位图、系统页、事务系统数据、表空间头部信息、扩展描述页、溢出页、索引页等等,
同时通过上一个页、下一个页建立一个双向链表把许许多多的页就串联起来,而无需这些页在物理上真正连着。但是并不是所有类型的页都有上一个和下一个页的属性,索引页是有这两个属性的,所以所有的索引页其实是一个双向链表。
File Trailer
我们知道 InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办?
为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),InnoDB 每个页的尾部都加了一个 File Trailer 部分,这个部分由 8 个字节组成,可以分成 2 个小部分:

  1. 前 4 个字节代表页的校验和
    这个部分是和 File Header 中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为 File Header 在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。
    如果写了一半儿断电了,那么在 File Header 中的校验和就代表着已经修改过的页,而在 File Trailer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。
  2. 后 4 个字节代表页面被最后修改时对应的日志序列位置(LSN),这个也和校验页的完整性有关。

这个 File Trailer 与 File Header 类似,都是所有类型的页通用的。

InnoDB 的体系结构

前面,我们站在微观的角度了解了数据记录和页面的存储格式,现在我们需要站在宏观的角度看看 InnoDB 的内存结构和磁盘存储结构。
可以参考 MySQL 官方文档:https://dev.mysql.com/doc/refman/5.7/en/innodb-architecture.html
在这里插入图片描述
可以看见,比较关键的是其中的各种 Buffer 和 Tabelspace(表空间)
不过 InnoDB 的内存结构和磁盘存储结构在 MySQL8.0 有所变化:https://dev.mysql.com/doc/refman/8.0/en/innodb-architecture.html
但是不影响我们后面对 InnoDB 内部原理的学习。

InnoDB 的表空间

表空间是一个抽象的概念,对于每个独立表空间(也就是上图的File-Per-Table Tablespaces)来说,就对应着data目录下的一个名为表名.ibd 的实际文件。
任何类型的页都有专门的地方保存页属于哪个表空间,同时表空间中的每一个页都对应着一个页号,这个页号由 4 个字节组成,也就是 32 个比特位,所以一个表空间最多可以拥有 2的32次方个页,如果按照页的默认大小 16KB 来算,一个表空间最多支持 64TB 的数据。

独立表空间

区的概念
表空间中的页可以达到 2的32次方 个页,实在是太多了,为了更好的管理这些页面,InnoDB 中还有一个区(英文名:extent)的概念。对于 16KB 的页来说,连续的64 个页就是一个区,也就是说一个区默认占用 1MB 空间大小。
不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每 256个区又被划分成一个组。
组的概念
第一个组最开始的 3 个页面的类型是固定的:用来登记整个表空间的一些整体属性以及本组所有的区被称为 FSP_HDR,也就是 extent 0 ~ extent 255 这 256个区,整个表空间只有一个 FSP_HDR
其余各组最开始的 2 个页面的类型是固定的,一个 XDES 类型,用来登记本组256 个区的属性,FSP_HDR 类型的页面其实和 XDES 类型的页面的作用类似,只不过 FSP_HDR 类型的页面还会额外存储一些表空间的属性。

引入区的主要目的是什么?我们每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的 B+树的节点中插入数据。而 B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。
我们介绍 B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机 I/O。再一次强调,随机 I/O磁盘的速度和内存的速度差了好几个数量级,随机 I/O 是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序 I/O。
一个区就是在物理位置上连续的 64 个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区,从性能角度看,可以消除很多的随机 I/O。
段的概念
我们提到的范围查询,其实是对 B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点的页面放到区中的话,进行范围扫描的效果就大打折扣了。
所以 InnoDB 对 B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独
有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成 2 个段,一个叶子节点段,一个非叶子节点段。
段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。
在这里插入图片描述

系统表空间

系统表空间的结构和独立表空间基本类似,只不过由于整个 MySQL 进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,所以会比独立表空间多出一些记录这些信息的页面,相当于是表空间之首,所以它的表空间 ID(Space ID)是 0。
系统表空间有 extent 1 和 extent 两个区,也就是页号从 64~191 这 128 个页面被称为 Doublewrite buffer,也就是双写缓冲区。innodb的三大特性:双写缓冲区自适应Hash索引Buffer Pool
之前有讲过自适应Hash索引,现在来说下双写缓冲区Buffer Pool
双写缓冲区
它是一种特殊文件 flush 技术,带给 InnoDB 存储引擎的是数据页的可靠性。
它的作用是,在把页写到数据文件之前,InnoDB 先把它们写到一个叫 doublewrite buffer(双写缓冲区)的连续区域内,在写 doublewrite buffer 完成后,InnoDB 才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在doublewrite buffer中找到完好的 page 副本用于恢复。
这个双写缓冲区不仅在内存中有,在物理磁盘上也有,也就是说mysql在做数据的增删改的时候,会往内存中的双写缓冲区写入一份,在通过内存的双写缓冲区写磁盘的双写缓冲区。在双写缓冲区都写入成功后,在往具体的表对应的独立表空间中写入数据。

为什么引入这个双写缓冲区?
之前说过数据库是以页为单位写入磁盘,一页16kb,操作系统写入操作以扇区为单位,每次写入4Kb,也就是说每次往磁盘上写入一个数据页,需要4次,那如何保证四次都完成,要是其中有一次失败,数据页就损坏了,这种情况下会产生 partial page write(部分页写入)问题。这时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。在InnoDB 存储引擎未使用 doublewrite 技术前,曾经出现过因为部分写失效而导致数据丢失的情况。

双写缓冲区写入过程是怎样的呢?
mysql会现在把数据写入到内存中的双写缓冲区,大概是2M,再把内存双写缓冲区的数据分成两次,每次以1M写入到系统表空间的双写缓冲区,然后马上调用 fsync 函数,同步到具体的独立表空间上。在这个过程中是顺序写,也就是顺序IO,开销并不大。
在完成 doublewrite 写入后,将数据写入各数据文件文件这个过程,是离散写入,也就是随机IO。
所以在正常的情况下, MySQL 写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中。如果发生了极端情况(断电),InnoDB 再次启动后,发现了一个页数据已经损坏,那么此时就可以从doublewrite buffer 中进行数据恢复了。

前面说过,位于系统表空间上的 doublewrite buffer 实际上也是一个文件,写系统表空间会导致系统有更多的 fsync 操作, 而硬盘的 fsync 性能因素会降低MySQL 的整体性能。不过在存储上,doublewrite 是在一个连续的存储空间, 所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写,降低了大概 5-10%左右。
在一些情况下可以关闭 doublewrite 以获取更高的性能。比如在 读写分离的时候可以关闭从机的双写缓冲机制。

在数据库异常关闭的时候,可能有人会想到通过redo日志进行恢复,确实在一些情况下是可以通过redo日志进行恢复。比如说写入磁盘失败但是失败前的数据页的原始数据完好,那么可以通过redo日志把失败的部分补上。但是需要注意的是:redo日志中记录的是对页的物理操作,如偏移量 800,写’ aaaa’记录,而不是页面的全量记录,而如果发生 partial page write(部分页写
入)问题时,出现问题的是未修改过的数据,就是说写入失败前的数据也损坏了,此时redo日志无能为力。

如果是写 doublewrite buffer 本身失败,那么这些数据不会被写到磁盘,InnoDB此时会从磁盘载入原始的数据,然后通过 InnoDB 的事务日志来计算出正确的数据,重新写入到 doublewrite buffer,这个速度就比较慢了。如果 doublewrite buffer写成功的话,但是写数据文件失败,innodb 就不用通过事务日志来计算了,而是直接用 doublewrite buffer 的数据再写一遍,速度上会快很多。

总体来说,doublewrite buffer 的作用有两个: 提高 innodb 把缓存的数据写到硬盘这个过程的安全性;间接的好处就是,innodb 的事务日志不需要包含所有数据的前后映像,而是二进制变化量,这可以节省大量的 IO。

InnoDB 数据字典(Data Dictionary Header)

我们平时使用 INSERT 语句向表中插入的那些记录称之为用户数据,MySQL 只是作为一个软件来为我们来保管这些数据,提供方便的增删改查接口而已。但是每当我们向一个表中插入一条记录的时候,MySQL 先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的 B+树中。所以说,MySQL 除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比方说:
某个表属于哪个表空间,表里边有多少列,表对应的每一个列的类型是什么,该表有多少索引,每个索引对应哪几个字段,该索引对应的根页面在哪个表空间的哪个页面,该表有哪些外键,外键对应哪个表的哪些列,某个表空间对应文件系统上文件路径是什么。

上述这些数据并不是我们使用 INSERT 语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得已引入的一些额外数据,这些数据也称为元数据。InnoDB 存储引擎特意定义了一些列的内部系统表(internal system table)来
记录这些这些元数据:
SYS_TABLES : 整个 InnoDB 存储引擎中所有的表的信息
SYS_COLUMNS : 整个 InnoDB 存储引擎中所有的列的信息
SYS_INDEXES : 整个 InnoDB 存储引擎中所有的索引的信息
SYS_FIELDS : 整个 InnoDB 存储引擎中所有的索引对应的列的信息
SYS_FOREIGN : 整个 InnoDB 存储引擎中所有的外键的信息
SYS_FOREIGN_COLS : 整个 InnoDB 存储引擎中所有的外键对应列的信息
SYS_TABLESPACES : 整个 InnoDB 存储引擎中所有的表空间信息
SYS_DATAFILES : 整个 InnoDB 存储引擎中所有的表空间对应文件系统的文件
路径信息
SYS_VIRTUAL : 整个 InnoDB 存储引擎中所有的虚拟生成列的信息
这些系统表也被称为数据字典,它们都是以 B+树的形式保存在系统表空间某些页面中,其中 SYS_TABLES、SYS_COLUMNS、SYS_INDEXES、SYS_FIELDS 这四个表尤其重要,称之为基本系统表。

Buffer Pool

缓存的重要性
对于使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是 InnoDB 对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。
但是磁盘的速度慢,所以 InnoDB 存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘 IO 的开销了。

Buffer Pool
InnoDB 为了缓存磁盘中的页,在 MySQL 服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做 Buffer Pool(中文名是缓冲池)。那它有多大呢?这个其实看我们机器的配置,默认情况下 Buffer Pool 只有128M 大小。
show variables like 'innodb_buffer_pool_size';
在这里插入图片描述
可以在启动服务器的时候配置 innodb_buffer_pool_size 参数的值,它表示Buffer Pool 的大小,就像这样:
[server]
innodb_buffer_pool_size = 268435456
其中,268435456 的单位是字节,也就是指定 Buffer Pool 的大小为 256M。需要注意的是,Buffer Pool 也不能太小,最小值为 5M(当小于该值时会自动设置成5M)。
Buffer Pool 的缺省值其实是偏小的,一个比较合理的设置方法是按比例设置,比较权衡的值是 70%~75%之间。

Buffer Pool 内部组成
在buffer pool中也是按照页进行管理的。为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息,当然还有一些别的控制信息。
每个缓存页对应的控制信息占用的内存大小是相同的,我们称为控制块。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样的:
在这里插入图片描述
最初启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。

那么问题来了,从磁盘上读取一个页到 Buffer Pool 中的时候该放到哪个缓存页的位置呢?或者说怎么区分 Buffer Pool 中哪些缓存页是空闲的,哪些已经被使用了呢?
free 链表
innodb 把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free 链表(或者说空闲链表)。刚刚完成初始化的 Buffer Pool 中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到 free 链表中,
在这里插入图片描述
有了这个 free 链表之后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填
上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的 free链表节点从链表中移除,表示该缓存页已经被使用了。

缓存页的哈希处理
我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载到 Buffer Pool 中,如果该页已经在 Buffer Pool 中的话直接使用就可以了。那么问题也就来了,我们怎么知道该页在不在 Buffer Pool 中呢?难不成需要依次遍历Buffer Pool 中各个缓存页么?
我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个 key,缓存页就是对应的 value,怎么通过一个 key 来快速找着一个value 呢?
所以我们可以用表空间号 + 页号作为 key,缓存页作为 value 创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free 链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

flush 链表的管理
如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步。
但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 Buffer Pool 中哪些页是脏页,哪些页从来没被修改过呢?
所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫 flush 链表。链表的构造和 free 链表差不多。
在这里插入图片描述
链表的管理
Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool 大小,也就是 free 链表中已经没有多余的空闲缓存页的时候该咋办?当然是把某些旧的缓存页从 Buffer Pool 中移除,然后再把新的页放进来,那么问题来了,移除哪些缓存页呢?
Innodb 设立Buffer Pool 的初衷就是减少和磁盘的 IO 交互,最好每次在访问某个页的时候它都已经被缓存到 Buffer Pool 中了。假设我们一共访问了 n 次页,那么被访问的页已经在缓存中的次数除以 n 就是所谓的缓存命中率,我们的期望就是让缓存命中率越高越好。
再创建一个链表,这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为 LRU 链表(LRU 的英文全称:Least Recently Used)。
当我们需要访问某个页时,可以这样处理 LRU 链表:

  • 如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的控制块作为节点塞到 LRU 链表的头部。
  • 如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU链表的头部。

也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU 链表的头部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了。

划分区域的 LRU 链表
上述的LRU链表存在两种比较尴尬的情况:

  • InnoDB 提供了预读(英文名:read ahead)。所谓预读,就是 InnoDB认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool中。根据触发方式的不同,预读又可以细分为下边两种:
    线性预读
    InnoDB 提供了一个系统变量 innodb_read_ahead_threshold,如果顺序访问了
    某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 的请求。
    这个 innodb_read_ahead_threshold 系统变量的值默认是 56,我们可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值,取值范围是 0~64。
    随机预读
    如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到 Buffer Pool 的请求。InnoDB同时提供了innodb_random_read_ahead 系统变量,它的默认值为OFF。
    show variables like '%_read_ahead%';
    在这里插入图片描述
    并且可以根据通过执行 show engine innodb status 命令显示的三个参数判断read-ahead 算法的有效性:
    read_ahead、read_ahead_evicted、read_ahead_rnd
    如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到 LRU 链表的头部,但是如果此时 Buffer Pool 的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU 链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。
  • 应用程序可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有 WHERE 子句的查询)。扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统都加载到 Buffer Pool 中,这也就意味着 Buffer Pool 中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率。

因为有这两种情况的存在,所以 InnoDB 把这个 LRU 链表按照一定比例分成两截,分别是:

  1. 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称 young 区域。
  2. 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称 old 区域

在这里插入图片描述
我们可以通过查看系统变量 innodb_old_blocks_pct 的值来确定 old 区域在 LRU 链
表中所占的比例,比方说这样:SHOW VARIABLES LIKE 'innodb_old_blocks_pct';
在这里插入图片描述
有了这个被划分成 young 和 old 区域的 LRU 链表之后,InnoDB 就可以针对我们上边提到的两种可能降低缓存命中率的情况进行优化了:

  • 针对预读的页面可能不进行后续访问情况的优化
    InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页。
    但是InnoDB 规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。后续被马上访问到又会把该页放到 young 区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。所以在对某个处在 old 区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则将它移动到 young 区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time 控制的:
    SHOW VARIABLES LIKE 'innodb_old_blocks_time';
    在这里插入图片描述
    也就意味着对于从磁盘上被加载到 LRU 链表的 old 区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于 1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过 1s),那么该页是不会被加入到 young 区域的。
  • 更进一步优化 LRU 链表
    对于 young 区域的缓存页来说,我们每次访问一个缓存页就要把它移动到 LRU链表的头部,这样开销是不是太大?
    毕竟在 young 区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对 LRU 链表进行节点移动操作也会拖慢速度?为了解决这个问题,MySQL中还有一些优化策略,比如只有被访问的缓存页位于 young 区域的 1/4 的后边,才会被移动到 LRU 链表头部,这样就可以降低调整 LRU 链表的频率,从而提升性能

刷新脏页到磁盘
后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:

  1. 从 LRU 链表的冷数据中刷新一部分页面到磁盘
    后台线程会定时从 LRU 链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth 来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为 BUF_FLUSH_LRU
  2. 从 flush 链表中刷新一部分页面到磁盘。
    后台线程也会定时从 flush 链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为 BUF_FLUSH_LIST

有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看 LRU 链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU 链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE
当然,有时候系统特别繁忙时,也可能出现用户线程批量的从 flush 链表中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为,这属于一种迫不得已的情况。

多个 Buffer Pool 实例
Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内存空间,在多线程环境下,访问 Buffer Pool 中的各种链表都需要加锁处理,在 Buffer Pool 特别大而且多线程并发访问特别高的情况下,单一的 Buffer Pool 可能会影响请求的处理速度。所以在Buffer Pool 特别大的时候,我们可以把它们拆分成若干个小的 Buffer Pool,每个 Buffer Pool 都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。
我们可以在服务器启动的时候通过设置 innodb_buffer_pool_instances 的值来修改 Buffer Pool 实例的个数。那每个 Buffer Pool 实例实际占多少内存空间呢?其实使用这个公式算出来的:
innodb_buffer_pool_size/innodb_buffer_pool_instances
也就是总共的大小除以实例的个数,结果就是每个 Buffer Pool 实例占用的大小。
不过也不是说 Buffer Pool 实例创建的越多越好,分别管理各个 Buffer Pool 也是需要性能开销的,InnoDB 规定innodb_buffer_pool_instances 能设置的最大值是 64,而且当 innodb_buffer_pool_size(默认 128M)的值小于 1G 的时候设置多个实例是无效的,InnoDB 会默认把 innodb_buffer_pool_instances 的值修改为 1。

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值