什么是数据库的 “缓存池” ?(万字干货)

互联网全栈架构

共 10619字,需浏览 22分钟

 ·

2021-02-01 22:41

1、Buffer Pool 概述

Buffer Pool 是什么?从字面上看是缓存池的意思,没错,它其实也就是缓存池的意思。它是 MySQL 当中至关重要的一个组件,可以这么说,MySQL的所有的增删改的操作都是在 Buffer Pool 中执行的。

但是数据不是在磁盘中的吗?怎么会和缓存池又有什么关系呢?那是因为如果 MySQL的操作都在磁盘中进行,那很显然效率是很低的,效率为什么低?因为数据库要从磁盘中拿数据啊,那肯定就需要IO啊,并且数据库并不知道它将要查找的数据是磁盘的哪个位置,所以这就需要进行随机IO,那这个性能简直就别玩了。所以 MySQL对数据的操作都是在内存中进行的,也就是在 Buffer Pool 这个内存组件中。

实际上他就好比是 Redis,因为 Redis 是一个内存是数据库,他的操作就都是在内存中进行的,并且会有一定的策略将其持久化到磁盘中。那 Buffer Pool 的内存结构具体是什么样子的,那么多的增删改操作难道数据要一直在内存中吗?既然说类似 redis 缓存,那是不是也像 redis 一样也有一定的淘汰策略呢?

本篇文章,会详细的介绍 Buffer Pool 的内存结构,让大家彻底明白这里面的每一步执行流程。我们先看一下 MySQL从加载磁盘文件到完成提交一个事务的整个流程。我们先来看一个总体的流程图,从数据在磁盘中被加载到缓存池中,然后经过一些列的操作最终又被刷入到磁盘的一个过程,都经历了哪些事情,这个图不明白没有关系,因为本文重点是 Buffer Pool 这个整体的流程就是让大家稍微有个印象。

2、Buffer Pool 有多大

Buffer Pool 是 InnoDB 中的一块内存区域,他一定是有自己的大小的,且大小默认是 128M,不过这个容量似乎有点小了,大家的自己的生产环境可以根据实际的内存大小进行调整,参数为:innodb_buffer_pool_size=2147483648 单位是字节,

# 查看和调整innodb_buffer_pool_size
1. 查看@@innodb_buffer_pool_size大小,单位字节
SELECT @@innodb_buffer_pool_size/1024/1024/1024; #字节转为G

2. 在线调整InnoDB缓冲池大小,如果不设置,默认为128M
set global innodb_buffer_pool_size = 4227858432; ##单位字节

他在 InnoDB 中的整体结构大概是这样子的

3、数据页

刚刚介绍到 MySQL在执行增删改的时候数据是会被加载到 Buffer Pool 中的,既然这样数据是怎么被加载进来的,是一条一条还是说是以其他的形式呢。我们操作的数据都是以表 + 行的方式,而表 + 行仅仅是逻辑上的概念,MySQL并不会像我们一样去操作行数据,而是抽象出来一个一个的数据页概念,每个数据页的大小默认是 16KB,这些参数都是可以调整的。但是建议使用默认的就好,毕竟 MySQL能做到极致的都已经做了。每个数据页存放着多条的数据,MySQL在执行增删改首先会定位到这条数据所在数据页,然后会将数据所在的数据页加载到 Buffer Pool 中。

4、缓存页

当数据页被加载到缓冲池中后,Buffer Pool 中也有叫缓存页的概念与其一一对应,大小同样是 16KB,但是 MySQL还为每个缓存也开辟额外的一些空间,用来描述对应的缓存页的一些信息,例如:数据页所属的表空间,数据页号,这些描述数据块的大小大概是缓存页的15%左右(约800B)。

#  缓存页是什么时候被创建的?
当 MSql 启动的时候,就会初始化 Buffer Pool,这个时候 MySQL 会根据系统中设置的 innodb_buffer_pool_size 大小去内存中申请一块连续的内存空间,实际上在这个内存区域比配置的值稍微大一些,因为【描述数据】也是占用一定的内存空间的,当在内存区域申请完毕之后, MySql 会根据默认的缓存页的大小(16KB)和对应`缓存页*15%`大小(800B左右)的数据描述的大小,将内存区域划分为一个个的缓存页和对应的描述数据

