MySQL--InnoDB Buffer Pool详解

·  阅读 4924

0 疑惑

  1. 在刷脏页的时候,redo log 中记录的对应的数据页应该怎么处理?
  2. InnoDB 怎么判断一个页是脏页还是干净页?一些文章有说使用 LSN 与 Checkpoint 进行比对的,但个人理解为什么不用数据页中存储的 LSN 进行判断?
  3. LRU List 中的脏页驱逐与 FLU List 的刷脏有什么联系?LRU List 中的脏页被驱逐后,FLU List 如何摘掉此节点将其加入 Free List?

1 引言

目前 MySQL 默认的存储引擎是 InnoDB,不同的存储引擎有不同的实现。不知道大家在学习 MySQL 数据库相关知识的时候,有没有这样的疑惑:如果读写数据每次都直接操作磁盘的话,数据库的性能应如何保证?

要搞清楚这两个问题,就必须理解 InnoDB Buffer Pool。

2 简介

MySQL InnoDB Buffer Pool,MySQL InnoDB 缓冲池。里面缓存着大量数据(数据页),使 CPU 读取或写入数据时,不直接和低速的磁盘打交道,直接和缓冲区进行交互,从而解决了因为磁盘性能慢导致的数据库性能差的问题。

3 详述

3.1 数据页

InnoDB 中,数据管理的最小单位为页,默认是 16KB。

关于数据页的详细介绍,推荐大家看这篇文章,写的特别好:InnoDB数据页结构

3.2 Buffer Pool的工作机制

如上所述,buffer pool 最主要的功能便是加速读和加速写。

加速读就是当需要访问一个数据页的时候,如果这个页已经在缓存池中,那么就不再需要访问磁盘,直接从缓冲池中就能获取这个页面的内容。

加速写就是当需要修改一个数据页的时候,先将这个页在缓冲池中进行修改,记下相关的 redo log,这个页的修改就算已经完成了。至于这个被修改的页什么时候真正刷新到磁盘,这个是 buffer pool 后台刷新线程来完成的。

在实现上面两个功能的同时,需要考虑客观条件的限制,因为机器的内存大小是有限的。通常情况下,当数据库的数据量比较大的时候,缓存池并不能缓存所有的数据页,所以也就可能会出现,当需要访问的某个页时,该页却不在缓存池中的情况,这个时候就需要从磁盘中将这个页读出来,加载到缓存池,然后再去访问。这样就涉及到随机的物理 IO,也就增加了操作页所消耗的时间。

这样的情况是一个 bad case,是需要尽量避免的——因此需要想办法来提高缓存的命中率。innodb buffer pool 采用经典的 LRU 算法来进行页面淘汰,以提高缓存命中率。与传统的 LRU 算法相比,buffer pool 中的 LRU 列表其中间位置被打了一个 old 标识,可以简单的理解为将 LRU 列表分为两个部分,这个标记到 LRU 列表头部的数据页称为 young 数据页池,这个标志到 LRU 列表尾部的数据页称之为 old 数据页池。当一个页从磁盘上加载到缓存池的时候,会将它放在 old 标识之后的第一个位置,也就是说放在了 old 池子中(“中点插入策略”)。这个机制保证了在做大表的一次性全表扫描时,即使有大量新进来的数据页,也会被存放在 old 池子中,当 old 池子的大小不够缓存新进来页面的时候,也只是在 old 池子中进行循环冲洗,这样就不会冲洗 young 池子中的热点页,从而保护了热点页。这就是 buffer pool LRU 算法的简单机制。

3.3 基础知识

简单介绍了 buffer pool 的工作机制,再来看看 Buffer Pool Instance 的概念以及 buffer pool 里面最重要的几个链表。链表中的节点并不直接是数据页,而是数据页的控制体(控制体中的指针指向真正的数据页)。

3.3.1 Buffer Pool Instance

Buffer Pool 实例,大小等于 innodb_buffer_pool_size / innodb_buffer_pool_instances,innodb_buffer_pool_instances 的大小可配置。每个 Buffer Pool Instance 都有自己的锁,信号量,物理块。即各个 instance 之间没有竞争关系,可以并发读取与写入。如果 innodb_buffer_pool_size 大小小于 1G,将只有一个 instance。

3.3.2 Buffer Pool Chunk

