我们知道在MySQL中,内存与硬盘之间进行交互是页为单位。为了避免频繁从硬盘中加载数据,故在InnoDB存储引擎中提出了Buffer Pool缓冲池的概念
内存结构
前面提到为了避免频繁从硬盘中加载数据到内存中,InnoDB引擎提出了Buffer Pool缓冲池的概念。在每次使用完内存中的页数据后,并不会立即将该页所占用的内存空间释放掉,而是将其放在Buffer Pool缓冲池中,这样下次再需要访问使用该页时,直接从Buffer Pool中获取即可。而无需再从硬盘中重新加载了,可以看到此举将会大大提高访问性能
存放在Buffer Pool中的页,称此为缓存页。其大小与磁盘中默认的页大小一致,均为16KB。与此同时,每个缓存页都有一个相应的控制块,用于存放该缓存页的描述信息。其在内存中的结构如下所示。可以看到,Buffer Pool是使用了一块连续的内存空间,其中控制块位于前面,缓存页位于后面。在MySQL对Buffer Pool进行初始化时,即会提前划分好各控制块及其相应的缓存页的内存位置。故当剩余的内存空间不足划分出新的控制块、缓存页时,即会出现所谓的碎片
关于Buffer Pool的大小,可在启动MySQL前通过innodb_buffer_pool_size参数进行配置,其单位为字节
1 | [server] |
Note
- Buffer Pool的大小最低为5M。当小于该值时,MySQL会自动设置为5M
- 事实上,该配置参数并不包含各控制块所使用的内存空间。故一般地,Buffer Pool所使用的内存空间会比该配置参数的值稍微大一点
管理Buffer Pool
free链表
前面我们提到,在初始化的过程中即会将各缓存页划分完毕。那问题来了,当我们需要将用完后的页存放到Buffer Pool中时,该如何知道Buffer Pool中哪个缓存页是空闲的、未被使用的呢?其实很简单,MySQL将所有空闲缓存页所对应的控制块连接起来,建立了一个所谓的free链表。具体地,可通过基节点来实现对free链表的管理,其内部含有链表的头节点指针、尾节点指针、链表中节点数量。而在每个控制块内部则含有free链表的上一个、下一个节点的指针。其结构示意图如下所示
这样只需通过free链表即可很方便地获取一个未被使用的缓存页了。值得一提的是,正如Buffer Pool的结构示意图所示,链表的基节点并不在Buffer Pool所使用的内存空间之中,而是单独分配的
flush链表
如果Buffer Pool中某个缓存页中的数据记录被修改了,则其即会与硬盘中页就会出现数据不一致的问题,该缓存页也被称为Dirty Page脏页。故为了保证数据一致性,我们需要将脏页同步回硬盘。而为了便于统一同步,我们对这些脏页的控制块也建立了一个链表进行管理,即所谓的flush链表。其在结构上与上文的free链表基本一致,故此处就不再赘述了
LRU链表
对于已使用的缓存页而言,其亦是通过链表进行管理的。该链表即被称为LRU链表。由于Buffer Pool的内存空间毕竟有限,故需要使用某种内存淘汰策略。顾名思义,其对已使用的缓存页的淘汰策略是LRU(Least Recently Used,最近最少使用)算法。具体地,对LRU链表的管理规则如下
- MySQL访问某页数据时,如果该页不在Buffer Pool中。则首先从硬盘中进行加载该页;然后找到一个空闲的缓存页用于存放该页;最后,将该缓存页对应的控制块插入到LRU链表的头部
- MySQL访问某页数据时,如果该页在Buffer Pool中。则会将该缓存页对应的控制块移到LRU链表的头部
至此,大家可以看到。当Buffer Pool中无空闲缓存页可用时,只需从LRU链表的尾部淘汰即可
哈希表
在维护LRU链表时,我们需要先判断某页是否已经缓存到Buffer Pool中了。为了避免遍历,其使用了哈希表来提高效率。具体地,是以表空间ID+页号作为Key,而Value即为Buffer Pool中相应的缓存页
同步Dirty Page脏页
前面提到Buffer Pool通过flush链表来对Dirty Page脏页进行管理。具体地,脏页同步到硬盘中有以下几种方式
- BUF_FLUSH_LIST方式:后台线程定时将flush链表中的部分脏页同步到硬盘中
- BUF_FLUSH_LRU方式:后台线程定时从LRU链表的尾部开始扫描一定数量的缓存页,如果发现脏页则会同步到硬盘中。扫描缓存页的数量可通过(全局)系统变量innodb_lru_scan_depth进行控制
- 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区域,用于存放访问频率较低的缓存页(即冷数据)。示意图如下所示
而old区域占LRU链表的比例,可通过配置文件中配置参数innodb_old_blocks_pct来进行设定
1 | [server] |
此外,还可以通过(全局)系统变量innodb_old_blocks_pct来进行查看、修改
1 | -- 查看(全局)系统变量 innodb_old_blocks_pct |
当页被从硬盘中加载到内存后,会首先存放到LRU链表的old区域的头部。故即使预读了大量后续没有用到的页,也只会在old区域进行淘汰。而不会冲击到young区域中的缓存页。换言之,通过将LRU拆分为两部分,从而减小了无效预读对Buffer Pool的冲击和影响
现在我们再来看看如何避免全表扫描对Buffer Pool的冲击。对于全表扫描而言,其具备以下几个特点
- 正常情况下,发生全表扫描的频率较低。即这次全表扫描后,短时间内不会再发生该表的全表扫描。换言之,将全表扫描加载的页缓存到Buffer Pool的意义并不大
- InnoDB中规定每次从页中取一条记录,即认为访问了一次该页面。所以在全表扫描时,InnoDb会认为对该页进行了多次访问
- 一般来说,全表扫描时遍历完一个页面中所有记录所需时间是非常少的
虽然全表扫描一开始,会将页缓存到old区域中。但在遍历该页的记录时InnoDB认定将该页进行了多次访问,从而会将其移动到young区域头部。为此,InnoDB引擎提出了一个新的策略:对于old区域中的缓存页而言,会在相应的控制块中记录第一次访问该缓存页的时间。如果后续访问时间在距第一次访问时间指定的时间阈值后,才会将其从old区域移动到young区域的头部。具体地,可通过配置文件中配置参数 innodb_old_blocks_time 进行设置,其单位为毫秒。由于全表扫描遍历完某页的全部记录还是比较快,故结合该策略即可避免全表扫描时对LRU链表的young区域进行冲击
1 | [server] |
此外,亦可通过(全局)系统变量 innodb_old_blocks_time 来设置该时间阈值
1 | -- 查看(全局)系统变量 innodb_old_blocks_time |
优化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链表等等)。其示意图如下所示
具体地,可通过配置文件中配置参数 innodb_buffer_pool_instances 来设置Buffer Pool实例的数量
1 | [server] |
则每个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 | -- 查看(全局)系统变量 innodb_buffer_pool_size |
但是新问题随之而来,每次我们调整了Buffer Pool的内存大小后,MySQL就需要向OS操作系统重新申请一块新的连续内存空间,然后将原来Buffer Pool中的数据复制到这一块新的内存空间下。显然这个操作是非常耗时的。故为了提高动态调整Buffer Pool容量时的性能。MySQL引入了chunk的概念。具体地,每个chunk即表示一块连续的内存空间,而每个Buffer Pool则是以chunk为单位来向OS申请内存的。示意图如下所示,可以看到每个chunk中有若干对控制块、缓存页。值得一提的是,对于一个Buffer Poo实例而言,其内部各chunk中的缓存页均共用同一个链表(free链表、flush链表、LRU链表等)进行管理。换言之,只是将 原先一整块连续内存下的若干对控制块-缓存页 拆分为 多个连续内存的若干对控制块-缓存页
这样设计的好处就在于。当后续需要扩容时,只需以chunk为单位继续申请新增的内存空间使用即可;当后续需要缩容时,只需按chunk为单位释放相应的内存空间即可。而无需向之前那样,需要对原有缓存页进行复制工作。具体地,可通过配置文件中配置参数 innodb_buffer_pool_chunk_size 来设置每个实例中chunk所使用的内存大小,其单位为字节。换句话说,无法在服务运行期间调整chunk的大小。原因同理,一旦在服务运行期间调整chunk的内存大小就又需要进行缓存页的复制操作了
Note
- 在服务启动时,如果相关参数的配置出现 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
- 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 | -- 查看InnoDB引擎运行的状态信息 |
执行上述命令获取InnoDB引擎的状态信息。这里我们只截取与Buffer Pool相关的部分输出,并针对各统计项给出释义
1 | ... |
参考文献
- MySQL是怎样运行的