5、Free链表

上面是说了每个数据页会被加载到一个缓存页中,但是加载的时候 MySQL是如何知道那个缓存页有数据,那个缓存页没有数据呢?换句话说, MySQL是怎么区分哪些缓存页是空闲的状态,是可以用来存放数据页的。

为了解决这个问题, MySQL 为 Buffer Pool 设计了一个双向链表— free链表,这个 free 链表的作用就是用来保存空闲缓存页的描述块(这句话这么说其实不严谨,换句话:每个空闲缓存页的描述数据组成一个双向链表,这个链表就是free链表)。之所以说free链表的作用就是用来保存空闲缓存页的描述数据是为了先让大家明白 free 链表的作用,另外 free 链表还会有一个基础节点,他会引用该链表的头结点和尾结点,还会记录节点的个数(也就是可用的空闲的缓存页的个数)。

这个时候,他可以用下面的图片来描述:

当加载数据页到缓存池中的时候, MySQL会从 free 链表中获取一个描述数据的信息,根据描述节点的信息拿到其对应的缓存页,然后将数据页信息放到该缓存页中,同时将链表中的该描述数据的节点移除。这就是数据页被读取 Buffer Pool 中的缓存页的过程。

MySQL是怎么知道哪些数据页已经被缓存了,哪些没有被缓存呢。实际上数据库中还有后一个哈希表结构,他的作用是用来存储表空间号 + 数据页号作为数据页的key,缓存页对应的地址作为其value,这样数据在加载的时候就会通过哈希表中的key来确定数据页是否被缓存了。

6、Flush链表

MySql 在执行增删改的时候会一直将数据以数据页的形式加载到 Buffer Pool 的缓存页中,增删改的操作都是在内存中执行的,然后会有一个后台的线程数将脏数据刷新到磁盘中,但是后台的线程肯定是需要知道应该刷新哪些啊。

针对这个问题,MySQL设计出了 Flush 链表,他的作用就是记录被修改过的脏数据所在的缓存页对应的描述数据。如果内存中的数据和数据库和数据库中的数据不一样,那这些数据我们就称之为脏数据,脏数据之所以叫脏数据,本质上就是被缓存到缓存池中的数据被修改了,但是还没有刷新到磁盘中。

同样的这些已经被修改了的数据所在的缓存页的描述数据会被维护到 Flush 中(其实结构和 free 链表是一样的),所以 Flush 中维护的是一些脏数据数据描述(准确地说是脏数据的所在的缓存页的数据描述)

另外,当某个脏缓存页被刷新到磁盘后,其空间就腾出来了,然后又会跑到 Free 链表中了。

7、LRU链表

如果系统一直在进行数据库的增删改操作,数据库内部的基本流程就是:

我们还拿 redis 类做类比,以便更好的帮助大家明白其原理。Flush 的作用其实类似 redis 的 key 设置的过期时间,所以一般情况下,redis 内存不会不够使用,但是总有特殊的情况,问题往往就是在这种极端和边边角角的情况下产生的。

如果 redis 的内存不够使用了,是不是自己还有一定的淘汰策略?最基本的准则就是淘汰掉不经常使用到的key。Buffer Pool 也类似,它也会有内存不够使用的情况,它是通过 LRU 链表来维护的。LRU 即 Least Recently Uesd(最近最少使用)。

MySql 会把最近使用最少的缓存页数据刷入到磁盘去,那 MySql 如何判断出 LRU 数据的呢?为此 MySql 专门设计了 LUR 链表,还引入了另一个概念:缓存命中率

# 缓存命中率
可以理解为缓存被使用到的频率,举个例子来说:现在有两个缓存页,在100次请求中A缓存页被命中了20次,B缓存页被命中了2次,很显然A缓存页的命中率更高,这也就意味着A在未来还会被使用到的可能性比较大,而B就会被 MySQL 认为基本不会被使用到;

说到这里,那LRU究竟是怎么工作的。假设 MySQL在将数据加载到缓存池的时候,他会将被加载进来的缓存页按照被加载进来的顺序插入到LRU链表的头部(就是链表的头插法),假设 MySQL现在先后分别加载A、B、C数据页到缓存页A、B、C中,然后 LRU 的链表大致是这样子的。

