浅谈 Buffer Pool

缘由

MySQL 是个典型的关系型数据库,自 5.5 版本起,默认的存储引擎由 MyISAM 改为 InnoDB。InnoDB 存储引擎中数据以页的形式存储在磁盘上,页的默认大小为 16KB。
在这里插入图片描述
但是,基于磁盘和 CPU 巨大的速度差异(以上图 HDD 为例,相差千万倍 ),需得把数据缓存在内存里以提高性能,于是便引出了今天的主题 — Buffer Pool。

注:1ms(毫秒) = 1000 us(微秒) = 1000 * 1000 ns(纳秒)

Buffer Pool 是什么

Buffer Pool 是 InnoDB 中的一块内存区域,默认大小为 128MB。

mysql> show variables like 'innodb_buffer_pool_size';
+-------------------------+------------+
| Variable_name           | Value      |
+-------------------------+------------+
| innodb_buffer_pool_size | 4294967296 |
+-------------------------+------------+
1 row in set (0.00 sec)

注:innodb_buffer_pool_size 的单位为字节。

每当 MySQL 进行读取页的操作时,先判断该页是否在 Buffer Pool 中,在的话就被称为被命中,直接读,不在的话便从磁盘加载,放入其中,以便下次读取。

诚如上述所言,如何判断页是否在 Buffer Pool 中呢?一个简单快捷的方法就是哈希,在 InnoDB 中叫 page hash,用表空间号 + 数据页号作 key,Buffer Pool 中缓冲页对应的地址作为其 value,来快速判断。

注:磁盘上的数据页加载到 Buffer Pool 便称之为缓冲页,下同。

Buffer Pool 的内部结构

在这里插入图片描述

上图可以看出,Buffer Pool 中,占很大一部分的是缓冲页和索引页,其余的则是 change buffer、锁信息(lock info)、自适应哈希索引、数据字典信息等,这篇博客重点涉及前两块。

在这里插入图片描述

缓冲页的大小和 InnoDB 表空间使用的页大小一致,默认都是 16KB。为了更好的管理这些缓冲页,InnoDB 会为其创建一个对应的索引页,里面存储该页所述的表空间编号、页号、缓冲页所在 Buffer Pool 中的地址、链接节点信息等。

每个索引页的大小都一致,大约占缓冲页大小的 5% 左右,也就是 800B 左右。需要注意的是,MySQL 在启动时,会按照 innodb_buffer_pool_size 以及加上这 5%去申请连续的内存。

Free 链表

MySQL 刚启动并完成 Buffer Pool 的初始化后,由于没有加载数据页,此时会把索引页当节点,都放进去 Free 链表中。接下来,有数据页从磁盘加载到 Buffer Pool 中,就从 Free 链表中取出一个节点,加载数据页到缓冲页,并把数据页的表空间号和数据页号等信息写入索引页,最后把此节点从 Free 链表中移除。
在这里插入图片描述
可以看出 Free 链表是个双向的链表,同时为了便于统计,有个基础节点,同时这个基础节点所占的空间并在 Buffer Pool 内,而是单独申请的(Flush 及 LRU 链表的基础节点也是如此)。

Flush 链表

MySQL 对数据的修改(增、删、改)都是在内存中的 Buffer Pool 中进行的,此时缓冲页就和磁盘上的数据页不一致了,也就成了脏页(dirty page),同时后台会有专门的线程刷脏页到磁盘。

Flush 链表就是对脏页进行管理的,凡是被修改过的缓冲页对应的索引页都会作为一个节点加到此链表。

和 Free 链表类似,Flush 也是个双向的链表。
在这里插入图片描述

LRU 链表

进入到这篇博客的重点了,LRU,也就是 Least Recent Used(最近最少使用)的缩写,毕竟 Buffer Pool 的大小不是无限制的,总有到尽头的,也就是 Free 链表无可用节点时,那时就得按照 LRU 规则淘汰掉缓冲页了。

那么问题来了,该移除哪些缓冲页呢?

朴素 LRU 链表

最简单的办法,就是设计一个像上面的 Free、Flush 链表类似的结构,最近访问的放入 LRU 链表的头部,不常访问的渐渐便移动到了尾部,每次就淘汰掉尾部的 N 个缓冲页,简单省事。

但是,仔细想想,这个方案是否有问题,是否有改进的地方?

全表扫描

举个反例就可以证明此方案有问题,那就是全表扫描。此时会从磁盘里加载大量的数据页到 Buffer Pool 中,LRU 的头部节点会被挤占,更有甚者会一直延伸到中后段去,更为关键的是,要是这些头部的缓冲页仅仅在此场景用到,接下来便无用武之地。最后,更为尴尬的事情来了,下一轮的要淘汰的就是之前使用的热点数据(此时,由于全表扫描被挤到了链表尾部了)。

