命中率领先,缓存准入策略对驱逐策略的增强,W-TinyLFU缓存策略解析

509 阅读11分钟

缓存是有效提升系统效能的手段,在各个场景广泛使用:

  • 系统平时工作正常,但一旦遇到流量高峰或用户突然增加,接口响应时间显著增长,导致系统卡顿,甚至崩溃。
  • 网络流量费用太高,几百块难以cover宽带的成本账单,急需降本增效。
  • 相同的接口结果重复计算,既耗时又浪费计算资源。

从结构上来看,缓存经常设置在终端用户和服务提供方之间的位置。不同的位置有不同的作用:

  • 如果设置在更靠近用户,它能减少向服务方的请求,从而节省服务方的成本。
  • 如果设置在更靠近服务方,缓存能提升服务方的处理能力、避免重复计算。

在实际场景里,缓存的使用是成体系化的,既需要在用户端设置,也需要在服务端设置。其组成部分包含浏览器Disk Cache、CDN、分布式缓存(如Redis)、In-Memory Cache等等。

一、以命中率(Hit Ratio)为核心

如果能规避数据的volatile(易变)问题,用上缓存就能在性能和吞吐量上两全其美。但高楼万丈平地起,缓存美丽的表现离不开扎实的硬件基础。大部分缓存都依赖高速但不可持久化的DRAM,而不是便宜却能持久化的NAND Flash。综合考虑价格和可靠性,缓存通常使用比原始数据小得多的空间来存储数据。
如果不看硬件本身的性能,缓存的引入增加了多余的中间环节,假若在操作数据时缓存不能生效,势必导致性能的下降。为此,命中率(Cache Hit Ratio)是体现缓存性能的核心指标:

Cache Hit Ratio=Number of Cache HitsTotal Number of Requests×100\text{Cache Hit Ratio} = \frac{\text{Number of Cache Hits}}{\text{Total Number of Requests}} \times 100

缓存命中(Cache Hit)即能够从缓存中获取数据。通过采集一段时间内的数据请求,就可以统计出命中缓存的数量。用它除以总请求数就得到了命中率。

命中率的分布符合一定的规律。互联网内容的访问情况常符合Zipfian(Zipf)分布,即大量的访问集中在少量的内容上,热点人物、热点新闻会“病毒式”的重复传播。以Zipf模拟的请求流量为例,如果使用LRU作为缓存策略,其命中率可稳定在以下规律[3](此处忽略缓存大小的影响,缓存大小取使其分布曲线的导数趋0时): image.png

图表的横轴为Zipf系数(Coefficient),该系数越大,数据访问对象越集中,系数<1时比较接近现实流量的访问分布。LRU作为广泛使用的经典缓存策略,即使有可观的缓存大小,在使用这种模拟访问流量的情况下,命中率仍较难达到30%(c<1)。

提升命中率需要有新方法。

二、TinyLFU和W-TinyLFU

形而上学的看,用户可能要访问的数据一般是两类:

  1. 最近刚访问的数据,即局部性原理。
  2. 一直以来访问最多的数据,即“习惯”。

他们各自有对应的缓存策略LRU、LFU。针对前面Zipf分布,单纯使用LRU的策略有难以突破的效率上限。为了有更好的表现,出现了许多融合多种基础策略形成的混合策略,如:

  • ARC(Adaptive Replacement Cache)策略:这是一种能够根据访问模式自动调整内部结构的缓存。
    ARC内部有两个队列,LRU List和LFU List,新元素首先进入LRU List,当它被重复访问就会被移入LFU List。ARC会统计历史访问情况,当某一个List的数据较多时,会自动增加该List的长度,从而提升对这种访问模式的适应能力。
  • SLRU(Segmented Least Recently Used)策略:它的内存也分为两个部分,其在LRU的基础上,增强了对LFU的适应能力。SLRU的内存分为probation和protected两个区域(可以理解为LRU和LFU),数据首先进入probation,重复访问会移入protected。SLRU数据流动逻辑和ARC相似,但其有自己独特的数据驱逐逻辑。probation区域数据满会直接移除,protected区域满则进入probation。也就是说protected(访问频率更高的数据)会活的更久。

