Low Inter-reference Recency Set,它和 LRU 一样,是一种内存淘汰策略。继承了 LRU 根据时间局部性预测内存冷热数据的特性,引进了 IRR(Inter-Reference Recency) 和 R (Recency),来增加对冷热数据的预测准确性,从而减少错误的数据淘汰,提高缓存命中率。
既然说到 LIRS 是继承 LRU,那就不得不聊聊 LRU 有哪些优缺点,以及顺便聊聊大佬们还有哪些改进吧。
内存淘汰策略是为了解决内存空间有限的情况下,尽可能的避免有价值的数据被淘汰。
一般来说,有价值的数据就是我们常说的热点数据。
LRU 就是这么一种广泛使用的策略,其本质就是在内存不足的情况下,剔除掉内存中最近最少使用的数据,为新数据提供空间。
最近最少使用的数据,是根据时间局部性原理来预测的。基于此,该策略认为,在某个时刻被访问的数据,那么短时间内,可能会被再次访问,其访问的概率是大于相对更久之前的访问数据。
LRU 最大的优点在于简单,仅需要维护数据本身,而不需要维护更多的额外信息,来进行预测。
但是不需要其他信息的缺点也十分明显:
可能因为突然热度,或大的数据块,导致大部分数据的访问次数或热度不寻常的提高。导致这部分偶发性的大量数据替换“真实频繁访问”的数据,而驻留在内存中。
在文件仅比内存大一点,而循环访问文件的情况下。访问到文件末尾时,由于内存限制,会淘汰掉马上就要访问的文件开头的数据。理想情况下,非命中概率 应该接近于 缓存空间不足概率。
key 对比于数据库中,就是索引的概念。一般来说,索引访问的频率是远大数据的。
类比之下,缓存的 key 和 value的访问频率其实不一致。那内存持有的时间也应该不一样。这就是为什么 LIRS 栈中会存在非持久化key的原因,本质上就代表着不同持有时间。
其实基于以上的问题,也有过许多其他优秀的策略,或者改进版。
比如为了解决偶发的冷数据替换热数据的问题,LFU 追踪数据的历史信息,即访问频率,选择访问频率最低的淘汰。但是,过于关注历史信息,无法适应时间的变化。比如某个时刻过大的访问频率,将导致该数据永远无法被淘汰。这就是淘汰策略的时间适应性问题。
诸如 LRU 的改进版 LRU-K ,其主要目的也是为了解决 LRU 的冷热数据的问题。核心思想就是将“最近使用过 1 次”的判断标准扩展为“最近使用过 K 次”。
实现上,需要多维护 K 个队列,用来记录缓存数据的访问历史。只有当数据的访问次数达到 K 次时,再将数据放入缓存。淘汰时,会淘汰第 K 次访问时,间距当前时间最大的数据,即原先 LRU 的规则。
访问历史队列的淘汰策略,可以有不同规则,比如 LRU、FIFO。
固然解决了上述说到的第一、第三个问题,但是依然存在一些问题:
实际应用中 LRU-2 是综合各种因素后最优的选择,LRU-3 或者更大的 K 值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。
作者声称该方案的性能与 LRU-2 相同,其优化在于针对于 LRU-K 的 O(logN) 的时间复杂度,优化为 O(1)。 2Q 方案可以解决 LRU 较长时间的顺序引用和循环引用,即上述的第二个问题。
2Q 维护了两个缓存队列,一个 FIFO 队列,一个 LRU 队列。
当数据第一次访问时,2Q 算法将数据缓存在 FIFO 队列里面,当数据第二次被访问时,则将数据从 FIFO 队列移到LRU 队列里面,两个队列各自按照自己的方法淘汰数据。
相当于将 FIFO 队列作为一个冷数据的缓冲区域,从而解决冷数据“污染”热数据的问题。
随之带来的就是 FIFO 队列的大小问题,如果太小则有些数据来不及提升为热数据就可能被移除 FIFO,如果太大则会过多占用有限的空间,所以其比例需要根据情况进行配置。
剩下还有像 LRFU 结合了 LRU 和 LFU,通过权重控制两者的倾向性,其问题还是在于需要根据系统性能、工作负载等等因素人为调整权重。或者 MQ、ARC 等等。
说了这么多,主要还是为了说明 LRU 作为普遍使用的内存淘汰策略,都有什么优缺点,以及改进方案。正所谓,取其精华,去其糟粕。 LIRS 继承了 LRU 的模型,从而保证了其简单性;通过 IRR 和 R 准确、动态地维护冷热数据,保证了其适应性。
上面两次提到了 IRR 和 R,那么到底是什么呢?
首先,它们是衡量数据缓存的重要指标,含义如下:
基于以上两个指标,动态维护了块的类别和状态。
IRR 可以衡量数据当前访问的频度,IRR 越高说明在某个时间区间中,该数据的热度较低。根据高低区分了 LIR(Low IRR Block) 和 HIR(High IRR Block)。
LIR:低 IRR,热数据;HIR:高 IRR,冷数据。
核心假设在于,认为 IRR 大的数据,那么下一次的 IRR 会更大,也就是说,下一次访问会更遥远。因此是内存淘汰的优先选择。
其实现方案主要在于三点:
那突出的是一个实现简单,动态调整。因此需要一个简单的方案来维护 IRR 和 R。
LIRS 会持有 LIR 的集合和 HIR 的集合,并且保证 LIR 一定在缓存中。而 HIR 则不一定,并且 HIR 的缓存空间极小,一是为了防止时间局部性原理,可能短时间内会再次访问;二来又不会缓存太多这样的数据。
然而 LIR 和 HIR 的状态是可以置换的。当 LIR 很久未被访问,而 HIR 的数据短时间内被连续访问,那么 HIR 就会和 IRR 最大的 LIR 进行置换,HIR 变成 LIR, LIR 变成 HIR 或被淘汰。这也是动态的关键之一。
IRR 是去重的,从而保证了突发频繁访问的数据,不会影响到真正的热点数据。
其次,更加适应于 LRU 的模型,因为 LRU 的堆栈,一个数据只存在一个。从而更好地继承 LRU 的特性,并进行扩展。
1.核心概念
LIRS 维护两个集合:LIR 集合和 HIR 集合。一个数据在内存中,不是 LIR 就是 HIR。
HIR 的块,可能只是维护了索引,数据并不在内存中。
内存集合的容量为 L,LIR 的容量为 Llirs,HIR 的容量为 Lhirs。
Llirs + Lhirs = L。
一般来说,Lhirs 维护一个特别小的值,实践中一般为内存容量的 1%。
2.IRR 的比较,通过 R 的维护,来减少额外的维护成本。
IRR 可以理解为当前时刻,距离上次访问时的 R,即上一个时刻的 R - 当前访问 R(0) 。因此通过 IRR 维护数据块类型(LIR 和 HIR)时,可以使用特定的数据类型,来简单地使用 R 判断即可。
直白的说,就是当新的 HIR 的 IRR 小于 所有LIR 的 R,则将 HIR 与 R 最大的 LIR 状态置换。
为什么是将 IRR 和 R 比较?因为 R 组成了 LIR 下次的IRR。如果当前 R 都大于 IRR 了,那 LIR 的下次 IRR 肯定也比 HIR 的 IRR 大。
通过论文的例子来说明一下:
A ~ E 代表数据的块,下面一行的代表虚拟时刻。X 代表某个时刻,当前数据块被访问。
最后两列代表,在时刻 10,每个数据块的 IRR 和 R 的值。
假设 Llirs = 2, Lhirs = 1,可以看到 A 和 B 的 IRR 最小,那么 LIR 的块就是 A 和 B。 HIR 就是 {C, D, E},由于 Lhirs = 1,所以驻留在内存的块只有 E,C 和 D 的数据都不在内存中。
假设 10 再次访问的块是 D,那么 D 的 IRR 就变为了 2,B 的 R 还是 3。
基于上面的判断,D 的 IRR 大于 B 的 R,那么 B 的下一次 IRR 一定就更大了。而 D 的下一次访问可能会很快到来,因此 D 更有可能为热数据,所以 D 变为 LIR, 而 B 变为 HIR。
由于 HIR 的容量只有 1,所以需要把原来的 HIR E 淘汰掉,因此 E 变为非驻留内存的块。
如果 10 的时刻,访问的是 C,那么 C 的 IRR 就是当前时刻的 R,即为 4。不大于任何一个 LIR 的 R,所以不会发生置换。 C 会成为新的驻留内存的 HIR,淘汰掉 E。
从 上图看来,LIRS 会维护一个栈 S ,存储 LIR 和 HIR,HIR 可以有两种状态:内存驻留和非驻留。
另外维护一个队列 Q,持有所有驻留的 HIR,基于 FIFO 的规则。一旦 Q 中不再持有 HIR,那么对应在 S 中的 HIR 就会成为非驻留内存的块。Q 的大小就是上述所说的 Lhirs 。
因为LIR 的栈底一定是 LIR,如果数据置换后,栈底不是 LIR,那么就需要执行 栈剪裁(stack pruning):将底部的 HIR 继续淘汰,直到栈底是 LIR。
这样的淘汰方式非常的直观,一个数据块在栈 S 中从栈顶随时间推移到栈底的过程中,一直驻留内存。这么一段时间里,都没有被再次访问的话,就不需要再为其安排更长时间的内存空间了。换而言之,下次访问,就是 HIR 的冷数据了,统计信息就需要重新计算了。
这样数据结构就可以轻易维护 R,并通过 R 比较 IRR。
数据块在 S 中的位置就是 R 值,新数据和被命中的 LIR 块写入 S 头部就是为了动态维护 R 值,当一个 HIR 块提升为 LIR 时,需要淘汰一个 LIR 块,上文说了淘汰的策略是对比 IRR 值,当某个 HIR 的 IRR 值小于某个 LIR 的 IRR 值时,则将这两个数据块的状态互换。LIRS 抽象的点在于并没有看到 IRR 被维护,也就无法进行对比,这也是其设计的巧妙之处。
IRR 是由 R 值计算得出,具体的计算方式上文有提及: 上一个访问时刻R值
- 当前命中时的R值(0)
,当一个块被命中时,其 IRR 值也就立即刷新。 要对比 IRR 值其实可以通过对比 R 值来实现,如果一个 HIR 块的 R 值小于某个 LIR 块的 R 值,那么当这个 HIR 块被命中时,其 IRR 值肯定小于这个 LIR 块的下一个 IRR 值。所以剔除 LIR 时简单的比较 R 值就行,这也是上面剔除逻辑中,直接剔除 S 底部的 LIR 的原因,因为 S 底部的 LIR 其 R 值最大。
而进行栈剪裁的目的是为了维护 HIR 块和 LIR 块之间 IRR 值比较的环境, 底部剩余的 HIR 其 IRR 本身也很大,且因为没有 LIR 可比较,没有机会变成 LIR。
从数据访问的角度来说,其实一共为三种情况:
LIRS 中没有对 S 的大小进行限制,这在极端情况下会导致 S 大小不可控,弥补措施是给一个最大数量限制,当超过限制时,往 S 中写入一个数据之前要先剔除一个 S 底部的 HIR 块。
记得 LRU 的三个缺点吗?
突发访问的数据污染真正的热数据。LIRS 单次访问并不会马上定义为 LIR,所以突然大量访问的这部分数据块会很快被淘汰;
循环顺序访问的情况。
一开始内存空间足够时,数据会直接定义为 LIR。后面内存不足时,数据则为 HIR。假设前面 80% 的数据会驻留内存,后面 20% 访问数据会相继被淘汰。等第二次循环时,80% 的数据依旧会被命中。那么这个简单的场景下,可以得到内存不足率 20 % 完全等于未命中率。
当然,更复杂的情况,也可以有接近的概率。
索引和数据访问频度不同。通过对 HIR 的驻留和非驻留的区分,可以以不同策略维护索引和数据。更多的实践场景,可以基于此进行营业。比如 MySQL 的索引和数据。
单说算法不直白,性能如何是关键。从原文的性能测试结果来看, LIRS 在大部分内存场景下还是有优于 LRU 及其变种的。不过内容有点多,就没看下去。
有兴趣的同学,可以尝试自己实现这个策略。
题外话,LIRS是我从 Guava 的 Cache 原始项目 ConcurrentLinkedHashMap 中看到的。据说原作者出了更强的 Caffeine,用了号称 “现代” 的内存淘汰策略 W-TinyLFU。以后有机会可以研究下。
2Q:A Low Overhead High Performence Buffer Management Replacement Algorithm
LIRS:An Efficient Low Interreference Recency Set Replacement Policy to Improve Buffer Cache Performance
https://ruby-china.org/topics/38516?page=2