现在又来了一个请求,假设查询到的数据是已经被缓存在缓存页B中,这时候 MySQL就会将B缓存页对应的描述信息插入到LRU链表的头部,如下图:

然后又来了一个请求,数据是已经被缓存在了缓存页C中,然后LRU会变成这样子:

说到底,每次查询数据的时候如果数据已经在缓存页中,那么就会将该缓存页对应的描述信息放到LRU链表的头部,如果不在缓存页中,就去磁盘中查找,如果查找到了,就将其加载到缓存中,并将该数据对应的缓存页的描述信息插入到LRU链表的头部。也就是说最近使用的缓存页都会排在前面,而排在后面的说明是不经常被使用到的。

最后,如果 Buffer Pool 不够使用了,那么 MySQL就会将 LRU 链表中的尾节点刷入到磁盘中,用来给 Buffer Pool 腾出内存空间。来个整体的流程图给大家看下

8、LRU链表带来的麻烦

这里的麻烦指的是就是 MySQL本身的预读机制带来的问题

# 预读机制
MySQL 在从磁盘加载数据的的时候,会将数据页的相邻的其他的数据页也加载到缓存中。

# MySQL 为什么要这么做
因为根据经验和习惯,一般查询数据的时候往往还会查询该数据相邻前后的一些数据,有人可能会反问:一个数据页上面不是就会存在该条数据相邻的数据吗?这可不一定,某条数据可能很大,也可能这条数据是在数据页在头部,也可能是在数据页的尾部,所以 MySQL 为了提高效率,会将某个数据页的相邻的数据页也加载到缓存池中。

上图能够看到B的相邻也被加载到了C描述数据的前面,而实际上C的命中率比B的相邻页高多了,这就是LRU本身带来的问题。

# 哪些情况会触发预读机制
1. 有一个参数是 innodb_read_ahead_threshold, 他的默认值是56,意思就是如果顺序的访问了一个区里的多个数据页,访问的数据页的数量超过了这个阈值,此时就会触发预读机制,把下一个相邻区中的所有数据页都加载到缓存里去(这种就是:线性预读)

2. 如果 Buffer Pool 里缓存了一个区里的13个连续的数据页,而且这些数据页都是比较频繁会被访问的,此时就会直接触发预读机制,把这个区里的其他的数据页都加载到缓存里去(这种就是:随机预读)随机预读是通过:innodb_random_read_ahead 来控制的,默认是OFF即关闭的(MySQL 5.5已经基本飞起该功能,应为他会带来不必要的麻烦,这里也不推荐大家开启,说出来的目的是让大家了解下有这么个东西)

还有一种情况是 SELECT * FROM students 这种直接全表扫描的,会直接加载表中的所有的数据到缓存中,这些数据基本是加载的时候查询一次,后面就基本使用不到了,但是加载这么多数据到链表的头部就将其他的经常命中的缓存页直接全挤到后面去了。

以上种种迹象表明,预读机制带来的问题还是蛮大的,既然这么大,那 MySQL为什么还要进入预读机制呢,说到底还是为了提高效率,**一种新的技术的引进,往往带来新的挑战**,下面我们就一起来看下 MySQL是如何解决预加载所带来的麻烦的。

9、基于冷热数据分离的LRU链表

所谓的冷热分离,就是将 LRU 链表分成两部分,一部分是经常被使用到的热数据,另一部分是被加载进来但是很少使用的冷数据。通过参数innodb_old_blocks_pct 参数控制的,默认为37,也就是 37% 。用图表示大致如下:

数据在从磁盘被加载到缓存池的时候,首先是会被放在冷数据区的头部,然后在一定时间之后,如果再次访问了这个数据,那么这个数据所在的缓存页对应描述数据就会被放转移到热数据区链表的头部。

那为什么说是在一定的时间之后呢,假设某条数据刚被加载到缓存池中,然后紧接着又被访问了一次,这个时候假设就将其转移到热数据区链表的头部,但是以后就再也不会被使用了,这样子是不是就还是会存在之前的问题呢?

