W-TinyLFU缓存淘汰策略

5,470 阅读15分钟

背景

对于需要缓存的场景,一个优秀的缓存淘汰策略可以提高缓存的命中率,我们最常使用的缓存淘汰策略应该是LFU(最少使用)LRU(最近最少使用)

对于LFU来说,它需要维护每个元素的频率,为了准确需要维护全局元素的频率,这会带来巨大空间成本;而且一个元素建立起频率后将很难被淘汰,但是现实场景中访问频率会随着实际发生变化。

LRU是一个很优秀的缓存淘汰策略,但是它需要更大的空间来保证元素不会在下次访问之前被淘汰。

而W-TinyLFU则是一种非常优秀的缓存淘汰策略,它综合的考虑了现实场景中可能会遇到的各种问题,具有以下特点:

  • 准入策略:进入缓存的元素必须是能够提高缓存命中率的。
  • 基于频率:它具有LFU的优点,但使用了一种低空间消耗的频率统计方式,以避免LFU由于频率统计带来的巨大空间消耗。
  • 保鲜机制:前面提到了LFU建立起一定频率后就难以被淘汰,但是现实场景中访问频率是会随着时间变化的,因此W-TinyLFU加入了保鲜机制,来保证缓存中的元素是频繁被访问的。保鲜机制简单来说就是让每个元素的频率会随着时间降低,而不是一直保持不变。

基础数据结构和算法

W-TinyLFU是由多个独立的组件组合而成,因此我们先介绍一下与W-TinyLFU相关的数据结构和算法。

LFU(最少使用缓存淘汰策略)

LFU是一个基于频率的淘汰策略,也就是根据元素被使用次数进行淘汰。优点是如果元素的访问频率不会随着时间变化,那么它可以有效的保护热门数据。但是如果元素的访问频率随着时间变化,那么之前访问频率很高的元素会难以被淘汰。

一般的实现不会记录所有的元素的频率,因为那样空间成本会很大,只会记录存在缓存中的元素的频率。缓存中每个元素都需要记录访问频率,并使用一个双向链表来表示频率的大小关系,新加入缓存的元素被设置到链表尾部,每次被访问频率会增加,如果频率大于前面元素的频率,则前移一位。

比如一开始元素的频率和链表如下:

LFU.png

两次访问元素3之后,由于元素3的频率大于它前面的元素2,因此把它往前面移动一位。

LFU2.png

空间不足淘汰时选择链表尾部的元素进行淘汰。

LRU(最近最少使用缓存淘汰策略)

LRU的淘汰策略是选择最近最少使用的元素进行淘汰,换句话说就是在缓存中选择一个最久没有被使用的元素淘汰。

直观的理解是一个元素一开始会被从链表头部加入,链表头部表示最近被访问的元素,链表尾部表示最久没有被访问的元素。链表中的元素如果没有被访问会慢慢的被其他的元素推向链表尾部,而如果缓存中的元素被访问则会重新放到链表头部。

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

LRU.png

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

LRU2.png

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

SLRU(Segmented LRU,分段最近最少使用缓存淘汰策略)

上面的LRU有一个问题,如果一个元素只被访问一次,那么它也会把其他元素给挤出去。这样就会导致如果我们的缓存空间不够长,遇到突发的稀疏流量(比如列表遍历)将会把大量元素给挤出去,留下一堆很可能不会再次被访问的元素在缓存中,导致缓存命中率下降。

而SLRU就是把缓存分成两段,一段是淘汰段,一段是保护段,两个段都是普通的LRU实现。第一次被访问的元素将进入淘汰段,只有处于淘汰段中的元素再次被访问才会进入保护段。保护段中的元素如果被淘汰将会再次进入淘汰段,而淘汰段的元素被淘汰则会被移出缓存。

简单来说就是每个元素至少两次被访问才会进入保护段,而保护段中的元素是受保护的,它更难被淘汰,因为就算被淘汰也只是移动到淘汰段。