预读

所谓预读,就是触发 InnoDB 的相关设定后,不仅仅只加载当前的页,而且会把页所在区中所有其他页都加载到 Buffer Pool 中。

注:在 InnoDB 存储引擎中,所有的数据都被逻辑地存放在表空间(tablespace)中,而表空间又包括段(segment)、区(extent)、页(page)。

根据触发的方式的不同,预读可分为两种:

第一种为线性预读,也就是顺序读取某个区的页面超过 innodb_read_ahead_threshold(默认值为 56) 阈值。

mysql> show variables like 'innodb_read_ahead_threshold';
+-----------------------------+-------+
| Variable_name               | Value |
+-----------------------------+-------+
| innodb_read_ahead_threshold | 56    |
+-----------------------------+-------+
1 row in set (0.00 sec)

第二种为随机预读,也就是某个区的 13 个连续的页都被加载,可以通过 innodb_random_read_ahead 参数控制,默认是关闭的。

mysql> show variables like 'innodb_random_read_ahead';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_random_read_ahead | OFF   |
+--------------------------+-------+
1 row in set (0.00 sec)

和全表扫描一致,相关页加载到了 LRU 头部后,就会挤占然来的热点数据,于是就成了典型的好心办坏事。

innodb_old_blocks_pct 和 innodb_old_blocks_time

全表扫描和预读在朴素 LRU 链表中,都可能降低 Buffer Pool 的命中率,且

  • 加载到 Buffer Pool 中的页不一定会被用到;
  • 大量使用频率低的页加载到 Buffer Pool 中,会使热点数据被淘汰掉。

有问题,就解决问题,办法不是重起炉灶,而是按照 5:3 的比例把整个 LRU 链表分成了 young 区域(热数据放置如此)和 old 区域(不常用到的冷数据放置如此)。
在这里插入图片描述

这个位置由 innodb_old_blocks_pct 控制,默认值为 37,表示新读取的页插入到 LRU 链表的尾端的 37%(差不多 3/8 的位置),也就是 old 区。

mysql> show variables like 'innodb_old_blocks_pct';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| innodb_old_blocks_pct | 37    |
+-----------------------+-------+
1 row in set (0.00 sec) 

同时,还有个 innodb_old_blocks_time 参数来规定什么情形下,old 区的缓冲页数据移动到 young 区。

mysql> show variables like 'innodb_old_blocks_time';
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| innodb_old_blocks_time | 1000  |
+------------------------+-------+
1 row in set (0.00 sec)

此值默认为 1000 毫秒,也就是对于 old 区域的某个缓冲页来说,访问的间隔超过一秒,那么就会移动到 young 区。显然,对于全表扫描来说,由于短时间内(不超过 1 秒)会多次访问一个 old 区的缓冲页,因此也就不用担心移动到 young 区。

注:MySQL 中的一个数据页包含多行数据,一个数据页默认为 16K,假设一行数据占 1K,不考虑其他数据结构占据页的空间情形下,可以存 16 行。

好了,到此为止,LRU 链表还能不能再进一步优化呢?

当然。上面大都是针对 old 区优化的,对于 young 区则比较模糊。比如,从 old 区移动到 young 区的缓冲页是清楚的,但是,比如要访问的缓冲页本身就在 young 区,那是否每次都要移动到 young 区的链表头部呢,毕竟 young 区都是热点数据,频繁移动也会消耗性能的。

针对此种情况,InnoDB 规定,只有被访问的缓冲页位于 young 区的 1/4 位置后面,才会被移动到 young 区的头部。

其实 LRU 链表还包含了 unzip_LRU 的节点,InnoDB 支持压缩页功能,可以将原本 16KB 的页压缩成 1KB、2KB、4KB、8KB,对于这些压缩页是通过 unzip_LRU 链表进行管理的,这里不做过多介绍。

刷脏页策略

前面提到过,Flush 链表里存储的都是脏页,同时需要注意的是,Flush 链表的缓冲页同时也都包含在 LRU 链表里。

那,InnoDB 刷脏页的策略是什么呢?

大致分为两大类。

第一大类,就是 MySQL 数据库正常关闭,这时会把 Buffer Pool 中所有的脏页都刷到磁盘,这种方式也被称为 sharp checkpoint。

注:checkpoint 与 redo log 息息相关,redo log 是一组环形日志,记录了数据页的物理修改,也就是在某个数据页上做了什么修改,是为了实现数据库宕机时恢复数据用的,可以保证 crash safe。checkpoint 则相当于水位线,低于水位线的都是已经刷盘的,高于水位的大都是目前暂存在日志里的。InnoDB 会依据脏页的刷新情况,定期推进 checkpoint,从而减少数据库崩溃恢复的时间。

