MySQL之InnoDB存储引擎:浅谈InnoDB缓冲池Buffer Pool

我们知道在MySQL中,内存与硬盘之间进行交互是页为单位。为了避免频繁从硬盘中加载数据,故在InnoDB存储引擎中提出了Buffer Pool缓冲池的概念

abstract.png

内存结构

前面提到为了避免频繁从硬盘中加载数据到内存中,InnoDB引擎提出了Buffer Pool缓冲池的概念。在每次使用完内存中的页数据后,并不会立即将该页所占用的内存空间释放掉,而是将其放在Buffer Pool缓冲池中,这样下次再需要访问使用该页时,直接从Buffer Pool中获取即可。而无需再从硬盘中重新加载了,可以看到此举将会大大提高访问性能

存放在Buffer Pool中的页,称此为缓存页。其大小与磁盘中默认的页大小一致,均为16KB。与此同时,每个缓存页都有一个相应的控制块,用于存放该缓存页的描述信息。其在内存中的结构如下所示。可以看到,Buffer Pool是使用了一块连续的内存空间,其中控制块位于前面,缓存页位于后面。在MySQL对Buffer Pool进行初始化时,即会提前划分好各控制块及其相应的缓存页的内存位置。故当剩余的内存空间不足划分出新的控制块、缓存页时,即会出现所谓的碎片

figure 1.png

关于Buffer Pool的大小,可在启动MySQL前通过innodb_buffer_pool_size参数进行配置,其单位为字节

1
2
[server]
innodb_buffer_pool_size = 5242880

Note

  1. Buffer Pool的大小最低为5M。当小于该值时,MySQL会自动设置为5M
  2. 事实上,该配置参数并不包含各控制块所使用的内存空间。故一般地,Buffer Pool所使用的内存空间会比该配置参数的值稍微大一点

管理Buffer Pool

free链表

前面我们提到,在初始化的过程中即会将各缓存页划分完毕。那问题来了,当我们需要将用完后的页存放到Buffer Pool中时,该如何知道Buffer Pool中哪个缓存页是空闲的、未被使用的呢?其实很简单,MySQL将所有空闲缓存页所对应的控制块连接起来,建立了一个所谓的free链表。具体地,可通过基节点来实现对free链表的管理,其内部含有链表的头节点指针、尾节点指针、链表中节点数量。而在每个控制块内部则含有free链表的上一个、下一个节点的指针。其结构示意图如下所示

figure 2.png

这样只需通过free链表即可很方便地获取一个未被使用的缓存页了。值得一提的是,正如Buffer Pool的结构示意图所示,链表的基节点并不在Buffer Pool所使用的内存空间之中,而是单独分配的

flush链表

如果Buffer Pool中某个缓存页中的数据记录被修改了,则其即会与硬盘中页就会出现数据不一致的问题,该缓存页也被称为Dirty Page脏页。故为了保证数据一致性,我们需要将脏页同步回硬盘。而为了便于统一同步,我们对这些脏页的控制块也建立了一个链表进行管理,即所谓的flush链表。其在结构上与上文的free链表基本一致,故此处就不再赘述了

LRU链表

对于已使用的缓存页而言,其亦是通过链表进行管理的。该链表即被称为LRU链表。由于Buffer Pool的内存空间毕竟有限,故需要使用某种内存淘汰策略。顾名思义,其对已使用的缓存页的淘汰策略是LRU(Least Recently Used,最近最少使用)算法。具体地,对LRU链表的管理规则如下

  1. MySQL访问某页数据时,如果该页不在Buffer Pool中。则首先从硬盘中进行加载该页;然后找到一个空闲的缓存页用于存放该页;最后,将该缓存页对应的控制块插入到LRU链表的头部
  2. MySQL访问某页数据时,如果该页在Buffer Pool中。则会将该缓存页对应的控制块移到LRU链表的头部

至此,大家可以看到。当Buffer Pool中无空闲缓存页可用时,只需从LRU链表的尾部淘汰即可

哈希表

在维护LRU链表时,我们需要先判断某页是否已经缓存到Buffer Pool中了。为了避免遍历,其使用了哈希表来提高效率。具体地,是以表空间ID+页号作为Key,而Value即为Buffer Pool中相应的缓存页

同步Dirty Page脏页

