【大白话系统】MySQL 学习总结 之 缓冲池(Buffer Pool) 的设计原理和管理机制
一、缓冲池(Buffer Pool)的地位
在《MySQL 学习总结 之 InnoDB 存储引擎的架构设计》中,我们就讲到,缓冲池是 InnoDB 存储引擎中最重要的组件。因为为了提高 MySQL 的并发性能,使用到的数据都会缓存在缓冲池中,然后所有的增删改查操作都将在缓冲池中执行。
通过这种方式,保证每个更新请求,尽量就是只更新内存,然后往磁盘顺序写日志文件。
更新内存的性能是极高的,然后顺序写磁盘上的日志文件的性能也是比较高的,因为顺序写磁盘文件,他的性能要远高于随机读写磁盘文件。
正因为缓冲池的重要性,所以我们必须比较深入地去理解和研究其中的原理和机制。当然了,我这里还是比较大白话的介绍缓冲池这个组件的原理,比较适合大家比较整体和从宏观上去了解,如果需要更加深入的,建议大家去看 MySQL 的官方文档,或者是相关技术书籍。
二、 Buffer Pool 的大小
缓冲池(Buffer Pool)的默认大小为 128M,可通过 innodb_buffer_pool_size 参数来配置。
三、Buffer Pool 的结构
当 SQL 执行时,用到的相关表的数据行,会将这些数据行都缓存到 Buffer Pool
中。
但是我们可以想象一下,如果像上面的机制那么简单,那么如果是分页的话,不断地查询就要不断地将磁盘文件中数据页的数据缓存到 Buffer Pool
中了,那么这时候缓存池这个机制就显得没什么用了,每次查询还是会有一次或者多次的磁盘IO。
但是怎么缓存呢?
1、数据页概念
我们先了解一下数据页这个概念。它是 MySQL 抽象出来的数据单位,磁盘文件中就是存放了很多数据页,每个数据页里存放了很多行数据。
默认情况下,数据页的大小是 16kb。
所以对应的,在 Buffer Pool
中,也是以数据页为数据单位,存放着很多数据。但是我们通常叫做缓存页,因为 Buffer Pool
毕竟是一个缓冲池,并且里面的数据都是从磁盘文件中缓存到内存中。
所以,默认情况下缓存页的大小也是 16kb,因为它和磁盘文件中数据页是一一对应的。
所以,缓冲池和磁盘之间的数据交换的单位是数据页,包括从磁盘中读取数据到缓冲池和缓冲池中数据刷回磁盘中,如图所示:
2、怎么识别数据在哪个缓存页中?
到此,我们都知道 Buffer Pool
中是用缓存页来缓存数据的,但是我们怎么知道缓存页对应着哪个表,对应着哪个数据页呢?
所以每个缓存页都会对应着一个描述数据块,里面包含数据页所属的表空间、数据页的编号,缓存页在 Buffer Pool
中的地址等等。
描述数据块本身也是一块数据,它的大小大概是缓存页大小的5%左右,大概800个字节左右的大小。
描述如图所示:
四、Buffer Pool 的初始化
到此,我们都知道了,Buffer Pool
是缓存数据的数据单位为缓存页,利用描述数据块来标识缓存页。
那么,MySQL 启动时,是如何初始化 Buffer Pool
的呢?
1、MySQL 启动时,会根据参数 innodb_buffer_pool_size
的值来为 Buffer Pool
分配内存区域。
2、然后会按照缓存页的默认大小 16k 以及对应的描述数据块的 800个字节 左右大小,在 Buffer Pool
中划分中一个个的缓存页和一个个的描述数据库块。
3、注意,此时的缓存页和描述数据块都是空的,毕竟才刚启动 MySQL 呢。
五、Free 链表记录空闲缓存页
上面我们了解了 Buffer Pool
在 MySQL 启动时是如何初始化的。当 MySQL 启动后,会不断地有 SQL 请求进来,此时空先的缓存页就会不断地被使用。
那么, Buffer Pool
怎么知道哪些缓存页是空闲的呢?
1、Free 链表的使用原理
free 链表,它是一个双向链表,链表的每个节点就是一个个空闲的缓存页对应的描述数据块。
他本身其实就是由 Buffer Pool
里的描述数据块组成的,你可以认为是每个描述数据块里都有两个指针,一个是 free_pre 指针,一个是 free_next 指针,分别指向自己的上一个 free 链表的节点,以及下一个 free 链表的节点。
通过 Buffer Pool
中的描述数据块的 free_pre 和 free_next 两个指针,就可以把所有的描述数据块串成一个 free 链表。
下面我们可以用伪代码来描述一下 free 链表中描述数据块节点的数据结构:
DescriptionDataBlock{
block_id = block1;
free_pre = null;
free_next = block2;
}
free 链表有一个基础节点,他会引用链表的头节点和尾节点,里面还存储了链表中有多少个描述数据块的节点,也就是有多少个空闲的缓存页。
下面我们也用伪代码来描述一下基础节点的数据结构:
FreeListBaseNode{
start = block01;
end = block03;
count = 2;
}
到此,free 链表就介绍完了。上面我们也介绍了 MySQL 启动时 Buffer Pool
的初始流程,接下来,我会将结合刚介绍完的 free 链表,讲解一下 SQL 进来时,磁盘数据页读取到 Buffer Pool
的缓存页的过程。但是,我们先要了解一下一个新概念:数据页缓存哈希表,它的 key 是表空间+数据页号,而 value 是对应缓存页的地址。
描述如图所示:
2、磁盘数据页读取到 Buffer Pool
的缓存页的过程
1、首先,SQL 进来时,判断数据对应的数据页能否在 数据页缓存哈希表里 找到对应的缓存页。
2、如果找到,将直接在 Buffer Pool
中进行增删改查。
3、如果找不到,则从 free 链表中找到一个空闲的缓存页,然后从磁盘文件中读取对应的数据页的数据到缓存页中,并且将数据页的信息和缓存页的地址写入到对应的描述数据块中,然后修改相关的描述数据块的 free_pre 指针和 free_next 指针,将使用了的描述数据块从 free 链表中移除。记得,还要在数据页缓存哈希表中写入对应的 key-value 对。最后也是在 Buffer Pool
中进行增删改查。
六、Flush 链表记录脏缓存页
1、脏页和脏数据
我们都知道 SQL 的增删改查都在 Buffer Pool
中执行,慢慢地,Buffer Pool
中的缓存页因为不断被修改而导致和磁盘文件中的数据不一致了,也就是 Buffer Pool
中会有很多个脏页,脏页里面很多脏数据。
所以,MySQL 会有一条后台线程,定时地将 Buffer Pool
中的脏页刷回到磁盘文件中。
但是,后台线程怎么知道哪些缓存页是脏页呢,不可能将全部的缓存页都往磁盘中刷吧,这会导致 MySQL 暂停一段时间。
2、MySQL 是怎么判断脏页的
我们引入一个和 free 链表类似的 flush 链表。他的本质也是通过缓存页的描述数据块中的两个指针,让修改过的缓存页的描述数据块能串成一个双向链表,这两指针大家可以认为是 flush_pre 指针和 flush_next 指针。
下面我用伪代码来描述一下:
DescriptionDataBlock{
block_id = block1;
// free 链表的
free_pre = null;
free_next = null;
// flush 链表的
flush_pre = null;
flush_next = block2;
}
flush 链表也有对应的基础节点,也是包含链表的头节点和尾节点,还有就是修改过的缓存页的数量。
FlushListBaseNode{
start = block1;
end = block2;
count = 2;
}
到这里,我们都知道,SQL 的增删改都会使得缓存页变为脏页,此时会修改脏页对应的描述数据块的 flush_pre 指针和 flush_next 指针,使得描述数据块加入到 flush 链表中,之后 MySQL 的后台线程就可以将这个脏页刷回到磁盘中。
描述如图所示:
七、LRU 链表记录缓存页的命中率
1、缓存命中率
我们都知道,当加载磁盘中的数据页到缓存中时,会从 free 链表找到空闲的缓存页,然后将数据加载到缓存页里。
但是缓存页总会有用完的时候,此时需要淘汰一下缓存页,将它刷入磁盘中,然后清空。
那么会选择谁淘汰呢?
那么必定会淘汰缓存命中率低的缓存页。
什么叫缓存命中率低:假如你有100次请求,有30次请求都是查询和修改缓存页一,直接操作缓存而不需要从磁盘加载,这就是缓存命中率高。而缓存页二自加载到 Buffer Pool
后,只被查询和修改过一次,之后的100次请求中甚至没有一次是查询和修改它的,这就是缓存命中率低了,因为大部分请求都是操作其他缓存页,甚至要从磁盘中加载。
2、lru 链表的使用原理
InnoDB 存储引擎是利用 lru 链表完成上面的缓存命中率的。lru 就是 Least Recently Used,最近最少使用的意思。
ps:lru 链表也是类似于 free 链表和 flush 链表的数据结构。
当有磁盘数据页加载数据到缓存页时,会将缓存页对应的描述数据块放入 lru 链表的头部;后续只要查询或者修改了缓存页的数据,也会将对应描述数据块移到 lru 链表的头部去。
此时,lru 链表尾部的描述数据块对应的缓存页,必定是命中率最低的,也就是使用最少的缓存页,所以优先被淘汰的肯定是它。
3、lru 链表存在的问题
lru 链表的使用当然不会像上面的那么简单。
因为 MySQL 为了提高性能,提供了一个机制:预读机制。
当你从磁盘上加载一个数据页的时候,他可能会连带着把这个数据页相邻的其他数据页,也加载到缓存里去。这个机制会带来这么一个问题:连带的数据页可能在后面的查询或者修改中,并不会用到,但是它们却在 lru 链表的头部。
什么意思?
那就是,本来经常被用到的缓存页被压到 lru 链表的尾部去了,如果此时需要淘汰缓存页,命中率高的缓存页反而被淘汰掉了!
当然了,全表扫描也会带来同样的问题。
全表扫描会将表里所有的数据一次性加载到 Buffer Pool
来,但是却有很多数据在之后都不会用到。
4、冷热数据分离
什么是冷热分离?
简单点,就是将命中率高的数据和命中率低的数据分开,分成两块区域。
InnoDB 存储引擎就是利用冷热数据分离方案来解决上面的问题:将 lru 链表分为两部分,一部分是热数据区域链表,一部分是冷数据区域链表。
lru 链表的头节点指向热数据区域的链表头节点,lru 链表的尾节点指向冷数据区域的链表尾节点。
描述如图所示:
冷热分离的比例
由参数 innodb_old_blocks
控制,默认值为37,表示冷数据占所有数据的37%。
冷热分离的原理
磁盘中的数据页第一次加载到缓存页时,对应的描述数据块放到冷数据区域的链表头部,然后在 1s 后,如果再次访问这个缓存页,才会将缓存页对应的描述数据块移动到热数据区域的链表头部去。
这个 1s 由参数 innodb_old_blocks_time
指定,默认值是 1000 毫秒。
热数据区的优化
冷数据区的缓存页是在 1s 后再被访问到就移动到热数据区的链表头部。那么热数据区域的规则呢,是不是只要被访问就会移动到热数据区域的链表头部。
当然不是了。大家可以想一下,能留在热数据区域的缓存页,证明都是缓存命中率比较高的,会经常被访问到。如果每个缓存页被访问都移动到链表头部,那这个操作将会非常的频繁。
所以 InnoDB 存储引擎做了一个优化,只有在热数据区域的后 3/4 的缓存页被访问了,才会移动到链表头部;如果是热数据区域的前 1/4 的缓存页被访问到,它是不会被移动到链表头部去的。
lru 链表尾部的缓存页何时刷入磁盘
当 free 链表为空了,此时需要将数据页加载到缓冲池里,就会 lru 链表的冷数据区域尾部的缓存页刷入磁盘,然后清空,再加载数据页的数据。
一条后台线程,运行一个定时任务,定时将 lru 链表的冷数据区域的尾部的一些缓存页刷入磁盘,然后清空,最后把他们对应的描述数据块加入到 free 链表中去。
当然了,除了 lru 链表尾部的缓存页会被刷入磁盘,还有的就是 flush 链表的缓存页。
后台线程同时也会在 MySQL 不繁忙的时候,将 flush 链表中的缓存页刷入磁盘中,这些缓存页的描述数据块会从 lru 链表和 flush 链表中移除,并加入到 free 链表中。
八、总结
到此,我已经将缓冲池 Buffer Pool
介绍完毕了。
下面简单总结一下 Buffer Pool
从初始化到使用的整个流程。
1、MySQL 启动时会根据分配指定大小内存给 Buffer Pool
,并且会创建一个个描述数据块和缓存页。
2、SQL 进来时,首先会根据数据的表空间和数据页编号查询 数据页缓存哈希表 中是否有对应的缓存页。
3、如果有对应的缓存页,则直接在 Buffer Pool
中执行。
4、如果没有,则检查 free 链表看看有没有空闲的缓存页。
5、如果有空闲的缓存页,则从磁盘中加载对应的数据页,然后将描述数据块从 free 链表中移除,并且加入到 lru 链表的冷数据区域的链表头部。后面如果被修改了,还需要加入到 flush 链表中。
6、如果没有空闲的缓存页,则将 lru 链表的冷数据区域的链表尾部的缓存页刷回磁盘,然后清空,接着将数据页的数据加载到缓存页中,并且描述数据块会加入到 lru 链表的冷数据区域的链表头部。后面如果被修改了,还需要加入到 flush 链表中。
7、5或者6后,就接着在 Buffer Pool
中执行增删改查。
注意:5和6中,缓存页加入到冷数据区域的链表头部后,如果在 1s 后被访问,则将入到热数据区域的链表头部。
8、最后,就是描述数据块随着 SQL 语句的执行不断地在 free 链表、flush 链表和 lru 链表中移动了。