深度解密 MySQL 的 Buffer Pool

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS PostgreSQL,集群系列 2核4GB
简介: 深度解密 MySQL 的 Buffer Pool


楔子




我们前面介绍了 Buffer Pool,它本质上就是一片内存,里面存储了 MySQL 的表数据。并且这片内存是可配置的,因为它不可能无限大,默认是 128MB。

但很明显,在线上环境 128MB 有点小了,如果你的机器是 16GB 内存,或者更高,那么可以将它设置成 2GB。

[server]
innodb_buffer_pool_size = 2147483648

当然这些配置啥的都比较简单,网上一搜就出来了,我们本篇文章的重点是来探究一下 Buffer Pool 是以什么样的方式组织数据的?


MySQL 的数据页




我们知道数据库的数据存储模型是
表+字段+行,一个数据库里面可以有很多的表,一张表可以有很多行数据,每行数据都有自己的字段值。那么问题来了,MySQL 的数据是一行一行地加载到 Buffer Pool 里面的吗?

答案不是的,实际上 MySQL 对数据抽象出来了一个数据页的概念,它是把很多行数据放在了一个数据页里。因此磁盘文件中有很多的数据页,每一页数据里放了很多行数据,如下图所示。


所以 MySQL 操作数据是以页为单位的,假设我们要更新一行数据,此时 MySQL 会找到这行数据所在的数据页,然后从磁盘文件里把这行数据所在的数据页加载到Buffer Pool。

也就是说,Buffer Pool 中存放的是一个一个的数据页。


当然,读取数据页的时候不一定只读取一页,也可能会一次性读取很多页。

而一个数据页是 16KB,也就是说一个数据页包含了 16KB 的数据。而数据页读到 Buffer Pool 之后,被称为缓存页,毕竟 Buffer Pool 是一个位于内存当中的组件,当然数据页和缓存页在大小上是对应的,都是 16KB。


缓存页的描述信息




那么问题来了,Buffer Pool 里面难道只有缓存页吗?显然不是的,事实上基本任何一个组件,在存储数据的时候都不会只存储数据,一定还额外存储了数据的元信息。

对于 MySQL 的 Buffer Pool 也不例外,存储在里面的每一个缓存页都有一个描述信息,这个描述信息就是用来描述缓存页的。比如:对应的数据页所属的表空间、页号,以及当前缓存页在 Buffer Pool 中的地址以及别的一些信息。

所以每个缓存页都会对应一个描述信息,这个描述信息本身也是一块数据,存储在 Buffer Pool 中,并且每个缓存页的描述信息放在最前面,然后各个缓存页放在后面。


而且我们需要注意一点,Buffer Pool 中的描述信息也是一块数据,大概相当于缓存页大小的 5% 左右,也就是大概 800 字节。因此 Buffer Pool 真正的最终大小会比配置的大小多一些,如果你的 Buffer Pool 大小配置为默认值 128MB,那么实际大概会占 130 多 MB,因为它里面还要存放每个缓存页的描述信息。


Buffer Pool 的初始化




Buffer Pool 的结构我们知道了,那么它是如何被初始化的呢?很简单,当 MySQL 启动之后,就会按照配置文件中设置的 Buffer Pool 大小,稍微再加大一点,去向操作系统申请一块内存,然后用作 Buffer Pool 的内存区域。

当内存区域申请完毕之后,数据库就会按照默认的缓存页大小 16KB、以及描述信息的大小 800字节,在 Buffer Pool 中划分出一个一个的缓存页和对应的描述信息。

当划分完毕之后,Buffer Pool 就初始化完成了,只不过此时 Buffer Pool 中的缓存页都是空的,里面什么都没有。要等到对数据执行增删改查等操作的时候,才会把数据对应的页从磁盘文件里读取出来,然后放入 Buffer Pool 中的缓存页中。


空闲的缓存页




当数据库运行起来之后,肯定会不断执行增删改查操作,此时就需要从磁盘上读取一个一个的数据页放入 Buffer Pool 的缓存页里去,把数据缓存起来,之后在内存里对数据执行增删改查。

但是从磁盘读取数据页放入 Buffer Pool 中的缓存页的时候,这个缓存页必然是空的,也就是处于空闲状态;如果缓存页已使用的话,那么数据不就被覆盖掉了吗?因此这就涉及到一个问题,MySQL 怎么知道哪些缓存页是空闲的?哪些缓存页是已使用的呢?

所以 MySQL 会为 Buffer Pool 设计一个 free 链表,这是一个双向链表数据结构,在 free链表里,每个节点就是一个空闲缓存页的描述数据块。也就是说,只要缓存页是空闲的,那么它对应的描述数据块就会被放入这个 free 链表中。

