文章

InnoDB 的 Buffer Pool

Buffer Pool 是什么

学过操作系统的同学,应该都知道局部性原理,分为时间局部性和空间局部性。时间局部性指的是访问过一次的位置可能在不远的将来被多次访问。空间局部性指的是一个位置被访问,可能不久后它附近的位置也会被访问。

Buffer Pool 正是一个利用了局部性原理的优化设计,当访问某个页的数据时,即使只访问了页中的一条数据,也会把该页整个缓存到 Buffer Pool 中,由局部性原理可知不久后该页大概率还会被访问到,再次访问该页时,就可以省去磁盘 IO 的开销了。

Buffer Pool 也叫缓冲池,是 MySQL 启动时申请的一片连续内存,大小可通过 innodb_buffer_pool_size 参数(单位为字节)配置,默认大小为 128 MB。

Buffer Pool 最小值为 5 MB,配置值小于该值会被自动设置为 5 MB。

Buffer Pool 的内部结构及原理

为了管理 Buffer Pool 中的缓存页,InnoDB 为每个缓存页都分配了一个控制块,里面存储了一些控制信息(如表空间id、页号、缓存页在 Buffer Pool 中的位置、链表节点信息、锁信息、LSN 信息等)。控制块和缓存页一一对应,控制块被存放在 Buffer Pool 的左边,缓存页被存放在 Buffer Pool 的右边,它们俩中间可能有一些内存碎片,如图所示:

在 MySQL 5.7 中,一个控制块大小为 808 字节,大约占一个缓存页的 5%,因此 InnoDB 为Buffer Pool 申请的连续内存空间会比 innodb_buffer_pool_size 的值大 5% 左右。

free 链表

当准备将磁盘页缓存到 Buffer Pool 中时会遇到一个问题:将磁盘页存储到 Buffer Pool 中的哪个缓存页呢?这里的问题是我们不知道哪个缓存页是空闲的,也就是说我们需要在某个地方记录下哪些缓存页是空闲的,InnoDB 的解决方案是把所有空闲的缓存页对应的控制块作为一个个节点放到了一个链表中,这个链表叫 free 链表,如图所示:

可以看出,为了管理这个 free链表,特地定义了一个基节点,里面存储了 free 链表的头节点地址、尾节点地址以及链表中的节点数量。

有了这个 free 链表,之后需要缓存磁盘页到 Buffer Pool 中时,就从 free 链表中获取一个空闲的缓存页来使用,然后填写缓存页对应的控制信息(表空间id、页号等),最后将缓存页对应节点从链表移除。

一个链表基节点占用 40 字节内存空间,并且其占用的内存空间不在 Buffer Pool 中,而是单独申请的一块内存空间。

缓存页哈希表

当需要访问某个页中的数据时,怎么判断该页是否在 Buffer Pool 中呢?一个个遍历所有缓存页肯定是不可行的,我们很容易可以想到哈希表,它可以提供接近 O(1) 级别的查找效率。通过`表空间id + 页号`可以唯一定位一个页,因此自然而然想到用`表空间id + 页号`作为哈希表的 key,那么 value 就是缓存页本身。

有了缓存页哈希表之后,当需要访问某个页中的数据时,首先根据`表空间id + 页号`在哈希表中查找是否有对应的缓存页,如果有,就直接使用,如果没有,则从 free 链表获取一个空闲的缓存页。

flush 链表

如果修改了某个缓存页的数据,那它就和磁盘上对应页的数据不一致了,这样的缓存页被称为脏页(dirty page)。为了数据一致性,简单的做法就是每次修改缓存页都立即同步到磁盘上,但是磁盘 IO 是很慢的,频繁往磁盘写入数据是非常影响程序性能的,因此不能使用这样简单粗暴的方法。

InnoDB 的做法是不立即同步磁盘,而是在未来的某个时间点进行同步。不立即同步磁盘会引入一个问题:未来进行的同步的时候如何知道哪些缓存页是脏页?也就是说,我们要在某个地方记录脏页信息。InnoDB 的解决方案是创建一个存储脏页信息的链表,脏页对应的控制块会作为节点加入链表,这就是 flush 链表,其结构类似 free 链表,如图所示:

LRU 链表

Buffer Pool 的大小毕竟是有限的,如果 free 链表中没有了空闲的缓存页,应该把哪些旧的缓存页淘汰呢?我们使用缓存页的目的是减少磁盘 IO,可知缓存命中率应该越高越好,根据这个思路,应该是把最近最少使用的缓存页给淘汰掉,这就是大名鼎鼎的 LRU (Least Recently Used) 算法,因此 InnoDB 引入了一个 LRU 链表。那么这个 LRU 链表如何实现呢?

