数据库Buffer Pool“缓存池”是什么?有多大?
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 这个整体的流程就是让大家稍微有个印象。
# 查看和调整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 中的整体结构大概是这样子的
# 缓存页是什么时候被创建的?
当 MSql 启动的时候,就会初始化 Buffer Pool,这个时候 MySQL 会根据系统中设置的 innodb_buffer_pool_size 大小去内存中申请一块连续的内存空间,实际上在这个内存区域比配置的值稍微大一些,因为【描述数据】也是占用一定的内存空间的,当在内存区域申请完毕之后, MySql 会根据默认的缓存页的大小(16KB)和对应`缓存页*15%`大小(800B左右)的数据描述的大小,将内存区域划分为一个个的缓存页和对应的描述数据
复制代码
为了解决这个问题, MySQL 为 Buffer Pool 设计了一个双向链表— free链表,这个 free 链表的作用就是用来保存空闲缓存页的描述块(这句话这么说其实不严谨,换句话:每个空闲缓存页的描述数据组成一个双向链表,这个链表就是free链表)。之所以说free链表的作用就是用来保存空闲缓存页的描述数据是为了先让大家明白 free 链表的作用,另外 free 链表还会有一个基础节点,他会引用该链表的头结点和尾结点,还会记录节点的个数(也就是可用的空闲的缓存页的个数)。
这个时候,他可以用下面的图片来描述:
当加载数据页到缓存池中的时候, MySQL会从 free 链表中获取一个描述数据的信息,根据描述节点的信息拿到其对应的缓存页,然后将数据页信息放到该缓存页中,同时将链表中的该描述数据的节点移除。这就是数据页被读取 Buffer Pool 中的缓存页的过程。
但 MySQL是怎么知道哪些数据页已经被缓存了,哪些没有被缓存呢。实际上数据库中还有后一个哈希表结构,他的作用是用来存储表空间号 + 数据页号作为数据页的key,缓存页对应的地址作为其value,这样数据在加载的时候就会通过哈希表中的key来确定数据页是否被缓存了。
针对这个问题,MySQL设计出了 Flush 链表,他的作用就是记录被修改过的脏数据所在的缓存页对应的描述数据。如果内存中的数据和数据库和数据库中的数据不一样,那这些数据我们就称之为脏数据,脏数据之所以叫脏数据,本质上就是被缓存到缓存池中的数据被修改了,但是还没有刷新到磁盘中。
同样的这些已经被修改了的数据所在的缓存页的描述数据会被维护到 Flush 中(其实结构和 free 链表是一样的),所以 Flush 中维护的是一些脏数据数据描述(准确地说是脏数据的所在的缓存页的数据描述)
另外,当某个脏数据页页被刷新到磁盘后,其空间就腾出来了,然后又会跑到 Free 链表中了。
我们还拿 redis 类做类比,以便更好的帮助大家明白其原理。Flush 的作用其实类似 redis 的 key 设置的过期时间,所以一般情况下,redis 内存不会不够使用,但是总有特殊的情况,问题往往就是在这种极端和边边角角的情况下产生的。
如果 redis 的内存不够使用了,是不是自己还有一定的淘汰策略?最基本的准则就是淘汰掉不经常使用到的key。Buffer Pool 也类似,它也会有内存不够使用的情况,它是通过 LRU 链表来维护的。LRU 即 Least Recently Uesd(最近最少使用)。
MySql 会把最近使用最少的缓存页数据刷入到磁盘去,那 MySql 如何判断出 LRU 数据的呢?为此 MySql 专门设计了 LRU 链表,还引入了另一个概念:缓存命中率
# 缓存命中率
可以理解为缓存被使用到的频率,举个例子来说:现在有两个缓存页,在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 腾出内存空间。来个整体的流程图给大家看下
# 预读机制
MySQL 在从磁盘加载数据的的时候,会将数据页的相邻的其他的数据页也加载到缓存中。
1. 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是如何解决预加载所带来的麻烦的。
数据在从磁盘被加载到缓存池的时候,首先是会被放在冷数据区的头部,然后在一定时间之后,如果再次访问了这个数据,那么这个数据所在的缓存页对应描述数据就会被放转移到热数据区链表的头部。
那为什么说是在一定的时间之后呢,假设某条数据刚被加载到缓存池中,然后紧接着又被访问了一次,这个时候假设就将其转移到热数据区链表的头部,但是以后就再也不会被使用了,这样子是不是就还是会存在之前的问题呢?
所以 MySQL通过innodb_old_blocks_time来设置数据被加载到缓存池后的多少时间之后再次被访问,才会将该数据转移到热数据区链表的头部,该参数默认是1000单位为:毫秒,也就是1秒之后,如果该数据又被访问了,那么这个时候才会将该数据从 LRU 链表的冷数据区转移到热数据区。
现在再回头看下上面的问题
# 通过预加载(加载相邻数据页)进来的数据
1. 这个时候就很好理解了,反正数据会被放在LRU链表的冷数据区的(注意:这里说的放在链表中的数据都是指的是),当在指定时候之后,如果某些缓存页被访问了那么就将该缓存页的描述数据放到热数据区链表的头部
1. 全表扫描加载进来的数据页
1. 和上面一样,数据都是先在冷数据区,然后在一定时间之后,再次被访问到的数据页才会转移到热数据区的链表的头结点,所以这也就很好的解决了全表扫描所带来的问题
复制代码
再来思考下 Buffer Pool 内存不够的问题
# Buffer Pool 内存空间不够使用了怎么办?也就是说没有足够使用的空闲的缓存页了。
1. 这个问题在这个时候就显得非常简单了,直接将链表冷数据区的尾节点的描述数据多对应的缓存页刷到磁盘即可。
复制代码
但是这样子还不是足够完美,为什么这么说,刚刚我们一直在讨论的是冷数据区的数据被访问,然后在一定规则之下会被加载到热数据链表的头部,但是现在某个请求需要访问的数据就在热数据区,那是不是直接把该数据所在的缓存页对应的描述数据转移到热数据区链表头部呢?
很显然不是这样子的,因为热数据区的数据本身就是会被频繁访问的,这样子如果每次访问都去移动链表,势必造成性能的下降(影响再小极端情况下也可能会不可控),所以 MySQL针对热数据区的数据的转移也有相关的规则。
该规则就是:如果被访问的数据所在的缓存页在热数据区的前25%,那么该缓存页对应的描述数据是不会被转移到热数据链表的头部的,只有当被访问的缓存页对应的描述数据在热数据区链表的后75%,该缓存页的描述数据才会被转移到热数据链表的头部。
举个例子来说,假设热数据区有100个缓存页(这里的缓存页还是指的是缓存页对应的描述数据,再强调下,链表中存放的是缓存页的描述数据,为了方便有时候会直接说缓存页。希望朋友们注意),当被访问的缓存页在前25个的时候,热数据区的链表是不会有变化的,当被访问的缓存页在26~100(也就是数据在热数据区链表的后75%里面)的时候,这个时候被访问的缓存页才会被转移到链表的头部。
到此为止, MySQL对于LUR 链表的优化就堪称完美了。是不是看到这里瞬间感觉很多东西都明朗了,好了,对于 LRU 链表我们就讨论到这里了。