而数据库刚启动的时候,所有的缓存页都是空闲的,因为此时可能一条数据都没有。因此所有缓存页的描述数据块,都会被放入这个 free 链表中。

描述信息也是一块数据,所以也可以叫它描述数据块。


可以看到上面出现了一个 free 链表,这个 free 链表里面就是各个缓存页的描述数据块。只要缓存页是空闲的,那么对应的描述数据块就会加入到这个 free 链表中,每个节点都会双向链接自己的前后节点,组成一个双向链表。

除此之外,free 链表还有一个基础节点,它会引用链表的头节点和尾节点,并且里面还存储了链表中有多少个描述数据块的节点,也就是有多少个空闲的缓存页。

另外补充一下,我们上图画的容易让人产生误解,会让人觉得描述数据块在 Buffer Pool 里面有一份,在 free 链表里面又有一份,其实不是这样的。free 链表就是由 Buffer Pool 里的描述数据块组成的,你可以认为每个描述数据块里有两个指针,一个是 free_prev,一个是 free_next,分别指向自己的上一个节点,以及下一个节点。

通过 Buffer Pool 中描述数据块的 free_prev 和 free_next 两个指针,就可以把所有的描述数据块串成一个 free 链表。而这里为了画图方便,将它们单独拿了出来。

对于 free 链表而言,只有一个基础节点是不属于 Buffer Pool 的,占用 40 字节大小。里面存放了 free 链表的头节点的地址,尾节点的地址,还有 free 链表里当前有多少个节点(对应空闲的缓存页的数量)。

所以有了 free 链表,MySQL 便能快速找到一个描述数据块,进而找到对应的缓存页(空闲状态)。接着把磁盘上的数据页读取到对应的缓存页里去,同时把相关的一些描述信息写入缓存页的描述数据块里去,比如这个数据页所属的表空间之类的信息,最后再把该描述数据块从 free 链表里移除就可以了(因为它对应的缓存页不再是空闲状态)。


如何找到已使用的缓存页




前面说了,MySQL 在执行增删改查的时候,要先看数据是否在 Buffer Pool 中,也就是判断是否存在一个已使用的缓存页,存储了当前要操作的数据。如果有的话,那么直接在 Buffer Pool 里面操作即可;如果没有,那么就从磁盘中加载对应的数据页,然后基于 free 链表找到一个空闲的缓存页,再将数据页放进去。

但是问题来了,MySQL 怎么知道操作的数据有没有被缓存起来呢?所以 MySQL 内部还有一个哈希表,它会用数据页所属的表空间+数据页号作为 key,缓存页(已使用)的地址作为 value。

所以在操作数据的时候,先找到该数据对应的数据页,但是先不加载。然后以表空间+数据页号作为 key,去哈希表中查询,如果 key 存在,说明该数据页已经被缓存了;如果 key 不存在,那么再将数据页加载到缓存页中,并且把该数据页的表空间+数据页号缓存页的地址作为 key、value 添加到哈希表中。

所以当下次再操作相同的数据时,直接在 Buffer Pool 里面操作即可。


flush 链表




我们上面介绍了 free 链表,它是由未使用的缓存页的描述数据块、再加上一个基础节点组成的双向链表。如果执行增删改查的时候,发现数据页没缓存,那么必然会基于 free 链表找到一个空闲的缓存页,然后将数据页读到缓存页里去。但如果已经缓存了,那么就直接操作缓存页。

总之不管怎么样,我们都是在 Buffer Pool 里面进行增删改查操作。但是问题来了,如果 Buffer Pool 里的数据更新了,那么内存数据和磁盘数据是不是就不一致了?这时候我们就称缓存页变成了脏页,而这些脏页迟早是要被刷回磁盘的,因为要保证修改能够落盘,而这一步是由后台的 IO 线程处理的。

所以 MySQL 将数据从内存刷到磁盘,也是以页为单位的。

但是思考一个问题,MySQL 怎么判断缓存页是否被修改了呢?换句话说,MySQL 怎么判断哪些缓存页是脏页呢?因为有的缓存页虽然被使用了,但只是做一些查询操作,并没有涉及数据的更新,那么显然它就不是脏页。

所以 MySQL 引入了一个和 free 链表类似的 flush 链表,这个 flush 链表本质上也是通过描述数据块的两个指针,将所有被修改过的缓存页对应的描述数据块组合起来的双向链表。当然这个 flush 链表中也有一个基础节点,结构和 free 链表一模一样,这里就不画图了。

只不过 free 链表上的描述数据块对应的缓存页都是空闲状态,而 flush 链表上的描述数据块对应的缓存页都是已使用状态、并且还被修改了。如果某个描述数据块既不在 free 链表上、也不在 flush 链表上,那么它对应的缓存页一定是已使用状态、并且没有被修改。