这些混合策略相比朴素的原始策略都有了更好的缓存预测能力。但是他们的LFU的数据基础,都来自于LRU这个数据输入的“窗口”。如果高频数据恰好落在这个小窗口内,就能有效捕捉到目标数据,但这往往需要一定的运气。通常来说,只有设置较大的LRU窗口大小(Cache Size)才能提升目标对象被识别到的概率。但维持更大的窗口,存储更多的ghost entries,又会造成空间资源的浪费。

我们需要一种策略,它不仅能在较长的时间范围内统计访问频率,且成本不高,还能应对瞬时突发流量这样的特殊情况。这种情况在社媒中比较常见,轰动的新闻短时间会吸引极大的关注(是普通事件数百倍的曝光),但又会在很短的时间内被人遗忘。如果单纯使用LFU,就会导致这种高频过时数据一直在缓存中占用空间,无法被清除。

TinyLFU是符合这些要求的优秀策略之一,它综合运用这几个技巧来达成目的:近似大小统计、衰老(Aging)、及极小计数器(含Door Keeper)机制。

  1. 近似大小统计。LFU的首要目标是留下访问频率最高的这一部分数据,最高是一种相对概念,本质上来看,实现LFU并不需要精确的访问量统计。Bloom Filter可以在极小的空间上快速判断某个元素是否存在,但不能统计频率。Count-Min Sketch(CMS)是在Bloom Filter基础上实现的近似计数器,他把前者的“桶”换成了计数器。
    CMS在实现上不够好,低频元素会扰动高频数据的频次,为此,TinyLFU实现了自己的Minimal Increment Counting Bloom Filter机制。
    该机制统计频率的大概原理为:初始化一个计数器“桶”数组,输入的数据通过多个哈希函数定位到多个“桶”里的计数器,然后对最小的计数器进行+1操作。获取某个数据的频率时,同样通过哈希函数定位到多个计数器,并返回其中最小的计数值。

  2. 衰老(Aging)。LFU总是保留频率最高的数据,这不能适应互联网这种热点不断更替的访问模式。TinyLFU引入了Aging机制。算法根据缓存大小及访问数据量设置单个桶的计量上限,当某个计数器达到上限时,对所有桶进行减半操作。这样如果某个数据(也即某个桶)在被遗忘后,随着其他数据接连触达上限,它的计数器会被不断减半,直到衰退到0,从而在LFU中也被遗忘,以实现了数据的“保鲜”。

  3. 极小计数器(含Door Keeper)。一个整数用二进制来存储,最少需要占用logN(N为该整数)个bit位,如数字1024需要10个bit空间。TinyLFU为了避免空间浪费,给counter设置了上限:

    Supbound=Number of AccessCache Size\text{S}_{upbound} = \frac{\text{Number of Access}}{\text{Cache Size}}

    如果有1024次访问,Cache的大小为100,counter的上限约为1000/100≈10,数字10只需要4个bit就能存储,相比原始占用的空间减少了60%。由于Aging机制的存在,counter不保存原始大小不会影响最终在计数数组里面的相对次序。
    counter在设计上还考虑了初始大小的设置。数据访问的模式通常符合“长尾效应”,极少量的数据经常被访问,大量的数据很少访问。在这种模式下,约80%的数据只会被访问一次。如果为统计这些数据,设置了一个不适当的初始counter值,会导致空间利用率很不充足。TinyLFU引入了Door Keeper,所有的数据都先在其中进行统计,默认的空间都为1bit,从而大大减少了空间占用。

经过这些优化机制,TinyLFU能实现在比类似算法(Strawman)的平均大小减少约90%,同时在Zipf分布下,Hit Ratio比LRU高约10%[1]。

TinyLFU一般不会单独使用,它定位为数据准入策略,而不是淘汰策略(一般缓存策略的作用是这个),其用于判断新数据相比老数据谁更值得留在内存中。准入策略的定位和其维护的数据内容有关,TinyLFU维护了一个较大的虚拟的时间窗口,并统计窗口内数据访问频率的相对次序。这个窗口中的状态并不和内存中实际存储的数据对应。也就是说缓存项在TinyLFU中有状态,并不意味着有数据,这和一般的淘汰策略有很大的不同。

