LRU 不大行?试试 LIRS

LRU 不大行?试试 LIRS

LIRS 是什么?

Low Inter-reference Recency Set,它和 LRU 一样,是一种内存淘汰策略。继承了 LRU 根据时间局部性预测内存冷热数据的特性,引进了 IRR(Inter-Reference Recency)R (Recency),来增加对冷热数据的预测准确性,从而减少错误的数据淘汰,提高缓存命中率。

既然说到 LIRS 是继承 LRU,那就不得不聊聊 LRU 有哪些优缺点,以及顺便聊聊大佬们还有哪些改进吧。

先来说说 LRU

内存淘汰策略是为了解决内存空间有限的情况下,尽可能的避免有价值的数据被淘汰。

一般来说,有价值的数据就是我们常说的热点数据。

LRU 就是这么一种广泛使用的策略,其本质就是在内存不足的情况下,剔除掉内存中最近最少使用的数据,为新数据提供空间。

最近最少使用的数据,是根据时间局部性原理来预测的。基于此,该策略认为,在某个时刻被访问的数据,那么短时间内,可能会被再次访问,其访问的概率是大于相对更久之前的访问数据。

LRU 最大的优点在于简单,仅需要维护数据本身,而不需要维护更多的额外信息,来进行预测。

但是不需要其他信息的缺点也十分明显:

  • 可能因为突然热度,或大的数据块,导致大部分数据的访问次数或热度不寻常的提高。导致这部分偶发性的大量数据替换“真实频繁访问”的数据,而驻留在内存中。

  • 在文件仅比内存大一点,而循环访问文件的情况下。访问到文件末尾时,由于内存限制,会淘汰掉马上就要访问的文件开头的数据。理想情况下,非命中概率 应该接近于 缓存空间不足概率

  • key 对比于数据库中,就是索引的概念。一般来说,索引访问的频率是远大数据的。

    类比之下,缓存的 key 和 value的访问频率其实不一致。那内存持有的时间也应该不一样。这就是为什么 LIRS 栈中会存在非持久化key的原因,本质上就代表着不同持有时间。

其实基于以上的问题,也有过许多其他优秀的策略,或者改进版。

LFU

比如为了解决偶发的冷数据替换热数据的问题,LFU 追踪数据的历史信息,即访问频率,选择访问频率最低的淘汰。但是,过于关注历史信息,无法适应时间的变化。比如某个时刻过大的访问频率,将导致该数据永远无法被淘汰。这就是淘汰策略的时间适应性问题。

LRU-K

诸如 LRU 的改进版 LRU-K ,其主要目的也是为了解决 LRU 的冷热数据的问题。核心思想就是将“最近使用过 1 次”的判断标准扩展为“最近使用过 K 次”。

实现上,需要多维护 K 个队列,用来记录缓存数据的访问历史。只有当数据的访问次数达到 K 次时,再将数据放入缓存。淘汰时,会淘汰第 K 次访问时,间距当前时间最大的数据,即原先 LRU 的规则。

访问历史队列的淘汰策略,可以有不同规则,比如 LRU、FIFO。

固然解决了上述说到的第一、第三个问题,但是依然存在一些问题:

  • 由优先级队列的排序操作需要额外的O(logN)的时间复杂度,N为缓存的大小。
  • 对于访问频率没有明显差异的数据,LRU-K 的作用只约等于 LRU,而且凭空多了多余的操作。

实际应用中 LRU-2 是综合各种因素后最优的选择,LRU-3 或者更大的 K 值命中率会高,但适应性差,需要大量的数据访问才能将历史访问记录清除掉。

2Q

作者声称该方案的性能与 LRU-2 相同,其优化在于针对于 LRU-K 的 O(logN) 的时间复杂度,优化为 O(1)。 2Q 方案可以解决 LRU 较长时间的顺序引用和循环引用,即上述的第二个问题。

2Q 维护了两个缓存队列,一个 FIFO 队列,一个 LRU 队列。