Buffer Pool Instance 由若干个 chunk 组成,一个 chunk 就是一片连续的空间,每个 chunk 的大小默认为 128MB,最小为 1MB,且这个值在 8.0 中是可以动态调整生效的。Buffer Chunk 是最底层的物理块,在启动阶段由操作系统申请,直到数据库关闭才释放。Buffer chunk 主要存储数据页和数据页控制体。如下图:

设计数据页控制体的主要目的是为了方便管理数据页,控制体中有指针指向数据页。InnoDB 为每一个数据页都创建了一些所谓的控制信息,数据页控制体和数据页是一一对应的。这些控制信息包括该页所属的表空间编号、页号、页在 Buffer Pool 中的地址等。每个数据页对应的控制信息占用的内存大小是相同的。

马上要讲到的有关 buffer pool 中的几个链表,其节点实际上都是数据页控制体。

3.3.3 Free List

当最初启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化(分配 Buffer Pool 的内存空间),把它划分成若干对控制块和缓存页。此时并没有真实的磁盘页被缓存到 Buffer Pool 中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。

因为刚刚完成初始化的 Buffer Pool 中所有的数据页都是空闲的,所以每一个数据页都会被加入到 Free List 中,假设该 Buffer Pool 中可容纳的数据页数量为 n,那增加了 Free List 的效果图就是这样的:

如果需要从数据库中分配新的数据页,直接从上获取即可。InnoDB 需要保证 Free List 有足够的节点,提供给用户使用,否则需要从 FLU List 或者 LRU List 淘汰一定的节点。

3.3.4 LRU List

既然 buffer pool 的目的是加速写和加速读,因此必须想办法提高内存数据页的缓存命中率。InnoDB 基于经典的 LRU 算法管理 buffer pool 中的数据页。一般情况下 list 头部存放的是热数据,就是所谓的 young page(最近经常访问的数据),list 尾部存放的就是 old page(最近不被访问的数据)。

LRU 有以下标准算法:

  1. 3/8 的 list 信息是作为 old list,这些信息是被驱逐的对象;
  2. list 的中点就是我们所谓的 old list 头部和 young list 尾部的连接点,相当于一个界限;
  3. 新数据首先会插入到 old list 的头部;
  4. 如果是 old list 的数据被访问到了,这个页信息就会被移动到 young list 的头部变成 young page;
  5. 在 InnoDB buffer pool 里面,不管是 young list 还是 old list 的数据,如果不会被访问到,最后都会被移动到 list 的尾部作为牺牲者。

一般情况下,页信息会被查询语句立马查询到而被移动到 young list,这就意味着他们会在 buffer pool 里面保留很长一段时间。表扫描(包括 mysqldump 或者没有 where 条件的 select 等操作)将会刷入大量的数据进入 buffer pool,同时也会将更多 buffer pool 当中的信息刷出去,即使这个操作可能只会使用到一次而已。

可毕竟 LRU List 是一个链表,查找数据页的时间复杂度为 O(n),为了进一步提高读写性能,避免扫描 LRU List,实际上每个 Buffer Pool Instance 都有一个 page hash,通过它,使用 space_id 和 page_no 就能快速找到已经被读入内存的数据页,而不用线性遍历 LRU List 去查找。关于 page hash 的数据结构见总结模块中 InnoDB Buffer Pool 的架构图。

3.3.5 Flush List

在了解 Flush List 之前,首先需要了解脏页的概念。

脏页:内存数据页和磁盘数据页内容不一致的时候,这个数据页被称为“脏页”。内存数据写入磁盘后,内存和磁盘的数据页内容就一致了,称为“干净页”。不论脏页还是干净页,都存在内存里。

脏页最终肯定需要被刷回磁盘而变成干净页,但如果每次产生脏页后就立即同步到磁盘势必将严重影响程序的性能(毕竟磁盘慢的像乌龟一样)。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步,由后台刷新线程依次刷新到磁盘,实现修改落地到磁盘。

由于不是立即同步刷新脏页,所以我们不得不再创建一个链表,将脏页保存起来。凡是在 LRU List 中被修改过的页都需要加入这个链表中, 这个链表中的所有节点都是脏页,所以也叫 Flush List,一般被简写为 FLU List。

这里的脏页修改指的此页被加载进 Buffer Pool 后第一次被修改,只有第一次被修改时才需要加入 FLU List(代码中是根据 page 头部的 oldest_modification == 0 来判断是否是第一次修改),如果这个页被再次修改就不会再放到 FLU List 了,因为已经存在。需要注意的是,脏页数据实际还在 LRU List 中,而 FLU List 中的脏页记录只是通过指针指向 LRU List 中的脏页(即在 FLU List 上的页一定在 LRU List 上,反之不成立)。

