不会 MySQL 的 Buffer Pool,阿伟教你啊


我们都知道,MySQL 是基于磁盘存储的数据库,那我们每次查询一个页的记录时都得从磁盘加载整个页进内存然后读取呢,那这样既不是很慢。所以 MySQL 实现了一种叫做 Buffer Pool 的玩意,那 Buffer Pool 是啥玩意呢,我们接着往下看 🤩🤩🤩

一、Buffer Pool

其实,Buffer Pool 是 MySQL 服务器向操作系统申请的一片连续的内存区域,我们可以通过 innodb_buffer_pool_size 设置 Buffer Pool 的大小。

小贴士:innodb_buffer_pool_size 的单位是字节

那么 Buffer Pool 内部是怎么组成的呢?我们都知道,Buffer Pool 也是用来存储从磁盘加载的数据页,我们把它叫做缓存页,它的大小和磁盘上的数据页的大小是一样的,都是 16 KB。但是,我们为了方便管理这些缓存页,所以其实每个缓存页都对应有一个控制信息,它包括了该页所属的表空间、页号、缓冲页在 Buffer Pool 中的内存地址以及链表节点信息。我们把这个控制信息叫做控制块,它在 Buffer Pool 的前面, 而缓存页在 Buffer Pool 的后面,我们还是画一个图吧,这样更加形象一点:

image-20220327111108716

细心的同学可能看到了这个碎片,学过 JVM 的同学都知道 JVM 垃圾回收的时候可能会产生内存碎片是吧,其实这个碎片和那个差不多的,也是不够分配给控制块和缓存页的内存碎片空间

小贴士:其实这个控制块大小不在 innodb_buffer_pool_size 里面,控制块占缓冲页大小的 5% 左右,所以我们实际分配内存的时候会比 innodb_buffer_pool_size 设置的大小大 5% 左右

上面说了 Buffer Pool 是 MySQL 服务器向操作系统申请的一片连续的内存区域,当我们调整 Buffer Pool 的大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的 Buffer Pool 中的内容复制到这一块新空间,这是极其耗时的。所以 MySQL 决定以一个 chunk 为单位向操作系统申请空间,即一个 Buffer Pool 由若干各 chunk 组成:

image-20220327111113995

图中是多个 Buffer Pool 实例,我们可以通过指定 innodb_buffer_pool_instances 来控制 Buffer Pool 实例的个数。这样做是为了可以解决并发安全问题 🤭🤭🤭。

二、free 链表

在我们执行一个查询语句的时候,我们都会先在 Buffer Pool 里面查询,如果没有,然后从磁盘加载数据页进 BufferPool 的缓存页中查询。那我们怎么知道 Buffer Pool 中是否有空闲的缓存页呢?这时候就用到了我们的 free 表了。顾名思义,free 链表就是存储 Buffer Pool 中有哪些空闲的缓冲页,其中 free 链表的节点是缓存页的控制块如下图:

image-20220327111123555

从图中可以看出,我们为了管理好这个 free链表 ,特意为这个链表定义了一个 基节点 ,里边儿包含着链表的头点地址,尾节点地址,以及当前链表中节点的数量等信息。这里需要注意的是,链表的基节点占用的内存空间并不包含在为 Buffer Pool 申请的一大片连续内存空间之内,而是单独申请的一块内存空间。

每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 free链表 中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的 free链表 节点从链表中移除,表示该缓存页已经被使用

小贴士:当我们执行查询语句时,是如何确实 Buffer Pool 是是否有需要查询的缓存页呢?其实我们可以用一个 HashMap 来存储 Buffer Pool 中的缓存页,使用 数据页的 表空间 + 页号 作为 key,缓存页就作为对应的 value

三、flush 链表

有了 free 链表的学习,flush 链表学起来也是非常的简单了

当我们更新 Buffer Pool 中的记录时,此时 Buffer Pool 中的记录就和磁盘上的不一样了,我们此时把这个记录对应的缓存页叫做脏页,把缓存页的更新同步到磁盘的数据页就叫做刷脏页。那我们是不是每次更改了记录都需要刷新到磁盘呢。其实这样是没有必要的,因为磁盘 IO 是非常慢的,这会严重影响 MySQL 的性能。所以我们需要记录 Buffer Pool 中到底有哪些缓存页是脏页,此时就用到了我们的 flush 链表。顾名思义,flush 就是刷新的意思嘛,所以 flush 链表就是用来刷脏页用的。我们看看 flush 链表到底长啥样:

image-20220327111130448

四、LRU链表

当我们的 Buffer Pool 的实际大小满了之后,假如此时有新的数据页要加载进 Buffer Pool 中,既不是没法加载了,所以此时我们需要将 Buffer Pool 中的一些缓存页删除,来存储新的数据页。那到底删除哪些缓存页比较好呢?这时就用到了 LRU 链表了

1、简单的 LRU 链表

LRU 算法大家应该不陌生吧,那 LUR 链表也是一回事,就是当 Buffer Pool 中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。当我们需要访问某个页时,可以这样处理 LRU链表 :

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

也就是说,只要我们使用到某个缓存页,就把该缓存页调整到 LRU链表 的头部,这样 LRU链表 尾部就是最近最少使用的缓存页。

2、划分区域的 LRU 链表

那这样是不是就万事大吉了呢?不要高兴的太早了😮‍💨😮‍💨😮‍💨

情况一:MySQL 有预读机制,所谓预读,就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。预读又分为 线性预读 和 随机预读

线性预读

如果顺序访问了某个区( extent )的页面超过 n 页,就会触发一次 异步 读取下一个区中全部的页面到 Buffer Pool 的请求,这个 n 可以由系统变量 innodb_read_ahead_threshold 设置,默认值是 56