所以只要是被修改过的缓存页,MySQL 都会把它的描述数据块加入到 flush 链表中,表示这些都是脏页,后续都要刷新(flush)到磁盘里。

小插曲

介绍后续内容之前,这里先提一个问题:我们在编写 SQL 语句的时候用到的都是表和行的概念,但上面又提到了表空间、数据页,那么它们之间有什么区别呢?

其实简单来讲,一个是逻辑概念,一个是物理概念。

表、列和行,都是逻辑概念,我们只知道数据库里有多少张表,表里有多少字段,有多少行。但是这些表里的数据在数据库的磁盘上如何存储的,我们是不关注的,所以它们都是逻辑上的概念。

表空间、数据页这些都是物理上的概念,实际上在物理层面,表里的数据都放在一个表空间中。表空间是由一堆磁盘上的数据文件组成的,这些数据文件里存放了你表里的数据,而这些数据是由一个一个的数据页组织起来的,这些都是物理层面的概念,这就是他们之间的区别。

所以逻辑层面和物理层面是不一样的,在使用上我们只需要关注逻辑层面即可,但想深入 MySQL 的话,就需要关注物理层面了。


缓存页的淘汰策略




当执行 CRUD 操作的时候,无论是查询数据,还是修改数据,实际上都会把磁盘上的数据页加载到空闲的缓存页里来。但是随着 MySQL 不停地加载数据页,那么空闲的缓存页是不是越来越少呢?换句话说,每加载一张数据页,那么 free 链表的节点个数就减少 1,当 free 链表已经没有描述数据块的时候,就代表已经没有空闲的缓存页了。

如果已经没有空闲缓存页了,那么要是继续加载数据页的话,该怎么办呢?

显然都能想到,如果没有空闲的缓存页了,那么就把老的缓存页淘汰掉。也就是把缓存页里被修改过的数据,给它刷到磁盘上对应的数据页里去,然后这个缓存页就可以清空了,让它重新变成一个空闲的缓存页。接着再把磁盘上的数据页,加载到刚成为空闲状态的缓存页当中。

那么问题来了,我们应该把哪个缓存页的数据刷入磁盘并清空呢?不用想,肯定是使用频率最低的。如果一个缓存页只在加载数据页的时候被访问过,之后就再也没有访问了,那么我们就可以将它刷到磁盘并清空。

但 MySQL 怎么知道哪个缓存页的访问频率高,哪个访问频率低呢?所以它引入了一个 LRU(Last Recently Used)链表,通过该链表便可得知哪些缓存页是最近最少使用的。当需要腾出来一个缓存页用于加载新的数据页的时候,直接选择那个最近最少使用的缓存页即可。

MySQL 在将数据页加载到缓存页当中时,还会将缓存页的数据描述块放到 LRU 链表中。LRU 链表和 flush 链表的结构也是类似的,但不同的是,只有缓存页的数据被修改了,它的数据描述块才会放到 flush 链表中;而 LRU 链表不同,只要缓存页有数据,就会被放到 LRU 链表中。

当从磁盘加载数据页到缓存页的时候,它的描述数据块还会放在 LRU 链表的头部。而如果某个位于 LRU 链表尾部的描述数据块对应的缓存页被查询或修改了,那么相应的描述数据块也要移动到 LRU 链表的头部。也就是说,最近被访问过的缓存页,它的描述数据块一定在 LRU 链表的头部。

那么随着缓存页的不断访问,LRU 链表尾部的描述数据块对应的缓存页一定就是最少被访问的缓存页,然后将它刷到磁盘并清空之后,就可以用于保存新加载的数据页了。当然加载之后,该缓存页就会被移动到 LRU 链表的头部,因为数据页加载到缓存页之后,肯定是要被访问的。


LRU 链表带来的问题



MySQL 在数据页淘汰上引入了 LRU 机制,看似完美的解决了问题,但实际上 LRU 链表在运行的时候是存在隐患的。

首先带来隐患的就是 MySQL 的预读机制,所谓的预读机制就是在加载某个数据页的时候,会顺带把该数据页相邻的数据页,也一起加载到缓存页中。比如现在有两个空闲的缓存页,然后在加载某个数据页的时候,会顺带把它相邻的数据页一起加载到缓存页中。

但实际上,在加载完毕之后,只有一个缓存页被访问了,另一个通过预读机制加载的缓存页并没有人访问。但此时这两个缓存页对应的描述数据块,都在 LRU 链表的前面,我们举例说明:


第 1、3、4 个数据块对应的缓存页都是要频繁被访问的,而第 2 个数据块对应的缓存页是因为预读机制而加载进来的,实际上并没有人访问。此时缓存页已经都被使用了,如果接下来要再加载一个数据页到缓存页,会发生什么现象呢?显然要清空一个缓存页,用于容纳新的数据页,但问题是清空哪一个缓存页呢?

