InnoDB 之 BufferPool 详解

BufferPool官方文档:

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

在InnoDB存储引擎中,为了提高MySQL服务性能,减少磁盘IO次数,故此所有的DML操作均是在内存中的 BufferPool 中完成。最后通过服务的异步刷盘策略把修改数据落盘到磁盘中。

为什么需要设计 BufferPool ?这里我想一般有计算机基础知识的人都知道,内存和硬盘的效率是天差地别的,对于CPU来说硬盘的传输以及响应时间性能太低下,故此 MySQL 设计出了 BufferPool 来映射磁盘中的真实数据,并在内存中进行数据的管理与操作,这样执行DML语句的效率不就起飞了吗?至于是如何保障内存中的 BufferPool 数据在宕机,掉电的场景不会出现丢失(物理打击除外😃,大力出奇迹,硬盘都给你扬了),之前我的文章中也有所讲解:

MySQL 执行链路
当客户端SQL语句提交给 MySQL 服务器时首先需要建立连接,连接器会与客户端进行连接,并校验用户身份信息,权限信息,如果权限通过,则通过SQL分析器对SQL语句进行语义分析(检查SQL语句是否合法,能否解析通过),分析完成后将会对SQL的执行过程进行优化,例如剔除不必要的查询条件 1=1 ,以及选择最优的索引与Where条件的字段排序。 然后交给执行器执行此SQL语句,执行器将会根据数据库所使用的存储引擎执行SQL。MySQL在设计时是分为了两层,即Server层和存储引擎层。这样做的好处就是解藕,可以根据不同的数据库去选择合适的存储引擎。 常用的InnoDB存储引擎是以页(16KB)…

Buffer Pool简介

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

在每一个Buffer Pool Instance中,实际都会维护一个自己的Buffer Pool模块,InnoDB通过16KB Page的方式将数据从磁盘文件中读取到内存的Buffer Pool中,并通过一个LRU List来缓存这些Page,经常访问的Page在LRU List的前面,不经常访问的Page在后面。InnoDB访问一个Page时,首先会从Buffer Pool中获取,如果未找到,则会访问磁盘数据文件,读取到Page,并将其Put 到 LRU List中,当一个Instance的Buffer Pool中没有可用的空闲Page时,会对LRU List中的Page进行淘汰。

由于Buffer Pool中夹杂了很多Page压缩的逻辑,即将实际的16KB Page压缩为8KB、4KB、2KB、1KB,这块逻辑暂时先跳过不去做分析,我们先按照默认Page就是16KB的逻辑来梳理Buffer Pool相关的逻辑。

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

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

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

这么本文主要是详细讲解 BufferPool 中的数据结构,管理以及它的淘汰,刷盘策略,首先我们来看官方给出的 LRU 数据缓冲结构图:

Buffer Pool 它的默认总大小是128M,这个默认大小是可以通过命令 innodb-buffer-pool-size = xxxxxxx 按照需求进行修改(官方推荐物理机的70%),并分为了 New Sublist 与 Old Sublist,是不是想到了JVM 中的新生代和老年代呢,其实意思真差不多,在 BufferPool 中 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 的作用就是加快 RedoLog 的性能,用来记录事物开启后DML操作的具体数据信息进行缓存,然后在事物提交时同步刷盘到 RedoLog 日志进行持久化存储(顺序写入),从而减少 RedoLog 的写IO次数。默认情况下 ChangeBuffer 占据 BufferPool 百分之25的比例,最大可以调整至50%。

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

在开始梳理之前有些前置知识点需要了解:

数据页:已经加载了磁盘数据的页。

脏页:已经做了修改操作的页,需要刷到磁盘。

空白页:没有加载数据的页。

控制块:各个管理链表中节点存储的BufferPool页指针。

缓冲页:统一表示BufferPool中的页(页的大小都为16KB)。

设置多个Buffer Pool Instance