一个数据页可能会在不同的时刻被修改多次,在数据页上记录了最早一次也就是第一次修改的 LSN,即 oldest_modification。不同数据页有不同的 oldest_modification,FLU List 中的节点按照 oldest_modification 排序,链表尾是最小的,也就是最早被修改的数据页。当需要从 FLU List 中淘汰页的时候,从链表尾部开始淘汰。加入 FLU List,需要使用 flush_list_mutex 保护,所以能保证 FLU List 中节点的顺序。

虽然脏页既存在于 LRU List 中,也存在与 FLU List 中,但 LRU List 用来管理缓冲池中页的可用性,FLU List 用来管理将脏页刷新回磁盘,二者互不影响。

3.3.6 Buffer Pool预热

事实上,在 MySQL 重启后,由于 Buffer Pool 里面没有什么数据,因此这个时候业务上对数据库的操作,MySQL 就只能从磁盘中读取数据到内存,而这个过程可能需要很久才能恢复到 MySQL 重启前内存中保留的业务频繁使用的热数据。Buffer Pool 从无到重新缓存业务频繁使用热数据的过程称之为预热。在预热这个过程中,MySQL 数据库的性能不会特别好,并且 Buffer Pool 越大,预热过程越长。

为了减短这个预热过程,在 MySQL 关闭前,把 Buffer Pool 中的页信息保存到磁盘,等到 MySQL 启动时,再根据之前保存的信息把磁盘中的数据加载到 Buffer Pool 即可。

3.3.7 总结

这三个重要链表(Free List, LRU List, FLU List)的关系可以用下图表示:

Free List 跟 LRU List 的关系是相互流通的,页在这两个链表间来回置换。而 FLUSH List 中记录了脏页数据,即通过指针指向了 LRU List,所以图中 FLU List 被 LRU List 包裹。

3.4 数据页访问机制

下面梳理一下数据页的访问流程。

当访问的页在缓存池中命中,则直接从缓冲池中访问该页。如果没有命中,则需要将这个 page 从磁盘上加载到缓存池,因此需要在缓存池中的 Free List 中找一个空闲的内存页来缓存这个从磁盘读入的 page。

但存在空闲内存页被使用完的情况,不保证一定有空闲的内存页。假如 Free List 为空,则需要想办法尽快产生空闲的内存页。

首先去 LRU List 中找可以替换的内存页(干净页),查找方向是从链表的尾部开始找,只要找到可以替换的页,就将其从 LRU List 中摘除,加入空闲列表,然后再去空闲列表中找空闲的内存页。第一次查找最多只扫描 100 个页,循环进行到第二次时,查找深度就是整个 LRU List。如果在 LRU List 中没有找到可以替换的页,则进行单页刷新(从 FLU List 中取),将脏页刷新到磁盘之后,再将其加入到空闲列表。这便是 InnoDB 中的 LRU 页面淘汰机制。为什么只做单页刷新呢?因为它的目的是为了尽快获取空闲内存页,进行脏页刷新是不得已而为之,所以只会进行一个页的刷新。

Free List 是一个公共的链表,所有的用户线程都可以使用,存在争用的情况。因此自己产生的空闲内存页有可能会刚好被其它线程所使用,用户线程可能会重复执行上面的查找流程,直到找到空闲的内存页为止。

在执行一条 SQL 语句的时候,如果恰好需要进行单页刷脏,这条 SQL 语句的执行便会比预期更加耗时,有时候看起来就像是数据库“抖了一下”,这也是一个 bad case,需要尽量避免。

通过数据页访问机制,可以知道当无空闲页时产生空闲页就成为了一个必须要做的事情。如果需要通过刷新脏页来产生空闲页,查找空闲页的时间就会延长。因此,innodb buffer pool 中存在大量可以替换的页,或者 Free List 中一直存在着空闲内存页,对快速获取空闲内存页就起到了决定性的作用。

而在 innodb buffer pool 中,是采用何种方式来产生空闲内存页以及可以替换的内存页呢?这就是下面要讲的内容——脏页刷新策略。

3.5 脏页(FLU List)刷新策略

3.5.0 序言

InnoDB 的刷脏策略非常复杂,而且随着 MySQL 版本的升高,刷脏策略也一直在不断优化中。由于博主水平有限,无法阅读 MySQL 对应版本的实现源码,因此这部分内容的正确性不可考究,读者辩证看待即可。

