掘金 后端 ( ) • 2022-09-20 22:12

背景

使用过Redis做缓存的小伙伴肯定知道Redis里面有很多种缓存淘汰策略,其中有一种就是LRU。LRU其实就是最近最少使用(Least Recently Used,LRU),一般它的实现是使用一个双向链表:

比如一开始链表如下,则元素3是最近被访问过的,元素1是最久没有被访问过的。

LRU.png

如果我们这时候访问元素1,则把元素1重新放到链表头部,表示它是最近被访问过的。

LRU2.png

空间不足淘汰时选择链表尾部的元素进行淘汰,因为尾部的元素是最久没有被使用的。

LRU其实是一种非常有效的缓存淘汰策略,但是大家都知道Redis的内存空间是很宝贵的,而维护LRU的双向链表需要使用比较多的额外空间,至少需要一个前向指针、一个后向指针和一个指向数据的指针。

为此Redis的作者使用了一种基于随机采样的近似LRU(NearlyLRU),它在Redis里面是不需要花费额外空间的。

随机采样

随机淘汰其实是一种非常看运气的策略,它最好能达到理想的命中率,但是也可能达到最坏的命中率。当然它也不是完全没有用的,如果每个元素的权重是一样的,那么它确实是可以考虑。

当然更加一般的情况我们想要的是频繁被访问的元素不会被淘汰,而LRU刚好就有这种思想,其实LRU也可以实现为一个根据元素被访问时间戳排序的列表,每次淘汰列表的尾部的元素,也就是时间戳最小的元素。而这种最值场景一般需要维护一个堆结构,但是堆结构对于修改来说比较耗时。

而NearlyLRU就是利用了这个时间戳的思想,也就是每个元素带有最后一次被访问的时间戳(Redis里面本来就有记录,因此不需要额外的空间),但是它没有去维护一个堆,因此它只能随机进行采样。淘汰算法流程如下:

  1. 从所有元素里面随机采样5个元素
  2. 淘汰5个里面最后一次被访问的时间戳最小的元素

可以发现第2步其实就有LRU的思想,只是LRU是选取全部元素里面最后一次被访问的时间戳最小的元素,而NearlyLRU则是采样一小批。

因此NearlyLRU其实命中率是不如LRU的,但是它的好处也是明显的,不需要额外的数据结构。

如果想提高命中率,可以增大采样数量,这样会更加接近LRU,当然时间成本也会相应的上升。

实现

一个Golang的简单实现。

数据结构

可以看到数据结构非常简单,基本上只有一个map类型的entries,entries的值是lastAccessEntry,也就是Key和Value加上一个最后一次访问的时间戳,其实这个时间戳可以使用更加轻量的类型,比如int32。

type Cache[K comparable, V any] struct {
entries  map[K]*lastAccessEntry[K, V]
capacity int                 // 容量
samples  int                 // 淘汰时采样数量
onEvict  cache.OnEvict[K, V] // 淘汰时的回调函数
}

type lastAccessEntry[K comparable, V any] struct {
entry      *cache.Entry[K, V]
lastAccess time.Time // 最后一次使用时间
}

添加元素

添加元素分为三个小部分,第一个是如果元素存在直接设置新值和时间戳;第二个是如果缓存满了则淘汰一个元素;最后是添加新元素。

// 添加或更新元素
// 返回被淘汰的元素
func (c *Cache[K, V]) Put(key K, value V) *cache.Entry[K, V] {
// 如果 key 已经存在,直接设置新值
if entry, ok := c.entries[key]; ok {
entry.entry.Value = value
entry.lastAccess = time.Now()
return nil
}

// 如果已经到达最大尺寸,先剔除一个元素
var evicted *cache.Entry[K, V]
if c.Full() {
evicted = c.Evict()
}

// 添加元素
c.entries[key] = &lastAccessEntry[K, V]{
entry: &cache.Entry[K, V]{
Key:   key,
Value: value,
},
lastAccess: time.Now(),
}
return evicted
}