按照之前说的 LRU 链表的逻辑,肯定是把最后一个数据块对应的缓存页清空,但很明显这是不合理的,因为它一直被访问。只不过尾部的两个缓存页因为新加载了数据页,而跑到它前面去了。特别是第二个缓存页,它是因为预读机制而加载的,实际不会有人访问。

所以此时将最后一个描述数据块对应的缓存页清空是绝对不合理的,最合理的做法是将第二个清空,毕竟它没什么人访问。那么 MySQL 是怎么做的呢?我们一会再说。

哪些情况会触发 MySQL 的预读机制

现在我们已经了解预读机制对 LRU 链表造成的隐患了,预读机制加载进来的缓存页可能根本不会有人访问,结果它的描述数据块却放在了 LRU 链表的前面,此时就可能造成频繁被访问的数据页被清空。

那么哪些情况下会触发 MySQL 的预读机制呢?

1)MySQL 的配置中有这么一个参数:innodb_read_ahead_threshold,默认值是56。意思就是如果顺序访问了一个区里的多个数据页,并且访问的数据页的数量超过了这个阈值,此时就会触发预读机制,把下一个相邻区中的所有数据页都加载到缓存页里去。

2)如果 Buffer Pool 里缓存了一个区里的 13 个连续的数据页,而且这些数据页都是比较频繁会被访问的,此时会直接触发预读机制,把这个区里的其它的数据页都加载到缓存页里去。这个机制是通过参数 innodb_random_read_ahead 来控制的,默认是OFF,也就是说这个规则是关闭的。

所以默认情况下,主要是第一个规则可能会触发预读机制,一下子把很多相邻区里的数据页也加载到缓存页里去。这些缓存页的数据块如果一下子都放在 LRU 链表的前面,并且实际上也没什么人会访问的话,那就会如上图所示的那样,导致 Buffer Pool 里的一些频繁被访问的缓存页的数据块被移到 LRU 链表的尾部。

这样的话,如果要把一些缓存页淘汰掉,刷入磁盘,腾出来空闲缓存页,就会如上图所述,把 LRU 链表尾部一些频繁被访问的缓存页给刷入磁盘和清空掉了。这显然是不合理的,不应该这样做。

全表扫描

除了预读机制,全表扫描也会给 LRU 链表造成隐患。因为全表扫描会将该表的所有数据页都加载到缓存页当中,此时 LRU 链表中排在前面的一大串都是全表扫描加载进来的缓存页的描述数据块。但如果全表扫描之后,就再也没有用到表里的数据呢?此时 LRU 的尾部,可能全部都是之前一直被频繁访问的缓存页的描述数据块。

然后淘汰缓存页的时候,淘汰的就是频繁被访问的缓存页。

所以如果只是简单的 LRU,那么这个设计是漏洞百出的。因为预读机制、全表扫描机制,都会将大量不怎么访问的数据页加载到缓存页中,然后 LRU 链表的前面可能全部都是未来不怎么访问的数据页(的描述数据块)。而真正被频繁访问的缓存页,其描述数据块都在 LRU 链表的尾部了,然后缓存页淘汰的时候,就会将那些真正被频繁访问的缓存页淘汰掉。

MySQL 为什么要有预读机制

这里我们再来探究一下,MySQL 为什么要有预读机制,为什么有时候要把相邻的一些数据页也一次性读到 Buffer Pool 当中。

其实原因很好想,就是为了提高性能。假设你读取了数据页01 到缓存页里去,那么 MySQL 认为你接下来有可能会接着顺序读取和数据页01 相邻的数据页02 到缓存页里去。所以为了优化性能,MySQL 才设计了预读机制,也就是说如果在一个区内顺序读取了好多数据页了,比如数据页01 ~ 数据页56 都被依次顺序读取了,MySQL 会认为接下来可能会继续顺序读取后面的数据页。

那么此时它就干脆提前把后面的一大堆数据页都读取到 Buffer Pool 里去,那么后续再读取数据页60 的时候,是不是就可以直接从 Buffer Pool 里拿到数据了?

但理想情况下是这样,可如果预读的数据页后续压根儿没人使用,那这个预读机制就是在捣乱了。


LRU 算法优化



上面我们分析了预读机制对 LRU 链表造成的麻烦,那么 MySQL 是如何解决的呢?很简单,MySQL 在设置 LRU 链表的时候,采用了冷热数据分离的思想。因为这一系列问题,都是因为所有的缓存页混在一个 LRU 链表上导致的。

所以真正的 LRU 链表会被拆成两部分,一部分是冷数据,一部分是热数据,冷热比例由参数 innodb_old_blocks_pct 控制,默认是 37,也就是冷数据占比 37%。所以这个 LRU 链表应该长下面这样:

所以 LRU 链表按照一定比例拆分成了冷热两部分,头部是热数据,尾部是冷数据。那么在运行期间,这两部分又是如何使用的呢?

首先数据页在加载到 Buffer Pool 的时候,会使用冷区域链表的头部的缓存页,因为加载数据页就意味着要访问,但如果访问完这一次之后就再也不访问了怎么办?所以 MySQL 有一个参数 innodb_old_blocks_time,默认是 1000 毫秒,如果 1 秒钟之后又访问了这个缓存页,那么它才会被移动到热区域链表的头部。

由于操作都是在 Buffer Pool 进行的,所以加载数据页到缓存页的时候,缓存页一定会被立即访问,不然加载数据页干嘛。但这个操作可能就是一次性的,因此先使用冷区域链表的头部,如果在 1 秒之后又访问了该缓存页(后续可能经常访问),它才会被移动到热数据区域。


所以通过将链表划分为冷热数据区域,之前的问题就完美地解决了。预读机制以及全表扫描加载进来的会放在冷数据区域的头部,而热区域还是存放频繁被访问的缓存页,两者毫不影响。而如果 1s 后,冷数据区域的缓存页被访问了,那么 MySQL 就判定它为可能会被频繁访问的缓存页,然后将它移动到热数据区域的头部。

如果此时缓存页不够了,需要淘汰一些缓存页要怎么做呢?相信你一定知道答案,直接将冷数据区域尾部的缓存页淘汰掉即可。所以这就避免了淘汰频繁访问的缓存页这一情况发生,因为新加载的缓存页是在冷数据区域的头部,而不是整个 LRU 链表的头部,因此频繁访问的内存页仍在 LRU 链表的头部。

所以这种冷热数据隔离的思想,也值得我们在工作中借鉴,尽可能让冷数据和热数据隔离开,避免冷数据影响热数据访问。另外虽然 LRU 链表分成了两个区域,但整体还是一个 LRU 链表,只不过 MySQL 按照比例将它看成了两部分。

补充:如果冷数据区域的缓存页在 1s 后被访问了,那么会移动到热数据区域的头部,但如果热数据区域的缓存页被访问了,是不是也移动到热数据区域的头部呢?

答案不确定,因为如果是热数据区域前 1/4 的缓存页被访问,那么是不会移动的。举个例子,假设热数据区域有 100 个缓存页,那么前 25 个即使被访问了,也不会移动到链表的头部。但对于排在后面的 75 个缓存页,只要被访问,就会移动到链表头部去。这样的话,就可以尽可能减少链表中节点的移动了。


缓存页刷到磁盘的时机



Buffer Pool 在使用的时候,会频繁地从磁盘加载数据页到缓存页里去,然后更新 free 链表、flush 链表、LRU 链表。

比如加载一个数据页到缓存页,free 链表就会移除这个缓存页,LRU 链表冷数据区域的头部就会放入这个缓存页(的描述数据块);如果修改了一个缓存页,flush 链表就会记录这个脏页,LRU 链表可能还会把它移动到热数据区域的头部;如果清空一个缓存页(先刷到磁盘),那么它会加入 free 链表,并从 flush 链表、LRU 链表当中移除。

总之 MySQL 在做 CRUD 的时候,就是操作大量的缓存页以及链表,然后在缓存页满的时候,将 LRU 链表尾部的缓存页刷新到磁盘并清空。

所以整个过程我们已经了如指掌了,但问题是 MySQL 是什么时候刷新缓存页呢?难道只有在缓存页不够的时候才刷新吗?显然不是的,MySQL 刷页有以下几个时机。

‍定时把LRU尾部的部分缓存页刷入磁盘

首先第一个时机,并不是在缓存页满的时候,才会挑选 LRU 冷数据区域尾部的几个缓存页刷入磁盘。而是有一个后台线程,它会运行一个定时任务,这个定时任务每隔一段时间就会把 LRU 链表冷数据区域尾部的一些缓存页,刷入到磁盘里,并清空这些缓存页,然后再把它们加到 free链表中。

所以只要这个后台线程定时运行,可能缓存页都没用完,一批冷数据的缓存页就被刷入磁盘并清空,然后多一批可以使用的空闲缓存页。

定时把 flush 链表中的缓存页刷入磁盘

如果仅仅是把 LRU 链表的冷数据区域的缓存页刷入磁盘,显然是不够的,热数据区域中被修改过的缓存页也要刷到磁盘,因为要保证更新能够落盘。所以后台线程同时也会在 MySQL 不怎么繁忙的时候,找个时间把 flush 链表中的缓存页都刷到磁盘中,这样被修改过的数据,就会落盘了。

