跳到主要内容

InnoDB 之 BufferPool 详解

· 阅读需 14 分钟

在 InnoDB 存储引擎中,为了减少磁盘 IO 次数、提高服务性能,所有 DML 操作都是在内存中的 BufferPool 里完成的,再由异步刷盘策略把修改的数据落盘。本文详细梳理 BufferPool 的数据结构、页管理方式,以及淘汰和刷盘策略。

BufferPool 官方文档:

MySQL :: MySQL 8.0 Reference Manual :: 15.5.1 Buffer Pool

为什么需要设计 BufferPool?有计算机基础的人都知道,内存和硬盘的效率是天差地别的,对 CPU 来说硬盘的传输与响应时间太低下。所以 MySQL 设计出 BufferPool 来映射磁盘中的真实数据,在内存中完成数据的管理与操作,DML 语句的执行效率自然就起飞了。至于如何保障 BufferPool 中的数据在宕机、掉电的场景下不丢失(物理损坏这种就没办法了,硬盘都给你扬了),之前我的文章中也有讲解:

MySQL 执行链路

Buffer Pool 简介

InnoDB 中的数据访问以 Page 为单位,每个 Page 默认大小为 16KB,Buffer Pool 就是用来管理和缓存这些 Page 的。InnoDB 将一块连续的内存划分给 Buffer Pool 使用,并将其拆分为多个 Buffer Pool Instance 来更好地管理这块内存。每个 Instance 的大小相等,通过算法保证一个 Page 只会落在某个特定的 Instance 中,这种多 Instance 的模式提升了 Buffer Pool 的并发性能。

每一个 Buffer Pool Instance 内部都会维护自己的一套管理结构。InnoDB 以 16KB Page 为单位把数据从磁盘文件读取到内存,并通过一个 LRU List 来缓存这些 Page:经常访问的 Page 在 LRU List 的前面,不经常访问的在后面。访问一个 Page 时,先从 Buffer Pool 中查找,未命中则读磁盘数据文件,取到 Page 后放入 LRU List;当一个 Instance 中没有可用的空闲 Page 时,会对 LRU List 中的 Page 进行淘汰。

Buffer Pool 中还夹杂了不少 Page 压缩的逻辑,即把实际的 16KB Page 压缩为 8KB、4KB、2KB、1KB,这部分逻辑本文暂时跳过,先按默认 16KB Page 来梳理主干逻辑。

目前来看 BufferPool 是一个分层的结构:

  • BufferPool 由多个 Buffer Pool Instance 组成。
  • Buffer Pool Instance 由多个 Buffer Chunk 组成(默认每个 Buffer Chunk 是 128MB),Buffer Chunk 管理一片连续的内存区域,叫 Buffer Chunk Memory。
  • Buffer Chunk 由多个 Buffer Page 组成(简称 Page,默认 16KB)。

这种分层结构是为了降低高并发下 mutex 的竞争。

本文主要讲解 BufferPool 中的数据结构、管理方式以及淘汰、刷盘策略。首先看官方给出的 LRU 数据缓冲结构图:

Buffer Pool 的默认总大小是 128M,可以通过 innodb-buffer-pool-size = xxxxxxx 按需修改(官方建议专用数据库服务器上可以给到物理内存的大部分)。它分为 New Sublist 与 Old Sublist,是不是想到了 JVM 中的新生代和老年代?意思确实差不多:New Sublist 存放最频繁活跃的数据,Old Sublist 存放不经常访问的数据,Old Sublist 默认占 BufferPool 3/8 的比例。

由于内存资源稀缺,不可能把所有磁盘表数据都加载到内存,需要一定的淘汰策略。BufferPool 采用的是 LRU 算法加上 MySQL 自己的变种逻辑来实现内存数据淘汰,也被称为冷热数据处理:经常访问的数据向 New Sublist 区域移动,不经常访问的数据向 Old Sublist 移动,内存占用过高时优先淘汰 Old Sublist 中的数据。新读取的页会放到 Old Sublist 的 Head 位置,如果这个页经常被访问就会移动到 New Sublist 中,否则继续待在 Old Sublist。

BufferPool 内部还有一块区域叫做 ChangeBuffer,如图:

ChangeBuffer 用来缓存那些目标页不在 BufferPool 中的 DML 变更,避免每次修改都先把页从磁盘读上来,等对应的页后续被读入内存时再合并这些变更,从而减少随机 IO。默认情况下 ChangeBuffer 占 BufferPool 25% 的比例,最大可以调整至 50%。

比较完整的 BufferPool 结构图(还有一些链表没有画出来,例如压缩链表):

在开始梳理之前,有些前置概念需要了解:

  • 数据页:已经加载了磁盘数据的页。
  • 脏页:已经做了修改操作的页,需要刷回磁盘。
  • 空白页:没有加载数据的页。
  • 控制块:各个管理链表中节点存储的 BufferPool 页指针。
  • 缓冲页:统一表示 BufferPool 中的页(页大小都为 16KB)。