前面提到Buffer Pool通过flush链表来对Dirty Page脏页进行管理。具体地,脏页同步到硬盘中有以下几种方式

  1. BUF_FLUSH_LIST方式:后台线程定时将flush链表中的部分脏页同步到硬盘中
  2. BUF_FLUSH_LRU方式:后台线程定时从LRU链表的尾部开始扫描一定数量的缓存页,如果发现脏页则会同步到硬盘中。扫描缓存页的数量可通过(全局)系统变量innodb_lru_scan_depth进行控制
  3. BUF_FLUSH_SINGLE_PAGE:当用户线程需要将一个页缓存到Buffer Pool时,如果发现已无未被使用的、空闲的缓存页,此时就需要淘汰LRU链表尾部的一个缓存页。这个时候,如果待淘汰的缓存页恰好为脏页的话,就需要先将其同步到硬盘中

综上所述,可以看到前两种方式都是后台线程去完成的。而第三种方式则是通过用户线程去完成,此举会大大拖慢用户请求的处理速度

性能优化

优化LRU链表

划分young、old区域

在InnoDB引擎下,有时候我们可能只需要硬盘中页A的数据。但实际上却可能把页A、页B、页C都加载到内存当中。即所谓的Read-ahead预读。具体地,可分为线性预读、随机预读。讲道理,预读可以在一定程度上提高MySQL的效率,因为如果可能用完了页A的数据后还需要使用页B的数据。但是聪明的朋友可能会发现一个问题:如果预读的页太多,一旦Buffer Pool中也没用足够的空闲缓存页供存放。即会将LRU链表尾部的缓存页淘汰掉。类似地场景,当查询语句执行全表扫描时,如果该数据表所使用的页非常多,同样会导致LRU链表中的缓存页全部被换掉

上述两种情况,均会导致LRU链表淘汰掉一大部分原有高频访问的缓存页。这将大大影响Buffer Pool的效果。为此InnoDB对Buffer Pool的LRU链表进行的优化改进。具体地,其将LRU链表根据比例划分为两部分。链表前部分称之为young区域,用于存放访问频率较高的缓存页(即热数据);链表后部分称之为old区域,用于存放访问频率较低的缓存页(即冷数据)。示意图如下所示

figure 3.jpeg

而old区域占LRU链表的比例,可通过配置文件中配置参数innodb_old_blocks_pct来进行设定

1
2
3
[server]
# old区域占LRU链表的比例为20%
innodb_old_blocks_pct = 20

此外,还可以通过(全局)系统变量innodb_old_blocks_pct来进行查看、修改

1
2
3
4
-- 查看(全局)系统变量 innodb_old_blocks_pct 
show global variables like 'innodb_old_blocks_pct';
-- 修改(全局)系统变量 innodb_old_blocks_pct
set global innodb_old_blocks_pct = 20;

当页被从硬盘中加载到内存后,会首先存放到LRU链表的old区域的头部。故即使预读了大量后续没有用到的页,也只会在old区域进行淘汰。而不会冲击到young区域中的缓存页。换言之,通过将LRU拆分为两部分,从而减小了无效预读对Buffer Pool的冲击和影响

现在我们再来看看如何避免全表扫描对Buffer Pool的冲击。对于全表扫描而言,其具备以下几个特点

  1. 正常情况下,发生全表扫描的频率较低。即这次全表扫描后,短时间内不会再发生该表的全表扫描。换言之,将全表扫描加载的页缓存到Buffer Pool的意义并不大
  2. InnoDB中规定每次从页中取一条记录,即认为访问了一次该页面。所以在全表扫描时,InnoDb会认为对该页进行了多次访问
  3. 一般来说,全表扫描时遍历完一个页面中所有记录所需时间是非常少的

虽然全表扫描一开始,会将页缓存到old区域中。但在遍历该页的记录时InnoDB认定将该页进行了多次访问,从而会将其移动到young区域头部。为此,InnoDB引擎提出了一个新的策略:对于old区域中的缓存页而言,会在相应的控制块中记录第一次访问该缓存页的时间。如果后续访问时间在距第一次访问时间指定的时间阈值后,才会将其从old区域移动到young区域的头部。具体地,可通过配置文件中配置参数 innodb_old_blocks_time 进行设置,其单位为毫秒。由于全表扫描遍历完某页的全部记录还是比较快,故结合该策略即可避免全表扫描时对LRU链表的young区域进行冲击