所以 MySQL通过innodb_old_blocks_time来设置数据被加载到缓存池后的多少时间之后再次被访问,才会将该数据转移到热数据区链表的头部,该参数默认是1000单位为:毫秒,也就是1秒之后,如果该数据又被访问了,那么这个时候才会将该数据从 LRU 链表的冷数据区转移到热数据区。

现在再回头看下上面的问题

# 通过预加载(加载相邻数据页)进来的数据
1. 这个时候就很好理解了,反正数据会被放在LRU链表的冷数据区的(注意:这里说的放在链表中的数据都是指的是<缓存页中的数据所对应的描述数据>),当在指定时候之后,如果某些缓存页被访问了那么就将该缓存页的描述数据放到热数据区链表的头部

# 全表扫描加载进来的数据页
1. 和上面一样,数据都是先在冷数据区,然后在一定时间之后,再次被访问到的数据页才会转移到热数据区的链表的头结点,所以这也就很好的解决了全表扫描所带来的问题

再来思考下 Buffer Pool 内存不够的问题

#  Buffer Pool 内存空间不够使用了怎么办?也就是说没有足够使用的空闲的缓存页了。
1. 这个问题在这个时候就显得非常简单了,直接将链表冷数据区的尾节点的描述数据多对应的缓存页刷到磁盘即可。

但是这样子还不是足够完美,为什么这么说,刚刚我们一直在讨论的是冷数据区的数据被访问,然后在一定规则之下会被加载到热数据链表的头部,但是现在某个请求需要访问的数据就在热数据区,那是不是直接把该数据所在的缓存页对应的描述数据转移到热数据区链表头部呢?

很显然不是这样子的,因为热数据区的数据本身就是会被频繁访问的,这样子如果每次访问都去移动链表,势必造成性能的下降(影响再小极端情况下也可能会不可控),所以 MySQL针对热数据区的数据的转移也有相关的规则。

该规则就是:如果被访问的数据所在的缓存页在热数据区的前25%,那么该缓存页对应的描述数据是不会被转移到热数据链表的头部的,只有当被访问的缓存页对应的描述数据在热数据区链表的后75%,该缓存页的描述数据才会被转移到热数据链表的头部

举个例子来说,假设热数据区有100个缓存页(这里的缓存页还是指的是缓存页对应的描述数据,再强调下,链表中存放的是缓存页的描述数据,为了方便有时候会直接说缓存页。希望朋友们注意),当被访问的缓存页在前25个的时候,热数据区的链表是不会有变化的,当被访问的缓存页在26~100(也就是数据在热数据区链表的后75%里面)的时候,这个时候被访问的缓存页才会被转移到链表的头部。

到此为止, MySQL对于LUR 链表的优化就堪称完美了。是不是看到这里瞬间感觉很多东西都明朗了,好了,对于 LRU 链表我们就讨论到这里了。

10、Buffer Pool 中的链表小结

# free链表
用来存放空闲的缓存页的描述数据,如果某个缓存页被使用了,那么该缓存页对应的描述数据就会被从free链表中移除

# flush链表
被修改的脏数据都记录在 Flush 中,同时会有一个后台线程会不定时的将 Flush 中记录的描述数据对应的缓存页刷新到磁盘中,如果某个缓存页被刷新到磁盘中了,那么该缓存页对应的描述数据会从 Flush 中移除,同时也会从LRU链表中移除(因为该数据已经不在 Buffer Pool 中了,已经被刷入到磁盘,所以就也没必要记录在 LRU 链表中了),同时还会将该缓存页的描述数据添加到free链表中,因为该缓存页变得空闲了。

# LRU链表
数据页被加载到 Buffer Pool 中的对应的缓存页后,同时会将缓存页对应的描述数据放到 LRU 链表的冷数据的头部,当在一定时间过后,冷数据区的数据被再次访问了,就会将其转移到热数据区链表的头部,如果被访问的数据就在热数据区,那么如果是在前25%就不会移动,如果在后75%仍然会将其转移到热数据区链表的头部

后台线程将冷数据区的尾节点的描述数据对应的缓存页刷入磁盘文件中

11、Buffer Pool 的并发性能