只要 flush 链表中的缓存页被刷入了磁盘,那么这些缓存页也会从 flush 链表和 LRU 链表中移除,然后加入到 free 链表中。

所以整个过程就是:一边不停地加载数据到缓存页,不停地查询和修改数据,然后 free 链表的缓存页不停地减少、flush 链表的缓存页不停地增加、LRU 链表的缓存页不停地增加和移动;另一边,后台线程不停地在把 LRU 链表的冷数据区域的缓存页以及 flush 链表的缓存页,刷入磁盘并清空,然后 flush 链表和 LRU 链表中的缓存页在不断减少,free 链表中的缓存页在不断增加。

整体就是一个此消彼长的过程。

以上就是 Buffer Pool 的实现原理,它是基于内存的一个组件,所有的 CRUD 操作都基于 Buffer Pool。如果数据在 Buffer  Pool,那么操作 Buffer Pool,不在则先将数据页加载到 Buffer Pool(中的缓存页),然后操作 Buffer Pool。

内存操作完之后的某个时刻,再将所做的修改刷到磁盘中,并且中间还会涉及到各种日志、链表等等。


Buffer Pool 只能有一个吗




Buffer Pool 的原理我们就说完了,我们知道它就是一大块内存,由一堆缓存页和描述数据块组成,然后再加上各种链表来辅助它的运行。那么接下来我们聊一聊线上的 Buffer Pool 应该如何配置,从而实现更高的并发。

假设这时候 MySQL 接收了多个请求,一个请求会交给一个线程处理,那么 MySQL 必然开启多个线程,这是没有疑问的。但是,这多个线程是同时访问 Buffer Pool 吗?或者说这多个线程是同时操作 Buffer Pool 里面的缓存页和链表吗?

图中所示的是多个线程并发访问 Buffer Pool 的示意图,但事实真的如此吗?显然不是的,因为缓存页、链表这些数据结构是被共享的,所以在访问的时候必然要涉及到加锁。

所以这多个线程其实是串行访问的,一个线程拿到锁之后其它线程就不能访问了,只有等到它加载数据页到缓存页、更新 free 链表、更新 LRU 链表,然后释放锁之后,别的线程才能操作。

那这个时候可能有人就有疑问了,要是加锁的话,性能应该会有影响吧。答案是没啥影响,因为此时的操作都是基于内存进行的,基本都是微妙级的。所以即便串行,数据库的性能也不会差,可以想象一下 Redis。

不过虽说内存的操作不影响性能,但如果数据不在内存里面呢?显然要从磁盘加载数据页,而这是一次磁盘 IO。在高并发的场景下,还是会产生一些影响的。而为了解决这一点,我们在生产上可以配置多个 Buffer Pool。

当你给 Buffer Pool 分配的内存少于 1GB 时,MySQL 默认只创建一个 Buffer Pool。但如果你的机器内存很大,那么不妨多分配一些,假设你的机器是 32GB 的内存,那么可以考虑给 Buffer Pool 分配 8GB 的内存。

[server]
innodb_buffer_pool_size = 8589934592 
innodb_buffer_pool_instances = 4

比如上面这个配置,表示给 Buffer Pool 分配 8GB 内存,并且数量为 4 个,那么每个 Buffer  Pool 的大小就是 2GB。

当 MySQL 运行之后,就会创建 4 个 2GB 的 Buffer Pool,每个 Buffer Pool 负责管理一部分的缓存页和描述数据块,并且有自己独立的 free, flush, LRU 链表。这个时候多个线程并发访问时,就可以将压力分摊开来,让并发量提升为原来的 4 倍。

当然,访问同一个 Buffer Pool 的多个线程依旧是需要加锁的,但是访问不同 Buffer Pool 的线程之间没有关系。

通过设置多个 Buffer Pool,并发量可以成倍的提升,因为线程在不同的 Buffer Pool 之间执行加锁和解锁操作,彼此互不影响。

所以在生产环境中,设置多个 Buffer Pool 来优化并发访问性能,是 MySQL 的一个很重要优化手段。


Buffer Pool 可以动态调整大小吗




下面来探讨一个新的问题,Buffer Pool 能否动态调整大小呢?显然按照目前所介绍的,Buffer Pool 应该是不支持动态调整的,想调整的话必须在配置修改完之后重启 MySQL 才会生效。这么做的原因很好理解,假设当前 Buffer Pool 是 8GB,要将它调整为 16GB 的话,那么必须把当前内存里的缓存页、描述数据块、各种链表都拷贝过去,而这个过程是非常耗时的。

但实际上 MySQL 是支持的,那么它是怎么办到的呢?其实 Buffer Pool 里面还有一个概念叫 chunk,我们前面没有说。