一个简单的思路是当访问某个页时:

  1. 如果该页不在 Buffer Pool 中,就把该页从磁盘加载到 Buffer Pool 的缓存页中,再把这个缓存页对应的控制块作为节点加入到 LRU 链表的头部

  2. 如果该页已经在 Buffer Pool 中,就把这个缓存页对应的控制块作为节点移动到 LRU 链表的头部

但这个简单的 LRU 链表很快就遇到了一个问题,这个问题跟 InnoDB 提供的一个预读(read ahead)功能有关。预读就是 InnoDB 认为不久之后会读取某些页面,就预先把这些页面加载到 Buffer Pool 中。预读分为两种:

(1) 线性预读

如果顺序访问了某个区(extent)中 innodb_read_ahead_threshold 个页,就会触发一次异步读取下一个区中所有页到 Buffer Pool 的请求,这就是线性预读。

系统变量 innodb_read_ahead_threshold 的值默认是 56。

(2) 随机预读

如果 Buffer Pool 中已经缓存了某个区的连续 13 个页(不管是不是顺序读取的),并且这 13 个页在 young 区的前 1/4 内(下面会介绍 young 区),就会触发一次异步读取该区所有其他页到 Buffer Pool 的请求,这就是随机预读。

随机预读的开启与否由 innodb_random_read_ahead 系统变量控制,默认是 OFF 关闭的。

预读的页如果能被用到,就会极大提高语句的执行效率。但是如果没有用到,放到 LRU 链表头部的预读页可能会导致 LRU 链表尾部的一些缓存页被淘汰,这会一定程度降低缓存命中率。InnoDB 的解决方案是把 LRU 链表分成两个区域:

  1. young 区:LRU 前面的区域用来存储使用频率非常高的缓存页(热数据)

  2. old 区:LRU 前面的区域用来存放使用频率比较低的缓存页(冷数据)

有一个系统变量 innodb_old_blocks_pct 表示 old 区域的占比,默认为 37%(大约为 3/8),如:

mysql> SHOW VARIABLES LIKE 'innodb_old_blocks_pct';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| innodb_old_blocks_pct | 37    |
+-----------------------+-------+

下面是一个分区的 LRU 链表示意图:

InnoDB 规定当某个页初次被加载到 Buffer Pool 中时,其缓存页对应的控制块会被放到 LRU 链表的 old 区域头部,这样预读但是未被访问的页不会影响 young 区域中访问比较频繁的页,并且不久之后会逐渐从 old 区域淘汰。

分区后的 LRU 链表在大多数情况下运行良好,但是在全表扫描的时候又会出现问题。全表扫描意味着访问该表的所有页,如果表记录比较多的话,Buffer Pool 中的缓存页会全部被替换成该表的页,导致访问频率高的缓存页统统失效,这会极大地降低缓存命中率。针对这个问题,InnoDB 规定第一次访问 old 区域的某个缓存页时记录下访问时间,如果后续的访问跟第一次访问的时间间隔在 innodb_old_blocks_time 之内,那么该页就不会从 old 区移动到 young 区,否则移动到 young 区头部。

innodb_old_blocks_time 默认值为 1000 毫秒。

如果把 innodb_old_blocks_time 设置为 0,只要访问 old 区的缓存页,就会把该页移动到 young 区头部。

此时的 LRU 链表已经比较好用了,但是依旧还有优化空间。对于 young 区的缓存页来说,我们没必要每次访问都把它们移动到 young 区头部,这样频繁移动的开销太大了。针对这个问题,InnoDB 规定当访问 young 区的缓存页时,如果该缓存页位于 young 区前 1/4,就不把它移动到 young 区头部(也就是说被访问的缓存页位于 young 区后 3/4 才会被移动到 young 区头部),从而降低调整 LRU 链表的频率,提高了性能。

其他链表

为了更好地管理 Buffer Pool 中的缓存页,InnoDB 还引入了其他的一些链表 ,如 unzip LRU 链表用于管理解压页,zip clean 链表用于管理压缩页,zip free 数组中每一个元素都对应一个链表,它们组成了一个伙伴系统来为压缩页提供内存空间等。

刷新脏页到磁盘

InnoDB 主要有 3 种方式把脏页刷新到磁盘:

  1. BUF_FLUSH_LIST:后台线程会定时从 flush 链表中刷新一部分脏页到磁盘。

  2. BUF_FLUSH_LRU:后台线程会定时从 LRU 链表的尾部扫描 innodb_lru_scan_depth(默认 1024)个页,将发现的脏页刷新到磁盘。

  3. BUF_FLUSH_SINGLE_PAGE:用户线程发现 Buffer Pool 中没有空闲的缓存页可用,会先检查 LRU 链表尾部有没有可以直接释放掉的非脏页,如果没有非脏页,会从 LRU 链表尾部同步刷新一个脏页到磁盘。

Buffer Pool 多实例

