# 什么是Buffer Pool

首先我们都知道MySQL的数据都是存储在磁盘上的。每次查询语句如果都从磁盘上获取的话那就太慢了。因此InnoDB设计了缓存- Buffer Pool 就是为了缓存磁盘中的页,InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的16KB的大小划分出一个个的页, Buffer Pool 中的页就叫做缓存页 默认Buffer Pool只有128M大小。当然你可以根据配置自定义Buffer Pool的大小。Buffer Pool 除了缓存**「索引页」和「数据页」,还包括了 undo 页,插入缓存、自适应哈希索引、锁信息**等等。

innodb_buffer_pool_size = 268435456 # 注意这里的单位是字节

# Buffer Pool的内部组成

Buffer Pool中默认的缓存页大小和在磁盘上的页大小是一样的,都是16KB。为了管理这些缓存页。InnoDB为每个缓存页都创建了对应的控制信息块。控制信息块内面包括所属表空间编号,页号,缓存页在Buffer Poll中的地址,链表节点、索信息以及LSN等等。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool的前边,缓存页被存放到 Buffer Pool 后边。 所以整个Buffer Pool对应的内存空间是这样的。

image.png

碎片是什么玩意,在Buffer Pool中控制块和缓存页是一一对应的。如果控制块和缓存页足够多的话。可能就会剩下一部分空间。当然如果你设置的Buffer Pool大小整好的好可能也没有碎片

# free链表的管理

当MySQL启动的时候,需要先对Buffer Pool完成初始化。其实就是向操作系统申请Buffer Pool的内存空间。然后划分成若干对控制块和缓存页。最开始控制块和缓存页肯定都是空的。随着程序运行。会不断的有磁盘上的页缓存到Buffer Pool上。那我们如何区分Buffer Pool上哪些缓存页是空间,哪些已经被使用了呢?

InnoDB把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作 free链表 (或者说空闲链表)。 刚刚完成初始化的 Buffer Pool 中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到 free链表。增加了free链表结构就是这样的

image.png InnoDB为了更加方便管理这个free链表。特意为这个链表定义了一个基结点。里边儿包含着链表的头 节点地址(start),尾节点地址(end),以及当前链表中节点的数量(count)等信息。有了free链表以后。每当需要从磁盘上加载一个页到Buffer Pool中时,就从free链表取一个空闲的缓存页。并且把该空闲页对应的控制块信息填上。然后从free链表移除。

# 缓存页的哈希处理

当我们查询数据需要从磁盘上加载相应的页到Buffer Pool中的时候我们如何知道这个页在不在Buffer Pool中。如果遍历Buffer Pool中每一个页那不累死了嘛。因此InnoDB以用 表空间号 + 页号 作为 key , 缓存页 作为 value 创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据 表空间号 + 页号 看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free链表 中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

# flush链表的管理

设计 Buffer Pool 除了能提高读性能,还能提高写性能,也就是更新数据的时候,不需要每次都要写入磁盘,而是将 Buffer Pool 对应的缓存页标记为脏页,然后再由后台线程将脏页写入到磁盘。为什么不是直接更新到磁盘上?你想想 你修改一次更新一次磁盘。产生一次随机IO。那更新多的话不得慢死啊。 那为了能快速知道哪些缓存页是脏的,于是就设计出 Flush 链表,它跟 Free 链表类似的,链表的节点也是控制块,区别在于 Flush 链表的元素都是脏页。Flush链表的结构和free链表结构类似这里就不画了。

# 如何提高InnoDB中缓存命中率

Buffer Pool 的大小是有限的,我们肯定希望一些频繁访问的数据一直留在 Buffer Pool 中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证 Buffer Pool 不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在 Buffer Pool 中。为此InnoDB采用了最神奇的东东----LRU。相信大家对LRU肯定都不陌生了,这里就先简单介绍一下。

# 简单的LRU

  • 如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的控制块 作为节点塞到链表的头部。
  • 如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的 控制块 移动到 LRU链表 的头部。 但是InnoDB肯定不会采用最简单的LRU算法。InnoDB对LRU进行了一些优化

# 划分区域的LRU链表

先来说说MySQL中的预读机制。所谓预读就是就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。预读又分为两种

  • 线性预读:InnoDB 的提供了一个系统变量** innodb_read_ahead_threshold** ,如果顺序访问了某个区( extent )的页面超过这个系统变量的值,就会触发一次 异步 读取下一个区中全部的页到 BufferPool 的请求 默认值是56
  • 随机预读:如果 Buffer Pool 中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次 异步 读取本区中所有其的页面到 Buffer Pool 的请求。这个可以根据innodb_random_read_ahead 系统变量决定是否开启默认不开启

预读成功肯定是最好的结果。那如果预读失败怎么办。比如有时候我们执行全表扫描。然后那些我们预读出来的那些页被放到了InnoDB的LRU链表的头节点。但是从来没有被读取过呀。并且有时候预读多了。还导致链表中的其他节点被挤到了后面。然后被淘汰了。。这不就得不偿失了嘛。

因此InnoDB针对这种情况对传统的LRU链表进行了优化。InnoDB将LRU链表按照比例分成了俩部分。分别是

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