我们平时的系统绝对不可能每次只有一个请求来访问的,说白了就是如果多个请求同时来执行增删改,那他们会并行的去操作 Buffer Pool 中的各种链表吗?如果是并行的会不会有什么问题。

实际上 MySQL在处理这个问题的时候考虑的非常简单,就是: Buffer Pool 一次只能允许一个线程来操作,一次只有一个线程来执行这一系列的操作,因为MySQL 为了保证数据的一致性,操作的时候必须缓存池加锁,一次只能有一个线程获取到锁

这个时候,大家这时候肯定满脑子问号。串行那还谈什么效率?大家别忘记了,这一系列的操作都是在内存中操作的,实际上这是一个瞬时的过程,在内存中的操作基本是几毫秒的甚至微妙级别的事情。

但是话又说回来,串行执行再怎么快也是串行,虽然不是性能瓶颈,这还有更好的优化办法吗?那肯定的 MySQL早就设计好了这些规则。那就是 Buffer Pool 是可以有多个的,可以通过 MySQL的配置文件来配置,参数分别是:

#  Buffer Pool  的总大小
innodb_buffer_pool_size=8589934592
# Buffer Pool 的实例数(个数)
innodb_buffer_pool_instance=4

一般在生产环境中,在硬件不紧张的情况下,建议使用此策略。这个时候大家是不是又会有一个疑问(如果没有那说明你没认真思考哦),大家应该有这样的疑问:

# 问:多个 Buffer Pool 所带来的问题思考
在多个线程访问不同的 Buffer Pool 那不同的线程加载的数据必然是在不同的 Buffer Pool 中,假设 A 线程加载数据页A到 Buffer Pool A 中,B 线程加载数据页B到 Buffer Pool B 中,然后两个都执行完了,这个时候 C 线程来了,他到达的是 Buffer Pool B中,但是 C 要访问的数据是在 Buffer Pool A中的数据页上了,这个时候 C 还会去加载数据页A吗?,这种情况会发生吗?在不同的 Buffer Pool 缓存中会去缓存相同的数据页吗?

# 答:多个 Buffer Pool 所带来的问题解答
这种情况很显然不会发生,既然不会发生,那 MySql 是如何解决这种问题的?其实前面已经提到过了,那就是 数据页缓存哈希表(看下图),里面存放的是表空间号+数据页号 = 缓存页地址,所以 MySQL 在加载数据所在的数据页的时候根据这一系列的映射关系判断数据页是否被加载,被加载到了那个缓存页中,所以 MySQL 能够精确的确定某个数据页是否被加载,被加载的到了哪个缓存页,绝不可能出现重复加载的情况。

12、动态调整 Buffer Pool 的大小

到此为止,本文已经详细的介绍了 Buffer Pool 的内存结构,它的数据是如何存放的,如何刷磁盘的,又是如何加载的,以什么样的形式存在的等等知识点,下面我们继续挖掘,将 Buffer Pool 的相关知识点一次说个够。我们现在来讨论下 Buffer Pool 的大小能否动态调整。

假设我们现在的 Buffer Pool 的大小是 2GB大小,现在想将其扩大到 4GB,现在说一下如果真的要这么做,我们的 MySq 需要做哪些事情。首先 ,MySQL 需要向操作系统申请一块大小为 4G 的连续的地址连续的内存空间,然后将原来的 Buffer Pool 中的数据拷贝到新的 Buffer Pool 中。

这样可能吗?如果原来的是8G,扩大到 16G,那这个将原来的数据复制到新的 Buffer Pool 中是不是极为耗时的,所以这样的操作 MySQL必然是不支持的。但实际上这样的需求是客观存在的,那 MySQL是如何解决的呢?

为了处理这种情况,MySQL设计出 chunk (http 协议中也有使用到这个思想,所以我们会发现很多技术的优秀思想都是在相互借鉴)机制来解决的

# 什么是chunk机制
chunk是 MySQL 设计的一种机制,这种机制的原理是将 Buffer Pool 拆分一个一个大小相等的 chunk 块,每个 chunk 默认大小为 128M(可以通过参数innodb_buffer_pool_chunk_size 来调整大小),也就是说 Buffer Pool 是由一个个的chunk组成的