3.4 数据页访问机制 一节中发现在 Free List 不足时,LRU List 中的脏页也会被驱逐,LRU 中的单页刷脏是为了产生空闲页,FLU List 刷脏是为了保证有足够的空闲页,这两点注意区分。

刷脏相关文章

3.5.1 刷脏时机

以下四种情况,InnoDB 会进行脏页刷新:

  1. MySQL 认为系统空闲;
  2. MySQL 正常关闭过程;
  3. Free List 不足;
  4. redo log 写满。

前两种对性能基本没什么影响。

需要注意的是 redo log 写满:这种情况应尽量避免。发生这种情况时,系统将不再接受更新,所有更新语句会被堵住,此时写性能降为 0。对于敏感业务来说,这是不能接受的。由于此时需要将 redo log 中 write pos 向前推进,推进范围内 Redo Log 涉及的所有脏页都需要 flush 到磁盘中,因此 redo log 不宜设置过小或写太慢,否则可能造成 redo log 频繁写满,导致频繁触发 flush 脏页。

3.5.2 刷脏速度

InnoDB 的刷脏速度主要与以下几个因素有关:

  • 磁盘的 IOPS:InnoDB 全力刷脏页的速度
  • redo log 写入速度:N = (write pos 对应的 LSN - Checkpoint 对应的 LSN),当 N 越大,刷脏速度越快
  • buffer pool 中脏页所占比例:默认值是 75%

除开磁盘的 IOPS,最终的刷脏速度取上述后两者中最快的。其中磁盘 IOPS 的大小是刷脏速度的决定性因素,事实上磁盘 IOPS 的大小也直接代表了数据库性能的好坏。

innodb_io_capacity:这是 InnoDB 的一个关键参数,该参数用于告知 InnoDB 你的磁盘能力(InnoDB 目前还没有自主获取磁盘 IOPS 大小的能力),该值通常建议设置为磁盘的写 IOPS。当 innodb_io_capacity 设置过小时,InnoDB 会认为磁盘性能差,导致刷脏页很慢,甚至比脏页生成速度还慢,就会造成脏页累积,影响查询和更新性能。

innodb_io_capacity 大小设置:

  • 配置小,此时由于 InnoDB 认为你的磁盘性能差,因此刷脏页频率会更高,以此来确保内存中的脏页比例较少
  • 配置大,InnoDB 认为磁盘性能好,因此刷脏页频率会降低,抖动的频率也会降低

innodb_max_dirty_pages_pct:该参数指的是脏页比例上限(默认值是 75%),内存中的脏页比例越是接近该值,InnoDB 刷脏速度会越接近全力。

通过一个流程图能更好的说明刷脏速度与脏页比例和 redo log 写入速度之间的关系:

F1、F2 是上面通过脏页比例和 redo log 写入速度算出来的两个值,然后引擎取其中较大的值记为 R,最后按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度。

3.5.3 刷新邻接页

innodb_flush_neighbors:值为 1 为连坐刷脏页,0 为只刷自己的。

一旦一个请求需要在执行过程中先 flush 掉一个脏页,这个操作就可能要比平时慢。而 MySQL 中的一个机制,可能会让你的请求更慢:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好也是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,也就是对于每个邻接数据页,如果跟它相邻的数据页还是脏页,同样会被 flush 掉。

在 InnoDB 中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为 1 的时候会有上述的“连坐”机制,值为 0 时表示不找邻接页,自己刷自己的。

找“邻居”这个优化在机械硬盘时代很有意义,可以减少很多随机 IO。机械硬盘的随机 IOPS 一般只有几百,减少随机 IO 就意味着系统性能的大幅度提升。而如果使用的是 SSD 这类 IOPS 比较高的设备的话,建议把 innodb_flush_neighbors 的值设置成 0。因为这时候 IOPS 往往不是瓶颈,而“只刷自己”反而能更快地执行完必要的刷脏页操作,减少 SQL 语句的响应时间。

在 MySQL 8.0 中,innodb_flush_neighbors 参数的默认值已经是 0 了。

4 参考阅读

  1. [玩转MySQL之十]InnoDB Buffer Pool详解
  2. MySQL Innodb 内存池实现简介
  3. MySQL · 引擎特性 · InnoDB Buffer Pool
  4. 一文了解InnoDB存储引擎
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改