也就能更好的抵御突发的稀疏流量,保护段的元素不会被只访问过一次的元素给淘汰。

SLRU.png

空间不足时只会从淘汰段淘汰,保护段的元素不会被直接淘汰。

BloomFilter(布隆过滤器)

布隆过滤器(BloomFilter)是一种用于判断元素是否存在的方式,它的空间成本非常小,速度也很快。

但是由于它是基于概率的,因此它存在一定的误判率,它的Contains()操作如果返回true只是表示元素可能存在集合内,返回false则表示元素一定不存在集合内。因此适合用于能够容忍一定误判元素存在集合内的场景,比如缓存。

布隆过滤器的数据结构是一个位向量,也就是一个由01所组成的向量(下面是一个初始向量):

基础结构.png

每个元素添加进布隆过滤器前,都会经过多个不同的哈希函数,计算出不同的哈希值,然后映射到位向量上,也就是对应的位上面置1:

哈希映射.png

判断元素是否存在也是如上图流程,根据哈希函数映射的位置,判断所有映射位置是否都为1,如果是则元素可能存在,否则元素一定不存在。

由于不同的值通过哈希函数之后可能会映射到相同的位置,因此如果一个不存在的元素对应的位位置都被其他元素所设置位1,则查询时就会误判:

哈希映射_误判.png

假设上图元素3334并没有加入集合,但是由于它映射的位置已经被其他元素所映射,则查询时会误判。

具体可以看文章布隆过滤器:一种低空间成本的判断元素是否存在的方式

CountMinSketch(近似计数器)

CountMinSketch是一种计数器,用来统计一个元素的计数,它能够以一个非常小的空间统计大量元素的计数,同时保证高的性能及准确性。

与布隆过滤器类似,由于它是基于概率的,因此它所统计的计数是有一定概率存在误差的,也就是可能会比真实的计数大。比如一个元素实际的计数是10,但是计算器的计算结果可能比10大。因此适合能够容忍计数存在一定误差的场景,比如缓存中元素被访问频率。

CountMinSketch计数器的数据结构是一个二维数组,每一个元素都是一个计数器,计数器可以使用一个数值类型进行表示,比如无符号int

基础结构.png

对于增加计数操作,每个元素会通过不同的哈希函数映射到每一行的某个位置,并增加对应位置上的计数:

哈希映射.png

估算计数也是如上图流程,根据哈希映射到每一行的对应位置,然后读取所有行的计数,返回其中最小的一个。

返回最小的一个是因为其他其他元素也可能会映射到自身所映射位置上面,导致计数比真实计数大,因此最小的一个计数最可能是真实计数:

哈希映射_误判.png

比如上图元素123映射到了元素abc第一行的相同位置,因此这个位置的计数累加了元素abc和元素123的计数和。但是只要我们取三行里面最小的一个计数,那么就能容忍这种情况。

当然,如果一个元素的每一行的对应位置都被其他元素所映射,那么这个估算的计数就会比真实计数大。

具体可以看CountMinSketch计数器:基于布隆过滤器思想的近似计数器

W-TinyLFU

整体架构

W-TinyLFU由多个部分组合而成,包括窗口缓存过滤器主缓存

主缓存是使用SLRU,元素刚进入W-TinyLFU会在窗口缓存暂留一会,被挤出窗口缓存时,会在过滤器中和主缓存中最容易被淘汰的元素进行PK,如果频率大于主缓存中这个最容易被淘汰的元素,才能进入主缓存。

W_TinyLFU.png

窗口缓存

前面提到,W-TinyLFU选择一个元素是否加入缓存,得看这个元素加入缓存能否提高整体缓存的命中率,而这个评估的依据就是根据元素的频率。但是如果一个刚加入缓存的元素(表示元素刚刚才开始被访问),它的频率并不足以让它加入缓存,那么它会直接被淘汰。

