1. Buffer Pool 概述
Buffer Pool 到底是什么?从字面上看是缓存池的意思,没错,它其实也就是缓存池的意思。它是MySQL当中至关重要的一个组件,可以这么说,MySQL的所有的增删改的操作都是在 Buffer Pool 中执行的。
但是数据不是在磁盘中的吗?怎么会和缓存池又有什么关系呢?大家都知道MySQL数据其实是放在磁盘里面的,从磁盘里面查询数据那肯定需要IO,并且数据库并不知道它将要查找的数据是磁盘的哪个位置,所以这就需要进行随机IO,这样势必会很影响性能。所以一定是先把数据从磁盘中取出,然后放在内存中,下次查询直接从内存中来取,这也就是本文所要记录的Buffer Pool组件。
本篇文章,会详细的介绍 Buffer Pool 的内存结构,让大家彻底明白这里面的每一步执行流程。我们先来看一个总体的流程图,从数据在磁盘中被加载到缓存池中,然后经过一系列的操作最终又被刷入到磁盘的一个过程,都经历了哪些事情。
看完这张图后,大家是不是感觉很熟悉,这不就是执行更新语句的大致流程嘛。(图画的有点简陋,binlog 也是需要经过page cache的,然后在刷盘)由上可见,数据库一系列增删改的操作都是在Buffer Pool中直接完成的,然后在进行脏数据的刷盘。
2. Buffer Pool 内存数据结构
从上文我们可以知道在执行增删改操作的时候数据是会被加载到Buffer Pool 中的,那这些数据到底是以什么形式加载进来的呢?接下来我们就先来了解下相关的内存数据结构。
2.1 数据页
我们在数据库操作的数据都是以表 + 行的方式,而表 + 行仅仅是逻辑上的概念,MySQL并不会像我们一样去操作行数据,而是抽象出来一个一个的数据页概念。磁盘中存放了很多数据页,每个数据页里面存放了很多行数据。MySQL在执行增删改首先会定位到这条数据所在的数据页,然后会将数据所在的数据页加载到 Buffer Pool 中。
每个数据页的大小默认是 16KB,这些参数都是可以调整的。但是建议使用默认的就好,毕竟 MySQL能做到极致的都已经做了。
2.2 缓存页
当数据页被加载到缓冲池中后,Buffer Pool 中也有叫缓存页的概念与其一一对应,大小同样是 16KB,但是 MySQL还为每个缓存也页开辟额外的一些空间,用来描述对应的缓存页的一些信息,例如:数据页所属的表空间,数据页号,这些描述数据块会额外占据一些空间。
那这些缓存页是什么时候创建的呢?实际上当 MySQL启动的时候,就会初始化 Buffer Pool,这个时候 MySQL 会根据系统中设置的 innodb_buffer_pool_size 大小去内存中申请一块连续的内存空间,这个内存区域比配置的值稍微大一些,因为描述数据也是占用一定的内存空间的。当在内存区域申请完毕之后, MySQL会将内存区域划分为一个个的缓存页和对应的描述数据。
3. Page 和 链表
上面说了每个数据页会被加载到一个缓存页中,那MySQL是怎么区分哪些缓存页是空闲的状态,是可以用来存放数据页的呢?这时候就需要说到Buffer Pool中的三种Page和链表了。
3.1 Page
首先我们先来了解下Page的概念,在Buffer Pool中有如下三种Page,用来区分缓存页的状态。
-
Free Page(空闲页)
表示此Page 未被使用,位于 Free 链表。
-
Clean Page(干净页)
此Page 已被使用,但是页面未发生修改,位于LRU 链表。
-
Dirty Page(脏页)
此Page 已被使用,页面已经被修改,其数据和磁盘上的数据已经不一致。当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该Page 就变成了干净页。脏页 同时存在于LRU 链表和Flush 链表。
3.2 链表
3.2.1 Free 链表
什么是free链表呢?它是 MySQL 为 Buffer Pool 设计的一个双向链表,它的作用就是用来保存空闲缓存页的描述块,或者可以这么理解,每个空闲缓存页的描述数据块组成了一个双向链表,这个链表就是free链表。
另外 free 链表还会有一个基础节点,它会引用该链表的头结点和尾结点,还会记录节点的个数(也就是可用的空闲的缓存页的个数)。
大致如下所示:
当加载数据页到缓存池中的时候, MySQL会从 free 链表中获取一个描述数据的信息,根据描述节点的信息拿到其对应的缓存页,然后将数据页信息放到该缓存页中,同时将链表中的该描述数据的节点移除。这就是数据页被读取到 Buffer Pool 中的缓存页的过程。
但 MySQL是怎么知道哪些数据页已经被缓存了,哪些没有被缓存呢。实际上数据库中还有一个哈希表结构,它通过存储表空间号 + 数据页号作为key,缓存页对应的地址作为其value,这样数据页在加载的时候就会通过哈希表中的key来确定数据页是否被缓存了。
3.2.2 Flush 链表
上面我们已经知道Free 链表是用来保存空闲缓存页的,那MySQL是如何判断那些缓存页是脏页,从而进一步进行刷盘的呢?
针对这个问题,MySQL设计出了 Flush 链表,它的作用就是记录被修改过的脏数据所在的缓存页对应的描述数据。结构和 Free 链表是相同的。
另外,当某个脏页被刷新到磁盘后,其空间就腾出来了,然后又会跑到 Free 链表中了。
3.2.2 LRU链表
如果系统一直在进行数据库的增删改操作,数据库内部的基本流程就是:
我们这里用 redis 做类比,以便更好的帮助大家明白其原理。
如果 redis 的内存不够使用了,是不是有相应的淘汰策略?最基本的准则就是淘汰掉不经常使用到的key。Buffer Pool 也类似,它也会有内存不够使用的情况,它是通过 LRU 链表来维护的。LRU 即 Least Recently Uesd(最近最少使用)。
LRU相信大家应该都了解,这里就不画图描述了。简而言之,就是每次查询数据的时候如果数据已经在缓存页中,那么就会将该缓存页对应的描述信息放到LRU链表的头部,如果不在缓存页中,就去磁盘中查找,如果查找到了,就将其加载到缓存中,并将该数据对应的缓存页的描述信息插入到LRU链表的头部。也就是说最近使用的缓存页都会排在前面,而排在后面的说明是不经常被使用到的。
最后,如果 Buffer Pool 不够使用了,那么 MySQL就会将 LRU 链表中的尾节点刷入到磁盘中,为 Buffer Pool 腾出内存空间。
但是LRU也存在一定的问题,针对这些问题,我们先来了解下MySQL的预读机制。
4. MySQL 预读机制
预读是Mysql提高性能的一个重要的特性。预读 就是IO 异步将多个数据页读入 Buffer Pool 的一个过程,并且这些页被认为是很快就会被读取到的。InnoDB使用两种预读算法来提高I/O性能:线性预读和随机预读。
为了区分这两种预读的方式,我们可以把线性预读放到以extent(64个page为一个extent)为单位,而随机预读放到以extent中的page为单位。线性预读着眼于将下一个extent提前读取到buffer pool中,而随机预读着眼于将当前extent中的剩余的page提前读取到buffer pool中。
4.1 Linear线性预读
线性预读的单位是extend,一个extend中有64个page。线性预读的一个重要参数是innodb_read_ahead_threshold,是指在连续访问多少个页面之后,把下一个extend读入到buffer pool中,不过预读是一个异步的操作。当然这个参数不能超过64,因为一个extend最多只有64个页面。
例如,innodb_read_ahead_threshold = 56,就是指在连续访问了一个extend的56个页面之后把下一个extend读入到buffer pool中。
4.2 Random随机预读
随机预读方式则是表示当同一个extent中的一些page在buffer pool中发现时,Innodb会将该extent中的剩余page一并读到buffer pool中。由于随机预读方式给innodb 带来了一些不必要的复杂性,同时在性能也存在不稳定性,在5.5中已经将这种预读方式废弃,默认是OFF。
5. Buffer Pool 空间管理
5.1 LRU 带来的问题
5.1.1 预读失效
上面我们提到了缓冲池的预读机制可能会预先加载相邻的数据页。假如加载了 20、21 相邻的两个数据页,如果只有页号为 20 的缓存页被访问了,而另一个缓存页却没有被访问。此时两个缓存页都在链表的头部,但是为了加载这两个缓存页却淘汰了末尾的缓存页,而被淘汰的缓存页却是经常被访问的。这种情况就是预读失效,被预先加载进缓冲池的页,并没有被访问到,这种情况是不是很不合理?
5.1.2 缓冲池污染
还有一种情况是当执行一条 SQL 语句时,如果扫描了大量数据或是进行了全表扫描,此时缓冲池中就会加载大量的数据页,从而将缓冲池中已存在的所有页替换出去,这种情况同样是不合理的。这就是缓冲池污染,并且还会导致 MySQL 性能急剧下降。
5.2 基于冷热数据分离的LRU链表
为了解决这种问题,于是就有了基于冷热数据分离的LRU链表。
所谓的冷热分离,就是将 LRU 链表分成两部分,一部分是经常被使用到的热数据,另一部分是被加载进来但是很少使用的冷数据。通过参数innodb_old_blocks_pct 参数控制的,默认为37,也就是 37% 。整体结构大致如下所示:
所以数据在从磁盘被加载到缓存池的时候,首先是会被放在冷数据区的头部,然后在一定时间之后,如果再次访问了这个数据,那么这个数据所在的缓存页对应描述数据就会被放转移到热数据区链表的头部。
那为什么说是在一定的时间之后呢,假设某条数据刚被加载到缓存池中,然后紧接着又被访问了一次,这个时候假设就将其转移到热数据区链表的头部,但是以后就再也不会被使用了,这样子是不是就还是会存在之前的问题呢?
所以 MySQL通过innodb_old_blocks_time
来设置数据被加载到缓存池后的多少时间之后再次被访问,才会将该数据转移到热数据区链表的头部,该参数默认是1000单位为:毫秒,也就是1秒之后,如果该数据又被访问了,那么这个时候才会将该数据从 LRU 链表的冷数据区转移到热数据区。
现在再回头看下上面的两个问题,是不是发现以及完全解决了?反正数据加载进来都在冷数据区,一定时间后访问的数据才会被移动到热数据区。
这个时候我们再来看上面提到的Buffer Pool内存不够的问题,现在是不是就觉得很简单了?我们只需要直接将冷数据区的尾节点的描述数据对应的缓存页刷到磁盘即可。
你以为LRU到这就结束了吗?不。MySQL觉得这还不够完美,刚刚我们都是在研究冷数据区,在一定规则下将冷数据区的数据加载到热数据区的头部。那热数据区呢?热数据区的数据本来就是频繁访问的,难道每次访问我们都将其移动到热数据区头部吗?这势必会对性能有一定的影响,所以MySQL针对热数据区也有相对的规则。
该规则就是:如果被访问的数据所在的缓存页在热数据区的前25%,那么该缓存页对应的描述数据是不会被转移到热数据链表的头部的,只有当被访问的缓存页对应的描述数据在热数据区链表的后75%,该缓存页的描述数据才会被转移到热数据链表的头部。
举个例子来说,假设热数据区有100个缓存页描述数据,当被访问的缓存页在前25个的时候,热数据区的链表是不会有变化的,当被访问的缓存页在26~100(也就是数据在热数据区链表的后75%里面)的时候,这个时候被访问的缓存页才会被转移到链表的头部。
到此为止, MySQL对于LUR 链表的优化就堪称完美了。
6. 刷盘
上文我们知道内存不够的时候,会将冷数据区尾节点数据进行刷盘,那在Buffer Pool中的刷盘机制到底是怎么样的呢?
这里大家可能会担心脏页还没刷到磁盘的时候,MySQL 宕机了,这不就丢失数据了吗?实际上这个大家完全不需要担心,我们更新数据的时候会将相关操作记录在redo log日志中,通过 redo log 日志从而让 MySQL 拥有了崩溃恢复的能力。
接下来我们看下哪几种情况会触发脏页的刷新:
- 当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘;
- Buffer Pool 空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘;
- MySQL 认为空闲时,后台线程回定期将适量的脏页刷入到磁盘;
- MySQL 正常关闭之前,会把所有的脏页刷入到磁盘;
在我们开启了慢 SQL 监控后,如果你发现**「偶尔」会出现一些用时稍长的 SQL**,这可能是因为脏页在刷新到磁盘时可能会给数据库带来性能开销,导致数据库操作抖动。如果经常出现这种现象,就需要调大 Buffer Pool 空间或 redo log 日志的大小。
7. Buffer Pool 配置相关
7.1 并发性能
我们平时的系统绝对不可能每次只有一个请求来访问的,说白了就是如果多个请求同时来执行增删改,那他们会并行的去操作 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中呢?
实际上在上文将free链表的时候我们已经给出答案了,那就是通过数据页缓存哈希表。根据表空间号+数据页号,我们就可以得到具体的缓存页地址了。
7.2 动态调整Buffer Pool大小
我们现在来讨论下 Buffer Pool 的大小能否动态调整。
假设我们现在的 Buffer Pool 的大小是 2GB大小,现在想将其扩大到 4GB,现在说一下如果真的要这么做,我们的 MySq 需要做哪些事情。首先 ,MySQL 需要向操作系统申请一块大小为 4G 的连续的地址连续的内存空间,然后将原来的 Buffer Pool 中的数据拷贝到新的 Buffer Pool 中。
这样可能吗?如果原来的是8G,扩大到 16G,那这个将原来的数据复制到新的 Buffer Pool 中是不是极为耗时的,所以这样的操作 MySQL必然是不支持的。但实际上这样的需求是客观存在的,那 MySQL是如何解决的呢?
为了处理这种情况,MySQL设计出 chunk 机制来解决。那什么是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 链表是共享的。
那么说到这里大家应该都知道MySQL如何通过 chunk 机制来调整大小了吧?需要扩大的时候我们只需要新申请一个个的 chunk 就可以了。
这样不但不需要申请一块很大的连续的空间,更不需要将复制数据。这样就能达到动态调整大小了。
8. 总结
本篇文章我们详细讨论了 Buffer Pool 的内存结构,从 free 链表到 lru 链表,从 Buffer Pool 到 chunk,从磁盘中加载一个数据页到 Buffer Pool 到最后该数据页又被刷回到磁盘中的一整个过程,他的每一步都做了什么。
最后我们再来回顾下三个链表:
-
Free链表
用来存放空闲的缓存页的描述数据,如果某个缓存页被使用了,那么该缓存页对应的描述数据就会被从free链表中移除。
-
Flush链表
被修改的脏数据都记录在 Flush 中,同时会有一个后台线程会不定时的将 Flush 中记录的描述数据对应的缓存页刷新到磁盘中,如果某个缓存页被刷新到磁盘中了,那么该缓存页对应的描述数据会从 Flush 中移除,同时也会从LRU链表中移除(因为该数据已经不在 Buffer Pool 中了,已经被刷入到磁盘,所以就也没必要记录在 LRU 链表中了),同时还会将该缓存页的描述数据添加到free链表中,因为该缓存页变得空闲了。
-
LRU链表
数据页被加载到 Buffer Pool 中的对应的缓存页后,同时会将缓存页对应的描述数据放到 LRU 链表的冷数据的头部,当在一定时间过后,冷数据区的数据被再次访问了,就会将其转移到热数据区链表的头部,如果被访问的数据就在热数据区,那么如果是在前25%就不会移动,如果在后75%仍然会将其转移到热数据区链表的头部。