小贴士:异步读取意味着从磁盘中加载这些被预读的页面并不会影响到当前工作线程的正常执行

随机预读:

如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是不是顺序读取的,都会触发一次 异步 读取本区中所有其的页面到 Buffer Pool 的请求,我们可以通过 innodb_random_read_ahead 来打开和关闭随机预读,默认是 OFF,也就是不开启随机预读。

那预读到底有啥问题呢?如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到 LRU 链表的头部,但是如果此时 Buffer Pool 的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU链表 尾部的一些缓存页会很快的被淘汰掉,会大大降低缓存命中率。

情况二:全表扫描

全表扫描就是访问该表所在的所有页,假设这个表中记录非常多的话,那该表会占用特别多的页 ,当需要访问这些页时,会把它们统统都加载到 Buffer Pool 中,那么其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率。

既然预读机制和全表扫描都会降低缓存命中率,那我们的 MySQL 早就想到了解决方案,那就是把 LRU 链表分为 热数据区(young) 和 冷数据区(old),其中热数据区存储使用频率非常高的缓存页,冷数据区存储使用频率非常低的缓存页,我们还是画一个图方便我们理解:

image-20220327111137307

小贴士:我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是 young 区域的,某些节点固定是 old 区域的,随着程序的运行,某个节点所属的区域也可能发生变化。

我们可以通过系统变量 innodb_old_blocks_pct 查看old 区域在 LRU链表 中所占的比例,默认大小是 37 %

我们现在来看看针对上面的两种情况看划分区域的 LUR 链表是如何解决这些问题的。

预读:

当磁盘上的某个页面在初次加载到 Buffer Pool中 的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页。

全表扫描:

记录首次被加载到 Buffer Pool 的页被放到了 old 区域的头部,然后马上被访问到将会放入 yuoug 头部,这样就会把使用频率较高的也页面比下去,所以这样也是行不通的。因此,我们在对某个处在 old 区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则将它移动到young区域的头部。

小贴士:这个间隔时间是由系统变量 innodb_old_blocks_time 控制的,默认是 1000,单位是 ms

所以默认在 1s 内访问并不会将该缓存页从 old 区域移动到 young 区域的头部,这样就解决了全表扫描带来的问题 🤗🤗🤗

难道你认为这样就完了吗,那你太小看 MySQL 了,MySQL 提出了更进一步优化了 LRU 链表。

对于 young 区域的缓存页来说,我们每次访问一个缓存页就要把它移动到 LRU链表 的头部,这样开销是不是太大啦,毕竟在 young 区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对 LRU链表 进行节点移动操作是不是不太好啊?是的,为了解决这个问题其实我们还可以提出一些优化策略,比如只有被访问的缓存页位于 young 区域的 1/4 的后边,才会被移动到 LRU链表 头部,这样就可以降低调整 LRU链表 的频率,从而提升性(也就是说如果某个缓存页对应的节点在 young 区域的 1/4 中,再次访问该缓存页时也不会将其移动到 LRU 链表头部)。

五、刷脏页

啥为脏页:就是更新了缓存页中的记录,此时还没有同步到磁盘上的数据页,此时的缓存页就是脏页,里面的数据就是脏数据。因此我们需要将每隔一段时间将脏页刷到磁盘。这里 MySQL 为了不影响用户线程正常处理请求,所以 MySQL 后台专门有线程来进行刷脏页的操作,主要有两种途径刷脏页:

  • 从 flush链表 中刷新一部分页面到磁盘。
  • 从 LRU链表 的冷数据中刷新一部分页面到磁盘

假如后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看 LRU链表 尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU链表 尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。

六、总结

磁盘太慢,用内存作为缓存很有必要。

Buffer Pool 本质上是 InnoDB 向操作系统申请的一段连续的内存空间,可以通过 innodb_buffer_pool_size 来调整它的大小。

Buffer Pool 向操作系统申请的连续内存由控制块和缓存页组成,每个控制块和缓存页都是一一对应的,在填充足够多的控制块和缓存页的组合后, Buffer Pool 剩余的空间可能产生不够填充一组控制块和缓存页,这部分空间不能被使用,也被称为 碎片 。

InnoDB 使用了许多 链表 来管理 Buffer Pool 。

free链表中每一个节点都代表一个空闲的缓存页,在将磁盘中的页加载到 Buffer Pool 时,会从 free链表 中寻找空闲的缓存页。

在 Buffer Pool 中被修改的页称为脏页 ,脏页并不是立即刷新,而是被加入到 flush链表中,待之后的某个时刻同步到磁盘上。

LRU 链表 分为 young 和 old 两个区域,可以通过 innodb_old_blocks_pct 来调节 old 区域所占的比例。首次从磁盘上加载到 Buffer Pool 的页会被放到 old 区域的头部,在 innodb_old_blocks_time 间隔时间内访问该页不会把它移动到 young 区域头部。在 Buffer Pool 没有可用的空闲缓存页时,会首先淘汰掉 old 区域的一些页。

我们可以通过指定 innodb_buffer_pool_instances 来控制 Buffer Pool 实例的个数,每个 Buffer Pool 实例中都有各自独立的链表,互不干扰。

自 MySQL 5.7.5 版本之后,可以在服务器运行过程中调整 Buffer Pool 大小。每个 Buffer Pool 实例由若干个 chunk 组成,每个 chunk 的大小可以在服务器启动时通过启动参数调整可以用下边的命令查看 Buffer Pool 的状态信息:

SHOW ENGINE INNODB STATUS\G 

好了,到这里我们的 Buffer Pool 就讲的差不多了,我们下次见 🤣🤣🤣。

巨人的肩膀:

https://juejin.cn/book/6844733769996304392


文章作者: Skyu
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Skyu !
  目录