是不感觉有点眼熟。。。。JVM中的内存模型是不也分为young区和old区。果然哪计算机知识都是相同的呀。

image.png 这里简单的给大家画一下InnoDB中的LRU链表。。。。丑点就丑点吧。。太难了不会呀。。 特别注意:我们是按照某个比例将LRU链表分成两半的,不是某些节点固定是young区域的,某些节点固定是old区域的,随着程序的运行,某个节点所属的区域也可能发生变化。

划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。

但是如果执行的是全表扫描咋办。比如执行全表扫描的时候一个表里有100条数据。从第一条开始扫描。当我们扫描到第49条的时候就出现了预读机制。将50-100条的数据加载到了Buffer Pool中。确实这个时候50-100条的数据在old区。但是我们是全表扫描呀。这些在Old区的数据也会被扫描到呀。如果直接从Old区移动到了young区域。我们也不能保证这50条是我们想要的热点数据呀。如果数据量太多了的话。young区域的其他数据不也被挤出去了嘛。

但是你想想InnoDB的LRU链表能有这么简单嘛???那这是不可能的事。InnoDB还有更强的处理。

InnoDB中规定 在对某个处在 old 区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。

这个时间间隔是由系统变量innodb_old_blocks_time 控制的

image.png 默认是1000毫米。什么意思呢。对于InnoDB的LRU链表来说。如果第一次和最后一次访问该页面的时间间隔小于1S。那么该页是不会加入到young区域的。

# 超级无敌LRU链表

通过上述分析。我们对InnoDB中的LRU链表已经有了一个充分认识。但是!!!你以为到这里就结束了吗。对于 young 区域的缓存页来说,我们每次访问一个缓存页就要把它移动到 LRU链表 的头部,这样开销是不是太大啦,毕竟在 young 区域的缓存页都是热点数据,也就是可能被经常访问的。为此。我们还可以进行一些优化比如只有被访问的缓存页位于 young 区域的 1/4 的后边,才会被移动到 LRU链表 头部,这样就可以降低调整 LRU链表 的频率,从而提升性能(也就是说如果某个缓存页对应的节点在 young 区的 1/4 中, 再次访问该缓存页时也不会将其移动到 LRU 链表头部)。

# 其他的一些链表

为了更好的管理Buffer Pool中的缓存页,针对上面的措施以为。InnoDB还引进了一些其他的链表。比如 unzip LRU链表 用于管理解压页, zip clean链表 用于管理没有被解压的压缩页, zip free数组 中每一个元素都代表一个链表,它们组成所谓的 伙伴系统 来为压缩页提供内存空间等等。

# 刷新脏页到磁盘的时机

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

  • 从 LRU链表 的冷数据中刷新一部分页面到磁盘。

后台线程会定时从 LRU链表 尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth 来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷新页面的方式被称之为 BUF_FLUSH_LRU 。

(有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看 LRU链表 尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU链表 尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为 BUF_FLUSH_SINGLE_PAGE 。)

  • 从 flush链表 中刷新一部分页面到磁盘。

后台线程也会定时从 flush链表 中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种 刷新页面的方式被称之为 BUF_FLUSH_LIST 。

同时:下面几种情况会触发脏页的刷新:

  • 当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘;
  • Buffer Pool 空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘;
  • MySQL 认为空闲时,后台线程会定期将适量的脏页刷入到磁盘;
  • MySQL 正常关闭之前,会把所有的脏页刷入到磁盘;

这部分先给大家简单介绍一下脏页刷新时机。后序会专门出一片文章对MySQL的各种日志进行讲解

# 总结

  1. InnoDB存储引擎设计了一个缓冲池(Buffer Pool),来提高数据库的读写性能
  2. Buffer Pool以页外单位缓冲数据。可以通过 innodb_buffer_pool_size 参数调整缓冲池的大小,默认是 128 M。
  3. InnoDB通过三种链表来管理缓存页
    • Free List (空闲页链表),管理空闲页;
    • Flush List (脏页链表),管理脏页;
    • LRU List,管理脏页+干净页,将最近且经常查询的数据缓存在其中,而不常查询的数据就淘汰出去。;
  4. InnoDB对LRU进行了一些优化。
    • 将 LRU 链表 分为young 和 old 两个区域,加入缓冲池的页,优先插入 old 区域;页被访问时,才进入 young 区域,目的是为了解决预读失效的问题。
    • 当**「页被访问」且「 old 区域停留时间超过 innodb_old_blocks_time 阈值(默认为1秒)」** 时,才会将页插入到 young 区域,否则还是插入到 old 区域,目的是为了解决批量数据访问,大量热数据淘汰的问题。

可以通过调整 innodb_old_blocks_pct 参数,设置 young 区域和 old 区域比例。

# 彩蛋!!!

在开启了慢 SQL 监控后,如果你发现「偶尔」会出现一些用时稍长的 SQL,这可因为脏页在刷新到磁盘时导致数据库性能抖动。如果在很短的时间出现这种现象,就需要调大 Buffer Pool 空间或 redo log 日志的大小。