基于LRU算法淘汰部分缓存
今天我们接着来分析buffer pool的工作原理。我们来思考一个问题,当你要执行CRUD操作的时候,无论是查询数据,还是修改数据,实际上都会把磁盘上的数据页加载到缓存页里来,这个之前我们已经讲过。那么在加载数据到缓存页的时候,必然是要加载到空闲的缓存页里去的,所以必须要从free链表中找一个空闲的缓存页,然后把磁盘上的数据页加载到那个空闲的缓存页里去,我们来看示意图:
当你不停的把磁盘上的数据页加载到空闲缓存页里去,free链表中不停的移除空闲缓存页,迟早有那么一瞬间,你会发现free链表中已经没有空闲缓存页了。这个时候,怎么办呢?此时你只有一个办法,就是淘汰掉一些缓存页。什么叫淘汰缓存页呢?顾名思义,你必须把一个缓存页里修改过的数据刷回到磁盘上的数据页里去,然后这个缓存页就可以清空了,让它重新变成一个空闲的缓存页。接着你再把磁盘上你需要的新的数据页加载到这个腾出来的空闲缓存页中去。那么下一个问题来了,如果要把一个缓存页里的数据刷入磁盘,腾出来一个空闲缓存页,那么应该把哪个缓存页的数据给刷入磁盘呢?
要解答这个问题,我们就得引入一个缓存命中率的概念。假设现在有两个缓存页,一个缓存页的数据经常会被修改和查询,比如100次请求中,有30次都是在查询和修改这个缓存页里的数据,那么它的缓存命中率就比较高。另一个缓存页里的数据,就是从磁盘加载到缓存页之后,被修改和查询过1次,之后100次请求中没有一次是修改和查询这个缓存页的数据的,那么此时我们就说它的缓存命中率有点低。这个时候,如果让我们来选,我们会选择将哪一个缓存页的数据刷入磁盘呢?那还用想吗,当然是选择第二个缓存页刷入磁盘中。
接着我们就要来解决下一个问题,就是你怎么知道哪些缓存页经常被访问,哪些缓存页很少被访问?此时就要引入一个新的LRU链表了,这个所谓的LRU就是Least Recently Used,最近最少使用的意思。通过这个LRU链表,我们就可以知道哪些缓存页是最近最少被使用的,那么当缓存页需要腾出来一个刷入磁盘的时候,就可以选择那个最近最少使用的缓存页了。那么这个LRU链表大致是怎么个工作原理呢?我们先来看一张图:
假设我们从磁盘加载一个数据页到缓存页,就把这个缓存页的描述数据块放到LRU链表头部去,那么只要有数据的缓存页,它都会在LRU里,而且最近被加载数据的缓存页,会放到LRU链表的头部去。如果某个缓存页的描述数据块本来在LRU链表的尾部,后续你只要查询或修改了这个缓存页的数据,也要把这个缓存页的描述数据块挪动到LRU链表的头部去,也就是说最近被访问过的缓存页,一定在LRU链表的头部。那么这样的话,当你的缓存页没有一个空闲的时候,你就直接在LRU链表的尾部找到一个缓存页,它一定是最近最少被访问的那个缓存页。然后你就把LRU链表尾部的那个缓存页刷入磁盘中,然后把需要的磁盘数据页加载到腾出来的空闲缓存页中就可以了。
LRU链表在buffer pool实际运行中可能导致的问题
1、预读带来的一个巨大问题
我们现在来看下MySQL的预读机制,这个所谓的预读机制,说的就是当你从磁盘上加载一个数据页的时候,它可能会连带着把这个数据页相邻的其它数据页也加载到缓存里去了。举个例子,假设现在有两个空闲缓存页,然后在加载一个数据页的时候,连带着把它的一个相邻的数据页也加载到缓存里去了,正好每个数据页放入一个空闲缓存页!但是接下来呢,实际上只有一个缓存页是被访问了,另外一个通过预读机制加载的缓存页,其实并没有人访问,此时这两个缓存页可都在LRU链表的前面。这个时候,假如没有空闲缓存页了,你就要把LRU链表尾部的缓存页给刷入磁盘,但它可能是一个经常被访问的缓存页,但是它的位置被刚才预读机制加载进来的缓存页给占据了,而且还没有人访问它,这是绝对不合理的。
2、哪些情况下会触发MySQL的预读机制
现在我们已经理解了预读机制一下子把相邻的数据页加载进缓存的隐患了,那么我们来看看,到底哪些情况下会触发MySQL的预读机制。
(1) 有一个参数是 innodb_read_ahead_threshold,它的默认值是56,意思就是如果顺序的访问了一个区里的多个数据页,访问的数据页的数量超过了这个阈值,此时就会触发预读机制,把下一个相邻区中的所有数据页都加载到缓存里去。
(2)如果buffer pool里缓存了一个区里的13个连续的数据页,而且这些数据页都是比较频繁被访问的,此时就会直接触发预读机制,把这个区里的其它的数据页都加载到缓存里来。这个机制是通过参数innodb_random_read_ahead来控制的,它默认是OFF,也就是这个规则是关闭的。
所以默认情况下,主要是第一个规则可能会触发预读机制。
3、另外一种可能导致频繁被访问的缓存页被淘汰的场景
另外一种可能导致频繁被访问的缓存页被淘汰的场景,那就是。这个所谓的全表扫描,意思就是类似如下的SQL语句:select * from users
此时它没加where条件,会导致它直接把这个表里所有的数据页都从磁盘加载到buffer pool里去。这个时候它会把这个表的所有数据页都一一装入各个缓存页里去,此时可能LRU链表中排在前面的一大串缓存页都是全表扫描加载进来的缓存页。那么如果这次全表扫描过后,后续几乎没有用到这个表里的数据呢?此时LRU链表的尾部,可能全是之前一直被频繁访问的那些缓存页。
4、MySQL为什么要设计一个预读机制
其实就是为了性能,假设你读取了数据页01到缓存页里去,那么接下来有可能会顺序读取数据页01相邻的数据页02到缓存里去,这个时候,是不是在读取数据页02的时候要再次发起一次磁盘IO?所以为了性能优化,MySQL才设计了预读机制,也就是说如果在一个区内,你顺序读取了好多数据页了,比如数据页01到数据页56都被依次顺序读取了,MySQL会判断,你可能接着会继续顺序读取后面的数据页。那么此时它就干脆提前把后续的一大堆数据页(比如数据页57~70)都读取到buffer pool里去,那么后续你再读取数据页60的时候,就可以直接从buffer pool里拿数据了。
当然理想是上述那样,很丰满,但是现实可能很骨感。你预读的一大堆数据页要是占据了LRU链表的前面部分,可能这些预读的数据页压根后续没人会使用,那这个预读机制就是在捣乱了。
MySQL通过冷热数据分离的方案来优化LRU算法
1、基于冷热数据分离的思想设计LRU链表
所以为了解决我们前面讲的LRU链表的问题,MySQL真正在设计LRU链表的时候,实际采取的是冷热数据分离的思想。之前一系列的问题,说白了,不都是因为所有缓存页都混在一个LRU链表里才导致的吗?所以真正的LRU链表,会被拆分为两个部分,一部分是热数据,一部分是冷数据,这个冷热数据的比例是由innodb_old_blocks_pct参数控制的,它默认是37,也就是说冷数据占比37% 。
2、数据页第一次被加载到缓存的时候
数据页第一次被加载到缓存的时候,这个时候缓存页会被放在LRU链表的哪个位置呢?实际上这个时候缓存页会被放在冷数据区域的链表头部,我们看下面的示意图:
3、冷数据区域的缓存页什么时候会被放入到热数据区域
肯定很多人会想,只要对冷数据区域的缓存页进行了一次访问,就把这个缓存页放到热数据区域的头部,这样行不行呢?其实这也是不合理的,如果你刚加载了一个数据页到那个缓存页,它是在冷数据区域的链表头部,然后立马(1ms内)就访问了这个缓存页,之后就再也不访问它了呢?所以MySQL设定了一个规则,它设计了一个innodb_old_blocks_time参数,默认值1000毫秒,也就是说,必须是一个数据页被加载到缓存页1s之后,你访问这个缓存页,它才会被挪动到热数据区域的链表头部。
因为假设你加载了一个数据页到缓存去,然后过了1s之后你还访问了这个缓存页,说明你后续很可能会经常要访问它,这个时间限制就是1s,因此只有1s后你访问了这个缓存页,它才会把缓存页放到热数据区域的链表头部。假设此时缓存页不够了,就直接找到LRU链表中的冷数据区域的尾部的缓存页,它们肯定是之前被加载进来,而且加载进来1s过后都没有被访问过的缓存页,所以此时就直接淘汰它们就可以了。
4、LRU链表的热数据区域是如何进行优化的
在热数据区域中,如果你访问了一个缓存页,是不是应该要把他立马移动到热数据区域的链表头部?肯定不是的,热数据区域里的缓存页可能是经常被访问的,所以这么频繁的进行移动性能就不会太好,也没这个必要。所以说,LRU链表的热数据区域的访问规则被优化了一下,即只有在热数据区域的后3/4部分的缓存页被访问了,才会给移动到链表头部去。如果你是热数据区域的前面1/4的缓存页被访问,它是不会移动到链表头部去的。
举个例子,比如热数据区域有100个缓存页,那么排在前面的25个缓存页,它即使被访问了,也不会移动到链表头部去。只有访问后面的75个缓存页,它才会被移动到链表头部去,这样的话,可以尽可能的减少链表中的节点移动。
LRU链表中尾部的缓存页是如何刷入磁盘的
1、定时把LRU尾部的部分缓存页刷入磁盘
首先第一个时机,并不是在缓存页满的时候,才会挑选LRU冷数据区域尾部的几个缓存页刷入磁盘,而是有一个后台线程,它会运行一个定时任务,这个定时任务每隔一段时间就会把LRU链表的冷数据区域的尾部的一些缓存页刷入磁盘,清空这几个缓存页,把它们加入会free链表去。我们看下面的示意图:
大家可以想象一下,只要有缓存页被刷入磁盘,那么这个缓存页必然会加入到free链表中,从flush链表中移除,从lru链表中移除。
2、把flush链表中的一些缓存页定时刷入磁盘
在LRU链表的热数据区域里的很多缓存页可能会被频繁的修改,难道他们永远都不刷入磁盘中吗?所以这个后台线程同时也会在MySQL不怎么繁忙的时候,找个时间把flush链表中的缓存页都刷入磁盘中,这样你修改过的数据,迟早都会刷入磁盘。只要flush链表中的缓存页被刷入了磁盘,那么这些缓存页也会从flush链表和LRU链表中移除,然后加入到free链表中去。
3、如果实在没有空闲缓存页了怎么办
那么此时就会从LRU链表的冷数据区域的尾部找到一个缓存页,它肯定是最不经常使用的缓存页,然后把它刷入磁盘和清空,然后把数据页加载到这个腾出来的空闲缓存页里去。
总结
这就是MySQL的buffer pool缓存机制的一整套运行原理。我们已经完整的讲完了缓存页的加载和使用,以及free链表、flush链表、LRU链表的使用,包括缓存页是如何刷入磁盘的,以及缓存页没有空闲的时候应该怎么处理。大家理解了最近两篇文章之后,就应该完全理解MySQL在执行CRUD操作的时候是如何尽可能基于内存中的缓存来处理的。
思考
大家发现没有,如果你在执行CRUD的时候要从磁盘加载数据页到buffer pool的缓存页,一旦此时没有空闲的缓存页,就必须从LRU链表的冷数据区域的尾部把一个缓存页刷入磁盘,腾出来一个空闲缓存页,接着你才能基于缓存数据来执行这个CRUD的操作。但是如果频繁的出现这样一个情况,那你的很多CRUD执行的时候,难道都要先刷一个缓存页到磁盘上去,然后再从磁盘上读取一个数据页到空闲缓存页里来?这样岂不是每次都要执行两次磁盘IO?那么性能岂不是会很差?那么这个时候,我们应该如何优化MySQL的内核参数?