设置多个 Buffer Pool Instance

MySQL 服务器启动的时候,会向操作系统申请 BufferPool 的内存空间。多线程场景下,各个链表都需要加锁处理,当 BufferPool 特别大、并发访问量又特别高时,单一的 BufferPool 会影响处理速度。所以会把 BufferPool 分成多个小的 BufferPool,称为 Buffer Pool Instance,它们各自独立申请内存空间、独立管理链表,多线程访问时互不影响。可以通过参数修改实例数量:

# 创建 2 个 buffer pool 实例
innodb_buffer_pool_instances = 2

# 每个实例占用的内存 = 总大小除以实例数
# innodb_buffer_pool_size / innodb_buffer_pool_instances

因为创建和管理多个实例本身也有开销,MySQL 官方规定:当 innodb_buffer_pool_size 在 1G 以下时默认只有一个实例,设置多个也是无效的,只有大于 1G 时才鼓励设置多个实例。

Buffer Pool 对页的管理

数据页在 BufferPool 中不是胡乱存放的,而是由哈希表和若干个链表组织起来,以便支持前文介绍的各种操作。链表中的节点内容就是 Page Descriptor:

  1. page hash(快速访问):所有的页都由一张哈希表组织,叫 page hash,用于在 buffer pool 中快速访问到一个页,page hash 的 key 就是 page id。
  2. free list(页的申请):当需要把页载入 buffer pool 时,需要为其分配一个 buf_block_t 作为 page descriptor。如何找到空闲未被占用的 buf_block_t?它们都挂在 free list 上。
  3. LRU list(页的淘汰):buffer pool 的空间有限,当没有空间存放从磁盘载入的页时,需要把现有的一些页淘汰掉,使用的就是 LRU 算法。
  4. flush list(脏页写回):当一个页变成脏页时,需要 page cleaner 线程定期将其写回磁盘,这些脏页都在 flush list 上。

Free List 链表管理

最初启动 MySQL 服务器时,需要完成 BufferPool 的初始化:先向操作系统申请 BufferPool 的内存空间,然后把它划分成若干对控制块和缓冲页。此时并没有真实的磁盘页被缓存到 BufferPool 中(因为还没用到),之后随着程序运行,才会不断有磁盘上的页被缓存进来。那么问题来了:从磁盘读取一个页到 BufferPool 时,该放到哪个缓冲页的位置?或者说,怎么区分 BufferPool 中哪些缓冲页是空闲的、哪些已经被使用了?

最好在某个地方记录下哪些缓冲页是可用的,这时缓冲页对应的控制块就派上了大用场:把所有空闲缓冲页对应的控制块作为节点放到一个链表中,这个链表就称为 Free 链表(空闲链表)。刚完成初始化的 BufferPool 中所有缓冲页都是空闲的,所以每个缓冲页对应的控制块都会加入 Free 链表。

为了更好地管理 Free 链表,这里特意定义了一个基节点,包含链表的头节点地址、尾节点地址,以及当前链表中节点的数量等信息。需要注意的是,基节点占用的内存并不包含在为 BufferPool 申请的那一大片连续内存之内,而是单独申请的一块内存空间。

有了 Free 链表之后事情就好办了:每当需要从磁盘加载一个页到 BufferPool 时,就从 Free 链表中取一个空闲的缓冲页,把该缓冲页对应控制块的信息填上(该页所在的表空间、页号之类的信息),然后把这个控制块从 Free 链表中移除,表示该缓冲页已经被使用了。这里要清楚,我们真正从链表中获取的是控制块,通过控制块才能访问到真正的页。同理,"遍历 BufferPool 中的缓冲页"实际是"遍历各个缓冲页对应的控制块"。

总结:只要管理好了 Free 链表,就知道 BufferPool 中有哪些空闲的页。

Page Hash 缓冲页的哈希处理

当需要访问某个页中的数据时,会把该页从磁盘加载到 BufferPool 中;如果该页已经在 BufferPool 里,直接使用就可以了。那么问题来了:怎么知道该页在不在 BufferPool 中?难不成要依次遍历各个缓冲页?一个 BufferPool 中缓冲页那么多,都遍历一遍岂不是要累死?

回头想想,我们其实是根据表空间号 + 页号来定位一个页的,相当于表空间号 + 页号是一个 Key,缓冲页控制块就是对应的 Value。通过 Key 快速找到 Value,用的自然是哈希表。

所以可以用表空间号 + 页号作为 Key、缓冲页控制块的地址作为 Value 来创建一个哈希表。需要访问某个页的数据时,先根据表空间号 + 页号查哈希表:如果有对应的缓冲页,直接使用;如果没有,就从 Free 链表中选一个空闲缓冲页,把磁盘中对应的页加载到该缓冲页的位置。