第二大类,细化为以下四个小的类,去做刷盘动作,又被称之为 fuzzy checkpoint。

  • master thread checkpoint:以每秒或者每十秒的速度从 Buffer Pool 中的 Flush 链表中刷新一定比例的脏页回磁盘,这个过程是异步的,不会阻塞用户线程。
  • flush_lru_list checkpoint:通过参数 innodb_lru_scan_depth (默认 1024)控制 LRU 列表中可用页的数量,发生这个 checkpoint 时,说明脏页写入速度过慢。
  • async/sync flush checkpoint:指的是 redo log 不可用的情况,从 Flush 列表中刷新足够的脏页回磁盘。
  • dirty page too much checkpoint:脏页太多时,也会发生强制写日志,会阻塞用户线程,由 innodb_max_dirty_pages_pct 参数(默认75%)控制。

通过上述介绍,可以看出 fuzzy checkpoint 相对于 sharp checkpoint 来说,没有明确的刷盘时间点,因此名称中的 fuzzy 可以点题,也就是模糊的。

不过刷脏页前,需明确告知 InnoDB 所在主机的 IO 能力,这样才更能把好钢用到刀刃上。 innodb_io_capacity 这个参数,会告诉 InnoDB 所在主机的磁盘能力,设成磁盘的 IOPS 即可。

IOPS 即每秒的输入输出量(或读写次数),若需进一步了解可以参考 简单聊聊硬盘(上)

mysql> show global variables like 'innodb_io_capacity';
+--------------------+-------+
| Variable_name      | Value |
+--------------------+-------+
| innodb_io_capacity | 200   |
+--------------------+-------+
1 row in set (0.00 sec)

同时还得注意 innodb_flush_neighbors 这个参数。在 InnoDB 中,一旦某个查询请求需要在执行过程中先刷掉一个脏页时,在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。

mysql> show global variables like 'innodb_flush_neighbors';
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| innodb_flush_neighbors | 1     |
+------------------------+-------+
1 row in set (0.00 sec)

innodb_flush_neighbors 值为 1 的时候会有上述的“连坐”机制,值为 0 时就自己刷自己的。此参数在主机硬盘是 HDD 时还是挺有意义的,可以减少随机 IO,毕竟 HDD 的 IOPS 的随机 IO 只有几百,可以提升性能。但对于 SSD 这种 IOPS 轻松上万的且随机写的性能出色的设备来说就显得鸡肋了(固态硬盘不用磁头,可以做到随机读取,寻道时间几乎可以省略),建议置为 0,就能更快地执行完必要的刷脏页操作,减少 SQL 语句响应时间。
在这里插入图片描述

多实例

MySQL 5.7 添加多 Buffer Pool 实例特性,每个数据页依据前面提到的哈希值再模上实例数定位到相应的 Buffer Pool 实例中,用 innodb_buffer_pool_instances 参数表示,最多可设 64 个。之前 Buffer Pool 只此一份,Free、Flush、LRU 链表同一时间只能允许一个线程操作。增加了多实例特性,好处是减少数据库内部的资源竞争,增加数据库的并发处理能力。

mysql> show variables like 'innodb_buffer_pool_instances';
+------------------------------+-------+
| Variable_name                | Value |
+------------------------------+-------+
| innodb_buffer_pool_instances | 2     |
+------------------------------+-------+
1 row in set, 1 warning (0.01 sec)

那如何计算具体的 Buffer Pool 实例占多少内存呢?用下述公式即可算出。

innodb_buffer_pool_size / innodb_buffer_pool_instances

当然了实例数也不是越多越好,毕竟天下没有免费的午餐,管理多实例也是耗性能的,而且 MySQL 规定,当 innodb_buffer_pool_size 小于 1GB 时,InnoBD 会默认把 innodb_buffer_pool_instances 调整为 1。

同样也是 MySQL 5.7,增加了动态调整 Buffer Pool 的大小的功能,通过 innodb_buffer_pool_chunk_size 参数来配置,默认 128MB。也就是 MySQL 以 chunk 为单位申请内存空间,内存布局是一个个 chunk 组成。当然了,这时就得事先设计好 innodb_buffer_pool_size 的值,只需等于 innodb_buffer_pool_instances * innodb_buffer_pool_chunk_size 的倍数即可。需得注意的是, innodb_buffer_pool_chunk_size 只能在启动时指定,不能在运行过程中调整。

以往版本是 MySQL 启动时读取 innodb_buffer_pool_size 值以此来向操作系统申请内存,且不支持运行过程中调整该值,只能重新配置该值再重启。

在这里插入图片描述

参考

《MySQL是怎样运行的:从根儿上理解 MySQL》

《MySQL技术内幕:InnoDB存储引擎(第2版)》

《MySQL实战45讲》

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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值