这一篇主要介绍缓存替换算法LRU及其变种算法的实现原理。
LRU(Least Recently Used)算法
最近最少使用算法,核心思想是:最近使用的数据很大概率将会再次被使用。而最近一段时间都没有使用的数据,很大概率不会再使用。做法:把最长时间未被访问的数据置换出去。这种算法是完全从最近使用的时间角度去考虑的。

执行过程理解:
- 在缓存中查找客户端需要访问的数据 如果缓存命中,则将访问的数据中队列中取出,重新加入到缓存队列的头部。
- 如果没有命中,表示缓存穿透,将需要访问的数据从磁盘中取出,加入到缓存队列的尾部;
- 如果此时缓存满了,则需要先置换出去一个数据,淘汰队列尾部的数据,然后再在队列头部加入新数据。
存在的问题:
缓存污染:如果某个客户端访问大量历史数据时,可能使缓存中的数据被这些历史数据替换,其他客户端访问数据的命中率大大降低。
LRU变种算法
LRU-K算法
LRU-K算法相对于LRU算法来说多维护了一个队列用来存放访问历史数据,所有新加入缓存的数据都会加入到历史访问队列中,当历史访问队列中数据的访问次数达到K,才会将数据放置到LRU缓存队列中。 LRU算法可以理解为K为1的LRU-K算法,这个算法主要是解决缓存污染的问题,将数据访问一次就进入缓存队列的条件提高到访问K次才能进入缓存队列。

- 在LRU缓存队列中查找客户端需要访问的数据。 如果缓存命中,则将访问的数据中队列中取出,重新加入到缓存队列的头部。
- 如果没有命中,表示缓存穿透,从磁盘中获取访问数据,并且判断历史访问队列中是否存在该数据索引。
- 如果历史访问队列中存在该数据索引,则将索引的访问次数加1,否则加入该数据索引;
- 当历史访问队列中的数据访问次数达到K,将数据加入到LRU缓存队列的头部,并从历史访问队列中移除。
- 如果此时LRU缓存队列满了,则需要先置换出去一个数据,淘汰队列尾部的数据,即淘汰“倒数第K次访问离现在最久”的数据。然后再在队列头部加入新数据。
历史访问队列可以按照FIFO或者LRU的规则来管理数据。
存在的问题:
根据K的设置值越大,需要更多次访问才能清空历史访问队列中的数据,但是命中率会更高。相比LRU算法,需要占用的内存更大。
实际环境中,LRU-2为最优选择。
LRU-Two queues(2Q)算法
与LRU-2算法相似,只是历史缓存队列不只是保存数据索引,也是作为一个FIFO缓存队列,只有在FIFO缓存队列中存在的数据被再次访问时,才会加入到LRU缓存队列中。 实际应用:操作系统中的PageCache缓存。

执行过程理解:
- 在LRU缓存队列中查找客户端需要访问的数据。 如果LRU缓存队列中命中,则将访问的数据中队列中取出,重新加入到缓存队列的头部。
- 如果LRU缓存队列中没有命中,在FIFO缓存队列中查找访问数据。
- 如果FIFO缓存队列中命中,将数据加入到LRU缓存队列的头部,并从FIFO缓存队列中移除;
- 如果LRU缓存队列满了,则需要先置换出去一个数据,淘汰队列尾部的数据,然后再在队列头部加入新数据。
- 如果FIFO缓存队列中没有命中,表示缓存穿透,从磁盘中读取该数据,并将数据缓存到FIFO队列中。
- 如果FIFO缓存队列满了,则需要先置换出去一个数据,淘汰队列尾部的数据,即淘汰最先进入队列的数据。然后再在队列头部加入新数据。
存在的问题:
相比LRU算法,需要占用的内存更大。但是相比LRU-2算法,可以减少一次从磁盘读取数据的操作。
LRU-Multi queues(MQ)算法
使用多个队列作为缓存队列,每个队列具有不同的优先级,优先级对应于数据的访问次数。 Q-N分别代表优先级为N的队列,N越大表示优先级越高。Q-history不是缓存队列,只用来保存淘汰数据的索引和访问次数。

执行过程理解:
- 在所有LRU缓存队列中查找客户端需要访问的数据(这里的查询顺序猜想是从高优先级开始)。
- 如果LRU缓存队列中命中,则将数据的访问次数加1,并判断是否能加入到更高一级的缓存队列中,如果可以,则从队列中取出访问数据,加入到高一级缓存队列的头部。
- 如果LRU缓存队列中没有命中,表示缓存穿透,从磁盘中读取该数据,并判断Q-history队列中是否存在该数据索引。
- 如果Q-history队列中存在该数据索引,则将数据的访问次数加1,并计算其优先级,加入到对应优先级缓存队列的头部,并从Q-history队列中移除;
- 如果Q-history队列中不存在该数据索引,则将数据加到Q0缓存队列的头部。
- 如果存在某个LRU缓存队列满了,无法加入新数据,需要先置换出去一个数据,此时从最低一级队列开始按照LRU淘汰;每个队列淘汰数据时,将数据从缓存队列中删除,将数据索引加入Q-history头部;????然后再在队列头部加入新数据。
- 如果Q-history队列满了,无法加入新数据,则需要先置换出去一个数据,淘汰队列尾部的数据,即淘汰最先进入队列的数据。然后再在队列头部加入新数据。(Q-history按照FIFO的算法??)
- 数据在指定的时间里访问没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;
存在问题:
实现的复杂度相对比较高,维护了多个队列,始终需要记录每个数据的访问时间,访问次数,需要定时全局扫描数据的访问时间。
与其他算法(LFU)概念结合的其他的变种算法
Least Frequently Used(LFU)算法
最近最不常用算法,核心思想是:最近使用频率高的数据很大概率将会再次被使用。而最近使用频率低的数据,很大概率不会再使用。做法:把使用频率最小的数据置换出去。这种算法是完全从使用频率的角度去考虑的。