多线程环境下,访问 Buffer Pool 中个各种链表需要加锁,当 Buffer Pool 很大的时候,可以把 Buffer Pool 划分成多个小的 Buffer Pool,每个小的 Buffer Pool 都称为一个 Buffer Pool 实例,它们是互相独立的,即独立申请内存空间、独立管理各种链表,这样可以有效降低并发的竞争烈度,提高并发处理能力。

可以通过 innodb_buffer_pool_instances 系统变量来修改 Buffer Pool 实例的个数(默认为 1),这样每个 Buffer Pool 实例的大小就是:innodb_buffer_pool_size / innodb_buffer_pool_instances。

注意:当 innodb_buffer_pool_size 的值小于 1GB 的时候设置多个实例是无效的。

Buffer Pool 的 chunk

MySQL 5.7 开始把 Buffer Pool 的内存分配改成了 chunk 方式,也就是说不再一次性分配全部的内存,而是以 chunk 为单位按需增减 Buffer Pool 的内存。自此,一个 Buffer Pool 可能有多个实例,一个实例可能有多个 chunk,每个 chunk 是一个连续的内存空间,如图所示:

innodb_buffer_pool_chunk_size 默认大小为 128MB,不能在运行过程中修改它的值。

Buffer Pool 存储的其它信息

Buffer Pool 中的缓存页除了用来缓存磁盘上的页以外,还可以存储锁信息、自适应哈希索引等信息。

配置Buffer Pool 的注意事项

  1. innodb_buffer_pool_size 必须是 innodb_buffer_pool_instances * innodb_buffer_pool_chunk_size 的整数倍,否则 innodb_buffer_pool_size 会被自动调整为整数倍。

  2. 如果 innodb_buffer_pool_instances * innodb_buffer_pool_chunk_size 大于 innodb_buffer_pool_size,innodb_buffer_pool_chunk_size 会被自动调整为 innodb_buffer_pool_size / innodb_buffer_pool_instances

查看 Buffer Pool 的状态信息

MySQL 提供了 SHOW ENGINE INNODB STATUS 语句来查看 InnoDB 存储引擎运行过程中的一些状态信息,其中包括了 Buffer Pool 的状态信息,如: 

mysql> SHOW ENGINE INNODB STATUS\G
...
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137428992
Dictionary memory allocated 165135
Buffer pool size   8191
Free buffers       7299
Database pages     883
Old database pages 314
Modified db pages  0
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 0, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 509, created 374, written 607
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 883, unzip_LRU len: 0
I/O sum[0]:cur[0], unzip sum[0]:cur[0]
...

我们看下 Buffer Pool 的这些状态信息分别表示什么意思:

状态信息

说明

Total large memory allocated

为 Buffer Pool 分配的内存(字节)

Dictionary memory allocate

为数据字典信息分配的内存(不包括在 Total large memory allocated 中)

Buffer pool size

Buffer Pool 可以容纳的缓存页数量

Free buffers

当前 Buffer Pool 中空闲的缓存页

Database pages

LRU 链表中的页数

Old database pages

LRU 链表 old 区域中的页数

Modified db pages

脏页数量(即 flush 链表节点数)

Pending reads

等待从磁盘加载到 Buffer Pool 的页数

Pending writes LRU

等待从 LRU 链表刷新到磁盘页数

Pending writes flush list

等待从 flush 链表刷新到磁盘页数

Pending writes single page

等待以单页方式刷新到磁盘页数

Pages made young

从 old 区移动到 young 区的页数(不包括从 young 区后 3/4 移动到 young 区头部的页)

Pages made not young

由于不满足 innodb_old_blocks_time 限制而无法从 old 区移动到 young 区的页数(不包括由于在 young 区前 1/4 而无法移动到 young 区头部的页)

youngs/s

每秒从 old 区移动到 young 区的页数

non-youngs/s

每秒由于不满足 innodb_old_blocks_time 限制而无法从 old 区移动到 young 区的页数

Pages read, created, written

读取、创建、写入的页数

reads/s, creates/s, writes/s

每秒读取、创建、写入的页数

Buffer pool hit rate

在过去一段时间,每访问 1000 个页,平均多少页在 Buffer Pool 中(Buffer Pool 命中率)

young-making rate

在过去一段时间,每访问 1000 个页,平均有多少页移动到了 young 区头部(包括从 young 区后 3/4 移动到 young 区头部的页)

not young-making rate

在过去一段时间,每访问 1000 个页,平均有多少页没有移动到 young 区头部(包括由于在 young 区前 1/4 而无法移动到 young 区头部的页)

Pages read ahead

每秒线性预读的页数

evicted without access

每秒未经访问就被逐出 Buffer Pool 的页数

Random read ahead

每秒随机预读的页数

LRU len

LRU 链表的节点数

unzip_LRU len

unzip_LRU 链表的节点数

I/O sum

最近 50s 读取的磁盘页数量

I/O cur

当前正在读取的磁盘页数量

unzip sum

最近 50s 解压的页数

unzip cur

当前正在解压的页数

License:  CC BY 4.0