淘汰元素

因为Golang的map遍历的时候本来就是随机的(Golang故意加了随机种子,避免依赖于map的顺序),因此我们采样的时候直接使用遍历采集需要的样本,然后挑选里面时间戳最小的进行淘汰。

// 淘汰元素
func (c *Cache[K, V]) Evict() *cache.Entry[K, V] {
// 采样
var evictEntry *lastAccessEntry[K, V]
i := 0
for _, entry := range c.entries {
// 挑选里面时间戳最小的
if evictEntry == nil || entry.lastAccess.Before(evictEntry.lastAccess) {
evictEntry = entry
}
i++
if i >= c.samples {
break
}
}
if evictEntry == nil {
return nil
}
        // 淘汰
delete(c.entries, evictEntry.entry.Key)
// 回调
if c.onEvict != nil {
c.onEvict(evictEntry.entry)
}
return evictEntry.entry
}

设置采样数量

这个策略有一个参数需要设置,也就是采样数量。

// 设置采样个数
func (c *Cache[K, V]) SetSamples(samples int) {
        // 采样数量不能太小,否则和随机没区别
if samples < MinSamples {
samples = MinSamples
}
        // 也不能太大,否则和LRU没区别
if c.Cap() < samples {
panic("too large samples")
}
c.samples = samples
}

测试

测试是在一个有20.6w条日志记录的数据集里面进行,分别测试缓存大小为数据集合大小的0.1%、0.3%、0.5%、0.7%、1%、2%、3%、5%和10%的情况下的命中率。

LRU

缓存比例 命中数量 命中率 0.1% 26717 12.97% 0.3% 58169 28.23% 0.5% 87446 42.44% 0.7% 114358 55.50% 1.0% 148556 72.10% 2.0% 187286 90.89% 3.0% 190649 92.53% 5.0% 192606 93.48% 10.0% 192842 93.59%

Random

缓存比例 命中数量 命中率 0.1% 26320 12.77% 0.3% 55328 26.85% 0.5% 79768 38.71% 0.7% 100646 48.85% 1.0% 125155 60.74% 2.0% 167517 81.30% 3.0% 181521 88.10% 5.0% 190827 92.61% 10.0% 192842 93.59%

NearlyLRU

这里多添加了一个采样数量参数,分别是5、10和50个。

采样数量 缓存比例 命中数量 命中率 5 0.1% 26692 12.95% 5 0.3% 56646 27.49% 5 0.5% 84593 41.05% 5 0.7% 108402 52.61% 5 1.0% 140077 67.98% 5 2.0% 182233 88.44% 5 3.0% 189174 91.81% 5 5.0% 192474 93.41% 5 10.0% 192842 93.59% 10 0.1% 26442 12.83% 10 0.3% 57389 27.85% 10 0.5% 86084 41.78% 10 0.7% 112183 54.45% 10 1.0% 144483 70.12% 10 2.0% 185303 89.93% 10 3.0% 190134 92.28% 10 5.0% 192562 93.45% 10 10.0% 192842 93.59% 50 0.1% 26701 12.96% 50 0.3% 57972 28.14% 50 0.5% 87013 42.23% 50 0.7% 113776 55.22% 50 1.0% 147484 71.58% 50 2.0% 187028 90.77% 50 3.0% 190618 92.51% 50 5.0% 192600 93.47% 50 10.0% 192842 93.59%

测试结果

可以看到NearlyLRU的命中率是不如LRU的,但是比Random好很多,在采样数量5就能达到67%的命中率。而且随着采样数量增加,在采样数量50的时候已经接近LRU。

总结

这是一个非常有意思的结构,它也是带有随机性的,但是它通过引入时间戳+采样,在避免了空间的消耗的同时,还能保证不错的命中率,而且实现非常简单。适合那些需要简单的淘汰策略,不能有太多的额外空间消耗的场景。