1
2
[server]
innodb_old_blocks_time = 996

此外,亦可通过(全局)系统变量 innodb_old_blocks_time 来设置该时间阈值

1
2
3
4
-- 查看(全局)系统变量 innodb_old_blocks_time
show global variables like 'innodb_old_blocks_time';
-- 修改(全局)系统变量 innodb_old_blocks_time
set global innodb_old_blocks_time= 996;

优化young区域

前面我们提到了young区域中的缓存页都是热数据,那每访问一次该区域的缓存页就将其移动到young区域的头部显然有点频繁。故为了提高性能、减少young区域移动的频率,MySQL规定只有访问的是young区域中靠后部分的缓存页才移动到young头部。具体地,该优化策略的参数值为young区域的后1/4部分

优化并发: 多Buffer Pool实例

当MySQL同时处理多个用户请求时,即多个线程同时访问Buffer Pool中的数据。为了避免出现并发问题,其需要通过加锁等方式来保证线程安全。为了切实提高并发访问Buffer Pool的效率,InnoDB支持同时使用多个Buffer Pool。其中,每一个Buffer Pool都被称之为一个实例。各Buffer Pool实例之间是相互独立的。独立的含义具体是指各实例独立申请内存空间。自然各实例也是独立维护各自的链表(free链表、flush链表、LRU链表等等)。其示意图如下所示

figure 4.jpeg

具体地,可通过配置文件中配置参数 innodb_buffer_pool_instances 来设置Buffer Pool实例的数量

1
2
3
[server]
# 设置4个Buffer Pool实例
innodb_buffer_pool_instances = 4

则每个Buffer Pool实例的内存大小即为 innodb_buffer_pool_size / innodb_buffer_pool_instances。值得一提的是,如果innodb_buffer_pool_size 小于1G,则无法使用多实例的Buffer Pool。即此时MySQL会自动把 innodb_buffer_pool_instances 值调整为1

动态调整容量

在MySQL 5.7.5版本之前,无法在服务运行期间调整Buffer Pool的内存大小。而从MySQL 5.7.5版本开始,其支持通过(全局)系统变量 innodb_buffer_pool_size 在服务运行期间动态调整Buffer Pool所使用的内存大小,其单位为字节

1
2
3
4
-- 查看(全局)系统变量 innodb_buffer_pool_size
show variables like 'innodb_buffer_pool_size';
-- 修改(全局)系统变量 innodb_buffer_pool_size
set global innodb_buffer_pool_size= 8388608;

但是新问题随之而来,每次我们调整了Buffer Pool的内存大小后,MySQL就需要向OS操作系统重新申请一块新的连续内存空间,然后将原来Buffer Pool中的数据复制到这一块新的内存空间下。显然这个操作是非常耗时的。故为了提高动态调整Buffer Pool容量时的性能。MySQL引入了chunk的概念。具体地,每个chunk即表示一块连续的内存空间,而每个Buffer Pool则是以chunk为单位来向OS申请内存的。示意图如下所示,可以看到每个chunk中有若干对控制块、缓存页。值得一提的是,对于一个Buffer Poo实例而言,其内部各chunk中的缓存页均共用同一个链表(free链表、flush链表、LRU链表等)进行管理。换言之,只是将 原先一整块连续内存下的若干对控制块-缓存页 拆分为 多个连续内存的若干对控制块-缓存页

figure 5.jpeg

这样设计的好处就在于。当后续需要扩容时,只需以chunk为单位继续申请新增的内存空间使用即可;当后续需要缩容时,只需按chunk为单位释放相应的内存空间即可。而无需向之前那样,需要对原有缓存页进行复制工作。具体地,可通过配置文件中配置参数 innodb_buffer_pool_chunk_size 来设置每个实例中chunk所使用的内存大小,其单位为字节。换句话说,无法在服务运行期间调整chunk的大小。原因同理,一旦在服务运行期间调整chunk的内存大小就又需要进行缓存页的复制操作了