当数据第一次访问时,2Q 算法将数据缓存在 FIFO 队列里面,当数据第二次被访问时,则将数据从 FIFO 队列移到LRU 队列里面,两个队列各自按照自己的方法淘汰数据。

相当于将 FIFO 队列作为一个冷数据的缓冲区域,从而解决冷数据“污染”热数据的问题。

随之带来的就是 FIFO 队列的大小问题,如果太小则有些数据来不及提升为热数据就可能被移除 FIFO,如果太大则会过多占用有限的空间,所以其比例需要根据情况进行配置。

more…

剩下还有像 LRFU 结合了 LRU 和 LFU,通过权重控制两者的倾向性,其问题还是在于需要根据系统性能、工作负载等等因素人为调整权重。或者 MQ、ARC 等等。

LIRS

说了这么多,主要还是为了说明 LRU 作为普遍使用的内存淘汰策略,都有什么优缺点,以及改进方案。正所谓,取其精华,去其糟粕。 LIRS 继承了 LRU 的模型,从而保证了其简单性;通过 IRR 和 R 准确、动态地维护冷热数据,保证了其适应性。

IRR 和 R

上面两次提到了 IRR 和 R,那么到底是什么呢?

首先,它们是衡量数据缓存的重要指标,含义如下:

  • IRR(Inter-Reference Recency) :最近连续两次块访问之间访问其他块的个数。
  • R (Recency):最近一次访问到当前时间内访问其他块的个数,即 LRU 的维护的数据。

基于以上两个指标,动态维护了块的类别和状态。

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 或被淘汰。这也是动态的关键之一。

复用 LRU 模型

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 大。

通过论文的例子来说明一下:

LRU 不大行?试试 LIRS_第1张图片

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。

LRU 栈的应用

LRU 不大行?试试 LIRS_第2张图片

从 上图看来,LIRS 会维护一个栈 S ,存储 LIR 和 HIR,HIR 可以有两种状态:内存驻留和非驻留。

另外维护一个队列 Q,持有所有驻留的 HIR,基于 FIFO 的规则。一旦 Q 中不再持有 HIR,那么对应在 S 中的 HIR 就会成为非驻留内存的块。Q 的大小就是上述所说的 Lhirs

  • 当缓存空间未满时,所有数据块访问都为 LIR,写入 S 中。
  • 当 LIR 数据块在 S 中被命中时,将块移动到 S 的顶部(维护 R 值)
  • 当 LIR 的数量到达上限时,新加入的数据块为 HIR,同时写入 S 和 Q 的头部(R 值为 0)
  • 当 Q 的空间不足时,则剔除 Q 尾部的数据,如果数据在 S 中,则将数据块设为非驻留 HIR 块,如果不在 S 中,则将数据彻底剔除。
  • HIR 被命中时,将其提升为 LIR 并移动到 S 顶部,如果存在于 Q 中,则将其从 Q 中剔除。将 S 底部的 LIR 剔除出 S(需要进行栈剪裁),降级为非驻留 HIR,写入 Q 的头部 (如果没能及时被再次访问会被彻底剔除)。由于 Q 的容量极小,一般很快就会淘汰出内存。

因为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。

实现细节

从数据访问的角度来说,其实一共为三种情况:

  • 访问的是 LIR X,将 X 移到 S 栈顶。如果原来在栈底,考虑是否进行栈剪裁。
  • 访问的是 HIR X,首先一定需要把 X 移至 S 栈顶。
    • 如果 X 原来就在 S 中,那么和栈底的 LIR 进行置换。栈底的 LIR 变为 HIR 后,移至 Q 的队尾 ,并考虑栈剪裁。
    • 原来不在 S 中,则继续维持 HIR,并移到 Q 的队尾。
  • 访问的是非驻留的 HIR X,那么就需要新的内存空间,则把 Q 队头的 HIR 数据移除, S 中的对应 HIR 变为非驻留。X 的数据占用内存空间,X 移到 S 栈顶。处理情况同 HIR X。

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

你可能感兴趣的:(算法)