虽然TinyLFU有Aging来维护数据的新鲜度,但其虚拟的窗口维护的历史状态,仍可能会阻碍新数据达到准入策略的要求,从而失去进入缓存的机会。为了提升对新数据的适应能力,一般会在TinyLFU前再集成一个极小的Window(即LRU List)。

综合以上信息,TinyLFU的标准落地架构如下,这个架构也有一个自己的名字,W-TinyLFU。

image.png

TinyLFU在架构的核心位置,作为数据准入裁判的角色。新数据首先进入Window LRU(占1%内存),当Window存储不了时会交给TinyLFU裁决,以判断新数据和Main Cache(占99%内存)的数据谁更值得留下来。从架构规范上来看,Main Cache并不限制具体的缓存类型,可以使用SLRU这种比较高效的策略,也可以使用LRU、LFU这种比较经典的策略。但不论具体使用哪一种,将TinyLFU作为准入策略仍会带来不小的性能提升[1]。

三、Golang中的实现

W-TinyLFU不只是一个理论算法,它在各种场景实战中都取得了不错的成绩。Java生态里广泛使用的Caffeine就使用它作为底层的缓存策略。Caffeine在具体实现时又有自己的变种,它选择更广泛使用的Count-Min Sketch作为计数器桶(考虑到历史遗留代码),但TinyLFU能较好地适应这种变化,使用CMS仍能取得不错的表现。

Golang生态下也有许多缓存器集成了W-TinyLFU,以Ristretto(一种咖啡名)Library为例来解释W-TinyLFU实现。Ristretto流行度较高,Github Stars>5.5K。

缓存的实现关键体现在缓存结构的定义,以及策略的设置对缓存项准入的影响上。下面分三个方面来介绍:

  1. 定义Cache结构

    // cache.go
    type Cache[K Key, V any] struct {
            // 用来存储数据的核心的HashMap
            storedItems store[V]
            // 缓存准入和准出策略,TinyLFU就在其中实现
            cachePolicy policy[V]
    
            ......
    
    }
    
  2. 实现缓存策略
    policy是接口定义,其缓存策略的具体实现在defaultPolicy结构体中。

    // policy.go
    // policy用来封装所有的准入和淘汰操作
    type policy[V any] interface {
            ......
            
            // 尝试把数据加入缓存,如果能加入返回true
            Add(uint64, int64) ([]*Item[V], bool)
            // 判断key在缓存中是否存在
            Has(uint64) bool
            
            ......
    }
    
    // policy.go
    // defaultPolicy为policy的默认实现
    type defaultPolicy[V any] struct {
        // 同步锁,用来保证线程安全
        sync.Mutex
        // 准入策略
        admit    *tinyLFU
        // 淘汰策略
        evict    *sampledLFU
        
        ...... 
    }
    
  3. TinyLFU和淘汰策略的集成
    两者集成的逻辑主要体现在缓存项被添加时。Ristretto的实现没有引入前置Window。

    // policy.go Add(key uint64, cost int64) L172
    // 循环在evicitPolicy中寻找空间(room),直到空间能放下当前缓存项的花销(cost)
    for ; room < 0; room = p.evict.roomLeft(cost) {
        // 通过sampleLFU提取出最小的几个缓存项
        sample = p.evict.fillSample(sample)
    
        // 在TinyLFU这个更大的时间窗口中寻找sample中访问最少的那一个缓存项
        minKey, minHits, minId, minCost := uint64(0), int64(math.MaxInt64), 0, int64(0)
        for i, pair := range sample {
                // 获取TinyLFU中的频次
                if hits := p.admit.Estimate(pair.key); hits < minHits {
                        minKey, minHits, minId, minCost = pair.key, hits, i, pair.cost
                }
        }
    
        // 如果新项目的访问频率更低则放弃新项目
        if incHits < minHits {
                p.metrics.add(rejectSets, key, 1)
                return victims, false
        }
    
        // 否则删除老项目留出空间
        p.evict.del(minKey)
    
        // 更新被删除项目信息
        ......
    }
    

四、参考资料

  1. TinyLFU: A Highly Efficient Cache Admission Policy (arxiv.org)
  2. Cache (computing) - Wikipedia
  3. Content caching based on mobility prediction and joint user Prefetch in Mobile edge networks | Request PDF (researchgate.net)
  4. ristretto: A high performance memory-bound Go cache (github.com)