因此在W-TinyLFU中使用LRU来作为一个窗口缓存,主要是让元素能够有机会在窗口缓存中去积累它的频率,避免因为频率很低而直接被淘汰。

窗口缓存很小,只占整个W-TinyLFU的1%。

基于频率

在W-TinyLFU中主要是使用了LFU的访问频率的思想,根据访问的频率进行PK决定元素的去留,不过W-TinyLFU使用了另外的方式来统计元素的访问频率,也就是前面提到的BloomFilter和CountMinSketch。

频率统计机制

W-TinyLFU中使用BloomFilter+CountMinSketch来统计元素的访问频率,BloomFilter作为一个前置计数器,而CountMinSketch则作为主计数器。

BloomFilter避免前面所提到的稀疏流量对CountMinSketch计数器的影响,也就是稀疏流量只会在BloomFilter中进行计数(可以当成是最大值为1的计数),换句话说就是如果BloomFilter中没有计数则先把这次的计数加到BloomFilter中。

在W-TinyLFU中使用一个4bit大小的CountMinSketch计数器来统计每个元素的访问频率,它是主要的计数器,元素在第一个计数会记录在BloomFilter中,之后就会记录在CountMinSketch中。

需要BloomFilter的主要原因是CountMinSketch也是基于概率的,在计数的正确性一定的情况下,越多的元素进入CountMinSketch计数器,那么CountMinSketch就需要越大和越多的哈希函数。而BloomFilter可以帮忙抵挡那部分计数值还不需要那么大的元素(也就是计数值只有1),这样我们就可以减小CountMinSketch计数器的大小。

计数器.png

估算元素计数值时需要把BloomFilter和CountMinSketch的计数进行求和,对于每个元素,BloomFilter最大只能表示计数值1,而CountMinSketch计数器4bit可以表达最大计数值15,因此每个元素最大能表达的计数值是16。

保鲜机制

前面提到了LFU建立起一定频率后就难以被淘汰,但是现实场景中访问频率是会随着时间变化的,因此W-TinyLFU加入了保鲜机制,来保证缓存中的元素是频繁被访问的。保鲜机制简单来说就是让每个元素的频率会随着时间降低,而不是一直保持不变。

具体做法很简单,我们会在进行一定次数的操作之后,把前面提到的BloomFilter和CountMinSketch计数器的计数值进行衰减。对于BloomFilter会直接清空(置0),而CountMinSketch则会把每个元素的计数除以二。

这样不仅可以让元素的频率随着时间降低,而且还避免BloomFilter和CountMinSketch长时间使用后被污染(因为这两个数据结构都是基于概率的,因此每个位置都可能映射多个元素,时间长了造成的污染会越来越大)。

主缓存

主缓存中的元素会被有效保护,除非主缓存中的最可能被淘汰的元素在过滤器PK时输给了从窗口缓存中淘汰的元素,才会有主缓存中的元素被淘汰。

应用

实现

这里给出一个简单Golang实现,这个实现主要是尽可能的保持每个组件独立,然后进行组合。

其实一般实现会考虑整体结构,比如全局使用一个Map,在Entry里面存储元素所处缓存位置(窗口缓存、淘汰段、保护段)和元素的哈希值,这样组件之间虽然有一定耦合,但是能够减少哈希、判断次数,以提高性能。

代码地址

数据结构

W-TinyLFU的数据结构是上面所提到的组件的组合,比如布隆过滤器,CountMinSketch计数器,窗口缓存LRU,主缓存SLRU,采样计数。

bytesFunc主要是为了把Key转换成字节数组,这样才能计算哈希值。

type Cache[K comparable, V any] struct {
	filter           *bloom.Filter     // 过滤器
	counter          *cm.Counter4      // 计数器
	window           *lru.Cache[K, V]  // 窗口缓存
	main             *slru.Cache[K, V] // 主缓存
	samplesThreshold uint64            // 采样阈值,到达阈值计数会减半
	samples          uint64            // 当前采样数量
	bytesFunc        BytesFunc[K]      // 把Key转换成Bytes的函数
}