执行过程理解:
- 在缓存中查找客户端需要访问的数据
- 如果缓存命中,则将访问的数据从队列中取出,并将数据对应的频率计数加1,然后将其放到频率相同的数据队列的头部,比如原来是A(10)->B(9)->C(9)->D(8),D被访问后,它的time变成了9,这时它被提到A和B之间,而不是继续在C后面。
- 如果没有命中,表示缓存穿透,将需要访问的数据从磁盘中取出,加入到缓存队列的尾部,记频率为1,这里也是加入到同为1的那一级的最前面;
- 如果此时缓存满了,则需要先置换出去一个数据,淘汰队列尾部频率最小的数据,然后再在队列尾部加入新数据。
存在的问题:
某些数据短时间内被重复引用,并且在很长一段时间内不再被访问。由于它的访问频率计数急剧增加,即使它在相当长的一段时间内不会被再次使用,也不会在短时间内被淘汰。这使得其他可能更频繁使用的块更容易被清除,此外,刚进入缓存的新项可能很快就会再次被删除,因为它们的计数器较低,即使之后可能会频繁使用。
这个问题导致会直接使用LFU算法的比较少,一般都是使用混合LFU算法概念的变种算法。
Least Recently/Frequently Used(LRFU)算法
LRFU: A spectrum of policies that subsumes the least recently used and least frequently used policies
Adaptive Replacement Cache(ARC)算法
ARC: adaptive replacement cache(IBM), adjusted replacement cache(ZFS) ARC(Adaptive Replacement Cache)是一种适应性Cache算法, 它结合了LRU与LFU。是Solaris ZFS 中实现的ARC(Adjustable Replacement Cache)读缓存淘汰算法。
包含的链表:
- 最近最多使用的页面链表 (LRU list)
- 最近最频繁使用的页面链表(LFU list)
- 存储那些最近从最近最多使用链表中淘汰的页面信息(Ghost list for LRU)
- 存储那些最近从最近最频繁使用链表中淘汰的页面信息(Ghost list for LFU)
ghost链表不储存数据(仅仅储存页面信息,比如offset,dev-id)
ARC算法执行过程:
- 整个Cache分成两部分,起始LRU和LFU各占一半,后续会动态适应调整partion的位置(记为p)除此,LRU和LFU各自有一个ghost list(因此,一共4个list)

- 在缓存中查找客户端需要访问的数据, 如果没有命中,表示缓存穿透,将需要访问的数据从磁盘中取出,放到LRU链表的头部。


- 如果命中,且LFU链表中没有,则将数据放入LFU链表的头部,所有LRU链表中的数据都必须至少被访问两次才会进入LFU链表。如果命中,且LFU链表中存在,则将数据重新放到LFU链表的头部。这么做,那些真正被频繁访问的页面将永远呆在缓存中,不经常访问的页面会向链表尾部移动,最终被淘汰出去。

- 如果此时缓存满了,则从LRU链表中淘汰链表尾部的数据,将数据的key放入LRU链表对应的ghost list。然后再在链表头部加入新数据。如果ghost list中的元素满了,先按照先进先出的方式来淘汰ghost list中的一个元素,然后再加入新的元素。

这里注意上面的the cache才是实际的LRU和LFU结合的链表,因此是删除了LRU链表的尾部元素,尾部元素对应下面的位置索引是1。


这个迹象说明我们的LRU缓存太小了。在这种情况下,LRU链表的长度将会被增加1,并将命中的数据key从ghost list中移除,放入LRU链表的头部。显然,LFU链表的长度将会被减少1。
同样,如果一次命中发生在LFU ghost 链表中,它会将LRU链表的长度减一,以此在LFU 链表中加一个可用空间。


也就是说,利用这种适应机制,当系统趋向于访问最近的内容,会更多地命中LRU ghost list,这样会增大LRU的空间; 当系统趋向于访问最频繁的内容,会更多地命中LFU ghost list,这样会增加LFU的空间.
ARC算法参考: orientye.com/arc-cache/ blog.csdn.net/WSKINGS/art…
Mysql的变种LRU算法
Low Inter-reference Recency Set(LIRS)算法: LIRS: An efficient low inter-reference recency set replacement policy to improve buffer cache performance InnoDB中应用的LRU优化算法:使用链表来实现的。

- 在缓存中查询数据。
- 如果命中,如State 1所示,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU算法一样,将其移到链表头部,变成状态 2;
- 如果命中,且P3在old区域,则判断数据页在 LRU 链表中存在的时间是否超过了 1秒,如果是就把它移动到链表头部,也就是移到young区域的头部;如果不是,位置保持不变。( 1秒这个时间,是由参数innodb_old_blocks_time 控制的。其默认值是 1000,单位毫秒。)
- 如果没有命中,则淘汰数据页Pm,并将新数据页Px插入到LRU_old链表部分的头部。
- 如果LRU_old链表满了,则淘汰处于链表尾部的数据页。如果LRU_young链表满了,则淘汰处于young区域链表尾部的数据页。
这个策略,就是为了处理类似全表扫描的操作量身定制的。如果大量扫描历史数据表,扫描过程中,新插入的数据页都被放到 old 区域 ;一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。
这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了Buffer Pool,但是对 young 区域完全没有影响,从而保证了Buffer Pool响应正常业务的查询命中率。
参考资料
林晓斌-MySQL实战45讲(33 | 我查这么多数据,会不会把数据库内存打爆?)