Session 18 InnoDB的Buffer Pool

当需要访问某个页的数据时,InnoDB存储引擎就会把完整的页的数据全部加载到内存中(即使我们只需要访问一个页的一条记录)。在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。

MySQL服务器启动时向操作系统申请了一片连续的内存,叫做Buffer Pool(中文名是缓冲池)。默认情况下Buffer Pool128M大小,允许用户调整。

1
2
[server]
innodb_buffer_pool_size = 268435456

为了更好的管理这些在Buffer Pool中的缓存页,设计InnoDB的大佬为每一个缓存页都创建了一些所谓的控制信息,包括了【该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及LSN信息】。

控制信息占用的一块内存称为一个控制块控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前面,缓存页被存放到 Buffer Pool 后边。Buffer Pool空间在从内存申请并格式化(划分成若干对控制块和缓存页)后,便呈此结构:

image-20230802232110669

如果Buffer Pool的大小设置的刚刚好的话,也可能不会产生碎片

free链表

为了区分Buffer Pool中哪些缓存页是空闲的,哪些已经被使用了,我们最好在某个地方记录一下Buffer Pool中哪些缓存页是可用的。我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。

刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中。链表的基节点另外储存。每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了。

image-20230802232507297

我们怎么快速知道某页在不在Buffer Pool中呢?可以以表空间号 + 页号作为key缓存页value建立哈希表,直接在表中查找。

flush链表

如果我们修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。

如果每发生一次修改就立即同步到磁盘上对应的页上,则速度很慢。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步。但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道Buffer Pool中哪些页是脏页?可以创建一个存储脏页的链表。

因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。链表的构造和free链表差不多:

image-20230802233416394

LRU链表

当需要缓存的页占用的内存大小超过了Buffer Pool大小,也就是free链表用完时,需要移除一些缓存页。为了提高缓存命中率,我们采用**==LRU==(最近最少使用)算法**,具体的实现方式是LRU链表(LRU的英文全称:Least Recently Used)

当我们需要访问某个页时,可以这样处理LRU链表

  • 如果该页不在Buffer Pool中,在把该页从磁盘加载到Buffer Pool中的缓存页时,就把该缓存页对应的控制块作为节点塞到链表的头部,同时LRU链表的尾部找些缓存页淘汰
  • 如果该页已经缓存在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部。

但是存在两种意外情况:

  1. InnoDB提供了一个看起来比较贴心的服务——预读(英文名:read ahead),
  2. 全表扫描。这意味着Buffer Pool中的所有页都被换了一次血,将严重的影响到其他查询对 Buffer Pool的使用,从而大大降低了缓存命中率。

总结一下就是:

  • 加载到Buffer Pool中的页不一定被用到。
  • 如果非常多的使用频率偏低的页被同时加载到Buffer Pool时,可能会把那些使用频率非常高的页从Buffer Pool中淘汰掉。

因为有这两种情况的存在,所以设计InnoDB的大佬把这个LRU链表按照一定比例分成两截,分别是:

  • 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做==热数据==,或者称young区域
  • 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做==冷数据==,或者称old区域

image-20230802234217433

我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。系统变量innodb_old_blocks_pct的值用来确定old区的占比。

当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool却不进行后续访问的页面就会被逐渐从old区域逐出,而不会影响young区域中被使用比较频繁的缓存页。

但这样还不能处理全表扫描的问题。按规定,每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。

对应的措施是,在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time控制的,默认1000ms。

综上所述,正是因为将LRU链表划分为youngold区域这两个部分,又添加了innodb_old_blocks_time这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制。

更进一步优化LRU链表

如果被访问的缓存页位于young区域的前1/4,那就没必要移动到头部了。这样可以减少移动开销。

其他链表

比如unzip LRU链表用于管理解压页,zip clean链表用于管理没有被解压的压缩页,等等。关于压缩页不深入介绍。

刷新脏页到磁盘

后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正常的请求。主要有两种刷新路径:

多个Buffer Pool实例

多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理什么的,在Buffer Pool特别大而且多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,我们可以把它们拆分成若干个小的Buffer Pool,每个Buffer Pool都称为一个==实例==,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,独立的等等,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。我们可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数,比方说这样:

1
2
[server]
innodb_buffer_pool_instances = 2

image-20230803110609036

我们可以通过指定innodb_buffer_pool_instances来控制Buffer Pool实例的个数,每个Buffer Pool实例中都有各自独立的链表,互不干扰。当innodb_buffer_pool_size的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。

MySQL 5.7.5版本之后,可以在服务器运行过程中调整Buffer Pool大小。每个Buffer Pool实例由若干个chunk组成,每个chunk的大小可以在服务器启动时通过启动参数调整。

可以用下面的命令查看Buffer Pool的状态信息:

1
SHOW ENGINE INNODB STATUS;

包含了缓存、读取、刷新的页以及各链表节点等等的统计信息。