添加元素

添加元素的流程如下:

  • 增加元素的计数
  • 把元素添加到窗口缓存
  • 如果窗口缓存中有元素candidate被淘汰
    • 获取主缓存中最可能被淘汰的元素victim
    • candidate和victim根据频率进行PK
    • 如果candidate获胜则进入主缓存
    • 失败则被淘汰
// 添加或更新元素
// 返回被淘汰的元素
func (c *Cache[K, V]) Put(key K, value V) *cache.Entry[K, V] {
	// 计算元素哈希值
	hash := c.hash(key)

	// 增加元素计数
	c.inc(hash)

	// 先添加到window
	candidate := c.window.Put(key, value)
	if candidate == nil {
		return nil
	}

	// 获取main里面的最可能被淘汰的元素
	victim := c.main.Victim()
	if victim == nil {
		return c.main.Put(candidate.Key, candidate.Value)
	}

	// candidate和victim进行PK
	candidateFreq := c.estimate(c.hash(candidate.Key))
	victimFreq := c.estimate(c.hash(victim.Key))
	// 如果candidate胜利则加入主缓存
	if candidateFreq > victimFreq {
		return c.main.Put(candidate.Key, candidate.Value)
	}

	// 否则就被淘汰了
	return candidate
}

获取元素

获取元素的流程如下:

  • 增加元素的计数
  • 尝试从窗口缓存获取
  • 如果窗口缓存没有则尝试从主缓存获取
  • 如果都没有则获取失败
// 获取元素
func (c *Cache[K, V]) Get(key K) (V, bool) {
	// 计算元素哈希值
	hash := c.hash(key)

	// 增加元素计数
	c.inc(hash)

	// 判断元素是否存在window
	if value, ok := c.window.Get(key); ok {
		return value, true
	}

	// 判断元素是否存在main
	if value, ok := c.main.Get(key); ok {
		return value, true
	}

	// 不存在返回空值和false
	var value V
	return value, false
}

小结

可以发现在提前实现W-TinyLFU的各个组件的基础上,W-TinyLFU的主要逻辑其实并不复杂,它的复杂度主要是集中在它的多个组件上,因此学习W-TinyLFU其实也是在学习BloomFilter、CountMinSketch、SLRU这些优秀的数据结构~

测试

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

LFU

缓存比例命中数量命中率
0.1%2832213.75%
0.3%5982729.04%
0.5%8898443.19%
0.7%11566056.13%
1.0%14997072.78%
2.0%18742690.96%
3.0%19066692.53%
5.0%19256993.46%
10.0%19284293.59%

LRU

缓存比例命中数量命中率
0.1%2671712.97%
0.3%5816928.23%
0.5%8744642.44%
0.7%11435855.50%
1.0%14855672.10%
2.0%18728690.89%
3.0%19064992.53%
5.0%19260693.48%
10.0%19284293.59%

SLRU

缓存比例命中数量命中率
0.1%3009314.60%
0.3%6748132.75%
0.5%10159049.30%
0.7%13136063.75%
1.0%16416279.67%
2.0%18915191.80%
3.0%19115192.77%
5.0%19262093.48%
10.0%19284293.59%

TinyLFU

缓存比例命中数量命中率
0.1%3114815.12%
0.3%7030634.12%
0.5%10660551.74%
0.7%13833767.14%
1.0%17069482.84%
2.0%18888591.67%
3.0%19111692.75%
5.0%19263293.49%
10.0%19284293.59%

测试结果

可以发现W-TinyLFU的命中率是最好的,而且具有比较强的适应能力。不过实现起来还是比较复杂,如果没有那么高的命中率需求,也可以考虑使用SLRU,它的命中率也很高。

参考

TinyLFU: A Highly Efficient Cache Admission Policy