上面说过,Mysql服务器启动的时候,就会根系统申请BufferPool的内存空间,在多线程的情况下,各个链表都需要加锁进行处理,但在BufferPool特别大,并且多线程访问量也别高的情况下,单一的BufferPool会影响处理速度。所以会吧BufferPool会分成各种小的BufferPool,这些称为Buffer Pool Instance,他们都是独立去申请内存空间,独立管理的链表,并且在多线程访问的情况下互不影响,可以通过innodb_buffer_pool_instance的值来修改buffer pool可以创建几个实例。

innodb_buffer_pool_instances = 2

表示我们需要两个 buffer_pool 实例。

那么每个 pool_instance 占多少内存呢,其实就是我们之前的总数除一下

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中时,该放到哪个缓冲页的位置呢?或者说怎么区分BufferPool中哪些缓冲页是空闲的,哪些已经被使用了呢?我们最好在某个地方记录BufferPool中哪些缓冲页是可用的。这个时候缓冲页对应的控制块就排上了大用场了,我们可以把所用空闲的缓冲页对应的控制块作为一个节点放到一个链表中,这个链表页可以称为Free链表(或者说空闲链表)。刚刚完成初始化的BufferPool中,所有的缓冲页都是空闲的,所以每一个缓冲页对应的控制块都会加入到Free链表中。

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

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

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

Page Hash 缓冲页的哈希处理

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

再回头想想,我们其实是根据表空间号+页号来定位一个页的,也就相当于表空间+页号是一个Key(键),缓冲页控制块就是对应的Value(值)。怎么通过一个key来快速找到一个Value呢?  这里用的是哈希表。

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

LRU List 链表管理

LRU链表其实是对BufferPool缓冲区空间的一个管理方法,你可以想想看,如果一直加载数据页到缓冲区中,迟早有一天缓冲区会被加载满了的,这时肯定有一个机制用来释放那么没用的数据页的,该机制就是LRU算法(最近最少使用算法)

我们可以把该算法看成一个链表,该链表分为两个部分(Mysql变种),一部分是用来存储使用频率非常高的缓冲页,这一部分链表也被称为热数据,或者称为Young区域( New Sublist ),另一部分是用来存储使用频率不是很高的缓冲页,这一部分链表被称为冷数据,或者称为Old区域( Old Sublist )。

innodb设计通过对应比例将LRU链表分为两半,这里可以通过参数innodb_old_blocks_pct 看到旧的链表所占的比例情况,

有了这两个区域,InnoDB 设计者就可能针对BufferPool命中率的情况进行优化了。

  • 针对预读的页面可能不进行后续访问的优化。设计者规定,当磁盘上的某个页面在初次加载到BufferPool中某个缓冲页时,该缓冲页对应的控制块会放到Old区域的头部。这样一来,预读到BufferPool却不进行后续的页面的访问就会被逐渐从Old区域逐出,而不会影响Young区域中使用比较频繁的缓冲区。
  • 针对全表扫描时,短时间内访问大量使用频率非常低的页面进行优化。在进行全表扫描时,虽然首次加载到BufferPool中的页放到了Old区域的头部,但是后续会被马上访问到,每次进行访问时又会把该页放到Young区域的头部,这样仍然会把哪些使用频率比较高的页面给排挤下去。因此设计者认为在执行全表扫描的过程中,即使某个页面中有很多条记录,尽管每读取一条记录都算是访问一次页面,但是这个过程所花费的时间也是非常少的。所以我们只需要规定,在对某个处于Old区域的缓冲页进行第一个访问时,就在它对应的控制块中记录下这个访问时间,如果后续的访问时间与第一次访问的时间再某个时间间隔内,那么该页面就不会从Old区移动到Young区域的头部,否则将它移动到Young区域的头部,这个间隔时间是由系统变量innodb_old_bolcks_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、数据库正常关闭时。数据库关闭的时候,也会进行刷页,将之前做的修改持久化到硬盘。

文章目录

随心笔记

技术无止境 创新不停驻