Note

  1. 在服务启动时,如果相关参数的配置出现 innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances > innodb_buffer_pool_size 的情况,则 innodb_buffer_pool_chunk_size 会被MySQL自动调整为 innodb_buffer_pool_size / innodb_buffer_pool_instances
  2. innodb_buffer_pool_size 必须是 innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances 的整数倍。此举是为了保障每个Buffer Pool实例中的chunk数目是相同的。具体地:
    • 如果启动服务时,前者(innodb_buffer_pool_size)大于后者(innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances)但却并不是整数倍的话,MySQL会自动调整 innodb_buffer_pool_size 以满足整数倍的关系
    • 如果启动服务时,前者小于后者,则根据第1条的规则,调整 innodb_buffer_pool_chunk_size
    • 如果在服务运行时调整 innodb_buffer_pool_size ,前者如果不是后者的整数倍,MySQL会自动调整 innodb_buffer_pool_size 以满足整数倍的关系

查看状态

我们还可以通过下面的SQL语句查看服务运行时InnoDB引擎的状态信息

1
2
-- 查看InnoDB引擎运行的状态信息
show engine InnoDB status;

执行上述命令获取InnoDB引擎的状态信息。这里我们只截取与Buffer Pool相关的部分输出,并针对各统计项给出释义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
...
----------------------
BUFFER POOL AND MEMORY
----------------------
// Buffer Pool向OS申请的连续内存的大小, 包括全部控制块、缓存页及碎片。单位为字节
Total large memory allocated 8585216
// 数据字典所占内存的大小, 该部分内存空间与Buffer Pool无关。单位为字节
Dictionary memory allocated 3783769
// Buffer Pool中缓存页的总数量, 其单位为页
Buffer pool size 512
// Buffer Pool中空闲的、未被使用的缓存页的数量, 其单位为页
Free buffers 239
// LRU链表的young、old区域中缓存页的数量
Database pages 272
// LRU链表的old区域中缓存页的数量
Old database pages 0
// 脏页的数量, 即flush链表中缓存页的数量
Modified db pages 0
// 正在等待从磁盘上加载到Buffer Pool中页的数量
Pending reads 0
// 各同步方式下即将刷新、同步到硬盘中页的数量
Pending writes: LRU 0, flush list 0, single page 0
// 前者(made young)意为: LRU链表中节点从old区域被移动到young区域的数量
// 后者(not young)意为: LRU链表中old区域的节点由于innodb_old_blocks_time时间阈值限制
// 而未被移动到young区域的次数
Pages made young 2, not young 295
// 前者(youngs/s)意为: 每秒从old区域移动到young区域的节点数量
// 后者(non-youngs/s)意为: 每秒中LRU链表old区域的节点由于innodb_old_blocks_time时间阈值限制
// 而未被移动到young区域的次数
0.00 youngs/s, 0.00 non-youngs/s
// 分别意为读取、创建、写入页的数量
Pages read 1972205, created 17494, written 1355086
// 分别意为读取、创建、写入页的速率
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
// Buffer pool hit rate: 过去一段时间内,平均访问1000次页, 有多少次访问该页已经被缓存到Buffer Pool
// young-making rate: 过去一段时间内,平均访问1000次页, 有多少次访问使缓存页被移动到young区域头部
// 包括从young区域尾部(即后1/4部分)移动到young区域头部、从old区域移动到young区域头部
// not: 过去一段时间内,平均访问1000次页, 有多少次访问没用使缓存页被移动到young区域头部
// 包括缓存页由于innodb_old_blocks_time限制依然留在old区域、缓存页未在young区域的尾部(即后1/4部分)
Buffer pool hit rate 1000 / 1000, young-making rate 0 / 1000 not 0 / 1000
// Pages read ahead: 每秒中预读页的数量
// evicted without access: 每秒中页被缓存到Buffer Pool后未被访问就被淘汰的速率
// Random read ahead: 每秒中随机预读页的数量
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
// LRU链表中节点的数量; unzip_LRU链表中节点的数量
LRU len: 272, unzip_LRU len: 0
// I/O sum: 最近50秒从磁盘中读取的页数
// I/O cur: 正在从磁盘中读取的页数
// unzip sum: 最近50秒解压的的页数
// unzip cur: 正在解压的的页数
I/O sum[0]:cur[0], unzip sum[0]:cur[0]
...

参考文献

  1. MySQL是怎样运行的
0%