假设 Buffer Pool 大小是2GB,而一个chunk大小默认是128M,也就是说一个2GB大小的 Buffer Pool 里面由16个 chunk 组成,每个chunk中有自己的缓存页和描述数据,而 free 链表、flush 链表和 lru 链表是共享的

如果说有多个 Buffer Pool ,那就是这样

说到这里好像还是没有说到 MySQL到底是如何通过 chunk 机制来调整大小的。实际上是这样的,假设现在 Buffer Pool 有 2GB,里面有16个chunk,现在想要扩大到 4GB,那么这个时候只需要新申请一个个的 chunk 就可以了。

这样不但不需要申请一块很大的连续的空间,更不需要将复制数据。这样就能达到动态调整大小了(不会还有人问:这只是扩大,怎么缩小呢?gun)。不得不说 MySQL真机智。

13、生产环境如何设置 Buffer Pool 大小

Buffer Pool 是不是越大越好,理论上是的。那如果一个机器内存是16GB那分配给 Buffer Pool 15GB,这样很显然是不行的,因为操作系统要占内存,你的机器上总会运行其他的进行的吧?那肯定也是需要占用内存的。根据很多实际生产经验得出的比较合理的大小是机器内存大小的(50%~60%)。

最后一起来看看你的 INNODB 的相关参数,命令是show engine innodb status

show engine innodb status;

----------------------
Buffer Pool AND MEMORY
----------------------
-- Buffer Pool 的最终大小
Total memory allocated
-- Buffer Pool 一共有多少个缓存页
Buffer Pool size
-- free 链表中一共有多少个缓存也是可以使用的
Free buffers
-- lru链表中一共有多少个缓存页
Database pages
-- lru链表链表中的冷数据区一共有多少个缓存页
Old database pages
-- flush链表中的缓存页的数量
Modified db pages
-- 等待从磁盘上加载进来的缓存页的数量
Pending reads
-- 即将从lru链表中刷入磁盘的数量,flush链表中即将刷入磁盘的缓存页的数量
Pending writes: LRU 0, flush list 0, single page 0
-- lru链表的冷数据区的缓存页被访问之后转移到热数据区的缓存页的数量,以及冷数据区里1s之内被访问但是没有进入到热数据区的缓存页的数量
Pages made young 260368814, not young 0
-- 每秒从冷数据转移到热数据区的缓存页的数量,以及每秒在冷数据区被访问但是没有进入热数据区的缓存页的数量
332.69 youngs/s, 0.00 non-youngs/s
-- 已经读取创建和写入的缓存页的数量,以及每秒读取、创建和写入的缓存页的数量
Pages read 249280313, created 1075315, written 32924991 359.96 reads/s, 0.02 creates/s, 0.23 writes/s
-- 表示1000次访问中,有多少次是命中了BufferPool缓存中的缓存页,以及每1000次访问有多少数据从冷数据区转移到热数据区,以及没有转移的缓存页的数量
Buffer Pool hit rate 867 / 1000, young-making rate 123 / 1000 not 0 / 1000
-- lru链表中缓存页的数量
LRU len: 8190
-- 最近50s读取磁盘页的总数,cur[0]表示现在正在读取的磁盘页的总数
I/O sum[5198]:cur[0],

14、结束语

本篇文章我们详细讨论了 Buffer Pool 的内存结构,从 free 链表到 lru 链表,从 Buffer Pool 到 chunk,从磁盘中加载一个数据页到 Buffer Pool 到最后该数据页又被刷回到磁盘中的一整个过程,他的每一步都做了什么。

我们一起讨论完本文以后,是不是瞬间有种看透来了 MySQL的感觉,但是这个仅仅是前提,学习这些的目的是为了更好的理解 MySQL让我们能够在工作中更加游刃有余地使用它。因为只有在知道了底层原理的情况下,才能熟悉他的工作原理,遇到问题才能对症下药。

推荐阅读:
一个空格引发的“惨案“
“坑爹”排行榜:Java语言最违反常识的功能点TOP 10
我是一个Java类(必看,附带精彩吐槽)
炸裂!MySQL 82 张图带你飞!
面试官留步!听我跟你侃会儿Docker原理
顺丰快递:请签收MySQL灵魂十连

互联网全栈架构

浏览 15
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报