Buffer Pool 是由很多的 chunk 组成的,它的大小由 innodb_buffer_pool_chunk_size 控制,默认值是 128MB。如果 Buffer Pool 的大小为 8GB,数量为 4 个,那么每个 Buffer Pool 就是 2GB。而每个 Buffer Pool 又是由多个 128MB 的 chunk 组成的,所以每个 Buffer Pool 里面会有 16 个 chunk。

然后每个 chunk 里面有多个描述数据块和缓存页,并且每个 Buffer Pool 里面的多个 chunk 共享同一份 free, flush, LRU 链表。

从上图我们可以清晰地看到,一个 Buffer Pool 里面有多个 chunk,每个 chunk 就是一系列的描述数据块和缓存页。这样的话,MySQL 就以 chunk 为单位,将 Buffer Pool 这个大块头拆分成了多份,并且多个 chunk 共用同一个 free, flush, LRU 链表。

而有了 chunk 这套机制,就可以支持动态调整大小了。比如将 8GB 的 Buffer Pool 增加到 16GB,此时只需要申请一系列 128MB 大小的 chunk,然后再将它们分配给 Buffer Pool 即可,这样就避免了数据的拷贝。

当然啦,是否要线上动态修改 Buffer Pool 不是我们的重点,重点是要了解 Buffer Pool 的结构,只需要知道它是由多个 chunk 组成的,并且支持在运行期间动态修改大小就可以了。


Buffer Pool 到底要配多大



最后再来说一说生产环境中,我们应该给 Buffer Pool 设置多大的内存。不过首先我们来分析一下,如果 Buffer Pool 配置的过小会有什么后果。

其实不难想到,如果 Buffer Pool 过小就意味着空闲缓存页的数量少,那么很容易会频繁造成缓存页的刷新,和数据页的加载。因为没有空闲缓存页了,那么要想加载新的数据页,是不是得先释放一个已使用的缓存页呢?

而这会伴随着两次磁盘 IO,因此性能是不高的。

虽然后台线程会定期释放缓存页,但如果后台线程释放的速度跟不上使用的速度,就会造成上述局面。而解决这一问题的方式也很简单,给 Buffer Pool 的内存大小设置的大一些即可。

这样的话你会发现在高并发场景下,虽然后台线程释放缓存页的速度仍跟不上使用缓存页的速度、空闲缓存页的数量还是一直在减少,但由于 Buffer Pool 的内存设置的比较大(缓存页比较多),所以想要全部用完还是需要一段时间的。而一旦扛过数据库的高峰期,那么缓存页的使用频率又会下降,低于后台线程定期释放缓存页的频率,那么空闲缓存页的数量又会慢慢增加。

可 Buffer Pool 应该设置多大呢?一个合理的做法是将 Buffer Pool 设置为系统内存的 50~60%。比如机器内存是 32GB,那么 Buffer Pool 就设置为 20 GB;机器内存 128GB,Buffer Pool 就设置为 80GB。

总之 Buffer Pool 的大小和数量决定了数据库的运行效率。


小结



到目前为止,Buffer Pool 的内容我们就说完了,你也可以通过以下命令:SHOW ENGINE INNODB STATUS 实际查看 InnoDB 的一些使用情况。打印的内容比较多,我这里就不贴了,直接给出一些里面比较重要的参考指标。

  • Total memory allocated:Buffer Pool 的总大小;
  • Buffer pool size:Buffer Pool 一共能容纳多少个缓存页;
  • Free buffers:free 链表的节点数量,也就是目前有多少个空闲缓存页;
  • Database pages 和 Old database pages:LRU 链表中的缓存页数量、以及冷数据区域的缓存页数量;
  • Modified db pages:flush 链表中的节点数量,也就是目前有多少个缓存页被修改了;
  • Pending reads 和 Pending writes:等待从磁盘加载进缓存页的数据页的数量,以及从 LRU、flush 链表刷入磁盘的缓存页的数量;
  • Pages made young 和 not young:LRU 冷数据区域中因被访问(1s 后)而进入热数据区域的缓存页的数量,以及虽然被访问了(1s 内)但是却没有进入热数据区域的缓存页的数量;
  • youngs/s 和 not youngs/s:每秒从冷数据区域进入热数据区域的缓存页的数量,以及被访问了但是没有进入热数据区域的数量;
  • Pages read xx, created xx, written xx, xx reads/s, xx creates/s, xx writes/s:分别表示已经读取、创建和写入了多少个缓存页,以及每秒钟读取、创建和写入的缓存页的数量;
  • Buffer pool hit rate xxx / 1000:每 1000 次访问,有多少命中了 Buffer Pool;
  • young-making rate xxx / 1000 和 not xx / 1000:每 1000 次访问,有多少次访问让缓存页移动到了热数据区域,以及多少次没有移动到热数据区域;
  • LRU len:LRU 链表里的缓存页的数量,链表里面存储的其实是描述数据块,但为了描述方便,我们用缓存页代替,这一点心里了解就好;
  • I/O sum:最近 50s 读取磁盘数据页的总数;
  • I/O cur:现在正在读取的磁盘数据页的数量;