LRU List 链表管理

LRU 链表是对 BufferPool 缓冲区空间的一种管理方法。想想看,如果一直往缓冲区加载数据页,迟早有一天缓冲区会被装满,这时肯定要有一个机制来释放那些没用的数据页,这个机制就是 LRU 算法(最近最少使用算法)。

可以把该算法看成一个链表,这个链表分为两个部分(MySQL 的变种):一部分存储使用频率非常高的缓冲页,也就是热数据,称为 Young 区域(New Sublist);另一部分存储使用频率不高的缓冲页,也就是冷数据,称为 Old 区域(Old Sublist)。InnoDB 按比例把 LRU 链表分成两截,可以通过参数 innodb_old_blocks_pct 查看 Old 区域所占的比例。

有了这两个区域,InnoDB 设计者就可以针对 BufferPool 命中率的情况做优化了:

  • 针对预读的页面可能不进行后续访问的优化。设计者规定,当磁盘上的某个页面初次加载到 BufferPool 的某个缓冲页时,该缓冲页对应的控制块会放到 Old 区域的头部。这样一来,预读到 BufferPool 却没有后续访问的页面会逐渐从 Old 区域被逐出,而不会影响 Young 区域中使用频繁的缓冲页。
  • 针对全表扫描时短时间内访问大量低频页面的优化。全表扫描时,虽然首次加载的页放到了 Old 区域头部,但后续会被马上访问到,每次访问又会把该页放到 Young 区域头部,这样仍然会把那些使用频率高的页面排挤下去。设计者认为,全表扫描过程中即使某个页面中有很多条记录、每读一条记录都算访问一次页面,这个过程花费的时间也非常短。所以规定:对某个处于 Old 区域的缓冲页进行第一次访问时,在它对应的控制块中记录下访问时间;如果后续的访问时间与第一次访问的时间在某个间隔之内,该页面就不会从 Old 区域移动到 Young 区域头部,否则才移动。这个间隔时间由系统变量 innodb_old_blocks_time 控制。

Flush List 链表管理

如果修改了 BufferPool 中某个缓冲页的数据,它就与磁盘上的页不一致了,这样的缓冲页称为脏页。当然,可以每次修改完就立即刷新到磁盘对应的页上,但频繁写磁盘会严重影响性能,所以每次修改缓冲页后并不急着刷盘,而是在未来的某个时间点再刷新。

但如果不立即刷盘,之后刷盘时怎么知道 BufferPool 中哪些页是脏页、哪些是从没被修改过的页?所以不得不再创建一个存储脏页的链表:凡是被修改过的缓冲页对应的控制块,都会作为节点加入这个链表。因为这些节点对应的缓冲页都需要被刷新到磁盘上,所以称为 Flush 链表。Flush 链表的构造与 Free 链表差不多。另外,如果一个缓冲页是空闲的,它肯定不是脏页;如果是脏页,肯定不空闲——也就是说,某个缓冲页对应的控制块不可能既是 Free 链表的节点又是 Flush 链表的节点,只能处于其中一个链表中。

总结:Free 链表可以简单理解为缓冲区中所有空闲页组成的链表,Flush 链表则是所有被修改过的页(脏页)组成的链表。

脏页刷盘机制

脏页在以下几种情况下会被刷回磁盘:

1)Redo Log 写不下了。Redo Log 作为持久化的保障,每次更新操作都必须记录,如果 Redo Log 写不下了,就必须刷页,把数据同步到磁盘,然后移动 Redo Log 的 CheckPoint 指针空出新的位置。此时如果内存不紧张,刷完的页不需要淘汰出内存。

2)内存放不下数据页了。查询操作会把磁盘中的数据页读入内存再返回,如果内存放不下了,就必须淘汰最久未使用的数据页;如果被淘汰的是脏页,必须先刷进磁盘再淘汰。

3)数据库空闲时。如果数据库认为当前处于空闲状态,就会对脏页进行刷盘,此时内存如果不紧张,刷完后不进行淘汰。

4)数据库正常关闭时。关闭时也会进行刷页,把之前的修改持久化到硬盘。

小结

BufferPool 是 InnoDB 用内存换 IO 的核心设计:数据以 16KB 的 Page 为单位缓存在内存中,DML 操作先改内存、再异步落盘。为降低并发下的锁竞争,它被拆分为多个 Instance,内部再通过 Page Hash 实现快速定位,Free、LRU、Flush 三条链表分别负责空闲页分配、冷热数据淘汰与脏页写回。LRU 的 New/Old 分区加上 innodb_old_blocks_time 的时间窗口,解决了预读和全表扫描对热数据的冲刷问题。理解了这几条链表的协作方式,BufferPool 的整体运作机制也就清晰了。

评论 / COMMENTS