缓存不够的窘境

Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool 大小,也就是 free 链表中已经没有多余的空闲缓存页的时候该咋办?当然是把某些旧的缓存页从 Buffer Pool 中移除,然后再把新的页放进来,那么问题来了,移除哪些缓存页呢?
为了回答这个问题,我们还需要回到我们设立 Buffer Pool 的初衷,我们就是想减少和磁盘的IO 交互,最好每次在访问某个页的时候它都已经被缓存到 Buffer Pool 中了。假设我们一共访问了 n 次页,那么被访问的页已经在缓存中的次数除以 n 就是所谓的缓存命中率,我们的期望就是让缓存命中率越高越好。
从这个角度出发,回想一下我们的微信聊天列表,排在前边的都是最近很频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢?当然是留下最近很频繁使用的了。

简单的 LRU 链表

管理 Buffer Pool 的缓存页其实也是这个道理,当 Buffer Pool 中不再有空闲的缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,我们怎么知道哪些缓存页最近频繁使用,哪些最近很少使用呢?
再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为 LRU 链表(LRU 的英文全称:Least Recently Used)。当我们需要访问某个页时,可以这样处理 LRU 链表:
如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的控制块作为节点塞到 LRU 链表的头部。
如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU链表的头部。
也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU 链表的头部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了。

划分区域的 LRU 链表

但是这种实现存在两种比较尴尬的情况:
情况一:InnoDB 提供了预读(英文名:read ahead)。所谓预读,就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。根据触发方式的不同,预读又可以细分为下边两种:

1.线性预读

InnoDB 提供了一个系统变量 innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 的请求。
这个 innodb_read_ahead_threshold 系统变量的值默认是 56,我们可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值。

2.随机预读

如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到 Buffer Pool 的请求。InnoDB 同时提供了 innodb_random_read_ahead 系统变量,它的默认值为 OFF。
如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到 LRU 链表的头部,但是如果此时 Buffer Pool 的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU 链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。

情况二:应用程序可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有 WHERE 子句的查询)。

扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统都加载到 Buffer Pool 中,这也就意味着 Buffer Pool 中的所有页都被换了一次血, 其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中率。
总结一下上边说的可能降低 Buffer Pool 的两种情况: 加载到 Buffer Pool 中的页不一定被用到
如果非常多的使用频率偏低的页被同时加载到 Buffer Pool 时,可能会把那些使用频率非常高的页从 Buffer Pool 中淘汰掉。
因为有这两种情况的存在,所以 InnoDB 把这个 LRU 链表按照一定比例分成两截,分别是:

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

image.png
我们是按照某个比例将 LRU 链表分成两半的,不是某些节点固定是 young 区域的,某些节点固定是 old 区域的,随着程序的运行,某个节点所属的区域也可能发生变化。那这个划分成两截的比例怎么确定呢?对于 InnoDB 存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct 的值来确定old 区域在LRU 链表中所占的比例,比方说这样:

  1. SHOW VARIABLES LIKE 'innodb_old_blocks_pct';

image.png
从结果可以看出来,默认情况下,old 区域在 LRU 链表中所占的比例是 37%,也就是说 old 区域大约占 LRU 链表的 3/8。这个比例我们是可以设置的,我们可以在启动时修改innodb_old_blocks_pct 参数来控制old 区域在LRU 链表中所占的比例。在服务器运行期间,我们也可以修改这个系统变量的值,不过需要注意的是, 这个系统变量属于全局变量。

有了这个被划分成 young 和 old 区域的 LRU 链表之后,InnoDB 就可以针对我们上边提到的两种可能降低缓存命中率的情况进行优化了:
针对预读的页面可能不进行后续访问情况的优化:
InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区域中被使用比较频繁的缓存页。

针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化

在进行全表扫描时,虽然首次被加载到 Buffer Pool 的页被放到了 old 区域的头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到 young区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。
有同学会想:可不可以在第一次访问该页面时不将其从 old 区域移动到young 区域的头部,后续访问时再将其移动到 young 区域的头部。回答是:行不通!因为 InnoDB 规定每次去页面中读取一条记录时,都算是访问一次页面,而一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访问了这个页面好多次。
全表扫描有一个特点,那就是它的执行频率非常低,出现了全表扫描的语句也是我们应该尽快优化的对象。而且在执行全表扫描的过程中,即使某个页面中有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的。
所以在对某个处在old 区域的缓存页进行第一次访问时就在它对应的控制块中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则将它移动到 young 区域的头部。上述的这个间隔时间是由系统变量innodb_old_blocks_time 控制的:

  1. SHOW VARIABLES LIKE 'innodb_old_blocks_time';

image.png
这个 innodb_old_blocks_time 的默认值是 1000,它的单位是毫秒,也就意味着对于从磁盘上被加载到 LRU 链表的 old 区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于 1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过 1s),那么该页是不会被加入到 young 区域的, 当然, 像 innodb_old_blocks_pct 一样,我们也可以在服务器启动或运行时设置innodb_old_blocks_time 的值,这里需要注意的是,如果我们把innodb_old_blocks_time 的值设置为 0,那么每次我们访问一个页面时就会把该页面放到 young 区域的头部。
综上所述,正是因为将 LRU 链表划分为 young 和 old 区域这两个部分,又添加了 innodb_old_blocks_time 这个系统变量,才使得预读机制和全表扫描造成的缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面都只会被放到 old 区域,而不影响 young 区域中的缓存页。

更进一步优化 LRU 链表

对于 young 区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU 链表的头部,这样开销是不是太大?
毕竟在 young 区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对 LRU 链表进行节点移动操作也会拖慢速度?为了解决这个问题,MySQL 中还有一些优化策略,比如只有被访问的缓存页位于 young 区域的 1/4 的后边 才会被移动到 LRU 链表头部,这样就可以降低调整 LRU 链表的频率,从而提升性能
还有没有什么别的针对 LRU 链表的优化措施呢?当然还有,我们这里不继续说了,更多的需要看 MySQL 的源码,但是不论怎么优化,出发点就是:尽量高效的提高 Buffer Pool 的缓存命中率。

其他的一些链表

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