在这些指标里面,我们要重点关注几个链表的使用情况,LRU 链表的冷热转移情况,以及缓存页的读写情况,这些代表了当前 Buffer Pool 的使用情况。

此外还要关注 Buffer Pool 千次访问缓存命中率,命中率越高,说明有越多的操作是基于内存执行的,所以性能就越高。再有就是磁盘 IO 的情况,磁盘 IO 越多,说明性能越差。



本文参考自:

  • 儒猿技术窝《MySQL 实战高手》
相关实践学习
如何在云端创建MySQL数据库
开始实验后,系统会自动创建一台自建MySQL的 源数据库 ECS 实例和一台 目标数据库 RDS。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助     相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
存储 缓存 关系型数据库
【MySQL进阶-08】深入理解innodb存储格式,双写机制,buffer pool底层结构和淘汰策略
【MySQL进阶-08】深入理解innodb存储格式,双写机制,buffer pool底层结构和淘汰策略
566 0
|
3月前
|
SQL 缓存 关系型数据库
(十二)MySQL之内存篇:深入探寻数据库内存与Buffer Pool的奥妙!
MySQL是基于磁盘工作的,这句几乎刻在了每个后端程序员DNA里,但它真的对吗?其实答案并不能盖棺定论,你可以说MySQL是基于磁盘实现的,这点我十分认同,但要说MySQL是基于磁盘工作,这点我则抱否定的态度,至于为什么呢?这跟咱们本章的主角:Buffer Pool有关,Buffer Pool是什么?还记得咱们在《MySQL架构篇》中聊到的缓存和缓冲区么,其中所提到的写入缓冲区就位于Buffer Pool中。
145 1
|
4月前
|
缓存 关系型数据库 MySQL
MySQL Buffer Pool 解析:原理、组成及作用
MySQL Buffer Pool 解析:原理、组成及作用
|
4月前
|
缓存 关系型数据库 MySQL
MySQL数据库——InnoDB引擎-架构-内存结构(Buffer Pool、Change Buffer、Adaptive Hash Index、Log Buffer)
MySQL数据库——InnoDB引擎-架构-内存结构(Buffer Pool、Change Buffer、Adaptive Hash Index、Log Buffer)
89 3
|
5月前
|
缓存 关系型数据库 MySQL
MySQL高效运行的秘密:BufferPool缓存机制深度剖析!
MySQL高效运行的秘密:BufferPool缓存机制深度剖析!
143 0
MySQL高效运行的秘密:BufferPool缓存机制深度剖析!
|
存储 SQL 缓存
【MySQL】change buffer,buffer pool,redo log,bin log,undo log的作用
【MySQL】change buffer,buffer pool,redo log,bin log,undo log的作用
121 0
|
5月前
|
SQL 缓存 关系型数据库
MySQL(三)SQL优化、Buffer pool、Change buffer
MySQL(三)SQL优化、Buffer pool、Change buffer
65 0
|
5月前
|
存储 算法 关系型数据库
MySQL之深入InnoDB存储引擎——Buffer Pool
InnoDB存储引擎是基于磁盘存储的,并将其中的记录按照页的方式进行管理。在数据库系统中,由于CPU速度与磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池技术来提高数据库的整体性能。在数据库中进行读取页的操作,首先将从磁盘读到的页存放在缓冲池中,这个过程称为将页“FIX”在缓冲池中,在下一次读取相同的页时,首先判断该页是否存在缓冲池中,如果存在则被命中,直接读取,否则读取磁盘上的页。
|
存储 SQL 缓存
细说MySQL中磁盘与CPU的交互——神秘的Buffer Pool
MySQL是如何读取记录的?Buffer Pool缓存功不可没!什么是Buffer Pool?它的结构是什么样的?当数据不断的读取,缓存的数据如何更新?本文将带你详细了解这些!
120 0
细说MySQL中磁盘与CPU的交互——神秘的Buffer Pool
|
缓存 安全 关系型数据库
MySQL的Buffer Pool线程安全吗?
假设MySQL同时接收到了多个请求,他自然会用多线程处理,那这多线程就可能会同时访问BP,即同时操作里面的缓存页,同时操作一个free链表、flush链表、lru链表。现在多线程来并发的访问这个BP,此时他们都是在访问内存里的一些共享数据结构,如缓存页、各种链表,必要加锁,然后让一个线程先完成一系列操作,比如说加载数据页到缓存页,更新free、lru链表,然后释放锁,接着下个线程再执行操作。
91 0