W-TinyLFU 与 Caffeine 深度实战:从 LRU 的扫描免疫性缺陷到频率草图准入的在线替换工程

执行摘要:大多数团队把缓存命中率问题当作"调容量"问题,实际上它是一个在线替换决策问题。LRU 用"最近最久未用"这一个比特的瞬时信息做决策,被一次全表扫描就能冲垮;LFU 用历史频率做决策,却又被"一次性热点"永久占据。W-TinyLFU 的解法很反直觉:频率不用来挑选淘汰谁,而是用来决定"新来的有没有资格进来"——用一个 4-bit 的 Count-Min Sketch 做准入法官,配一个只占 1% 的小 LRU 窗口承接突发。本文拆解 TinyLFU 的草图数学、W-TinyLFU 的三段式结构、Hill Climbing 自适应,以及 Caffeine 如何用 striped ring buffer 和分层时间轮把这些理论变成纳秒级无锁实现,并给出可运行的代码。

一、先说清楚 LRU 到底错在哪

LRU 的问题不是一个,是三个,而且互相叠加。

第一,扫描免疫性(scan resistance)缺失。 一个 10000 条目的缓存,跑一次 20000 行的批量导出,缓存里剩下的全是导出数据,真正的热数据被全部挤走。LRU 认为"刚被访问过 = 有价值",但批量扫描的每次访问都是一次性访问,这个假设在扫描负载下完全失效。

第二,只用了 1 比特信息。 LRU 的排序键是"上次访问时间",一个连续量被压缩成了顺序;它完全丢弃了"这个 key 被访问过多少次"。理论上 LRU 是 LRU-2(用倒数第二次访问时间排序)的劣质近似,而 LRU-2 又只是 LFU 的近似。

第三,无法区分长尾。 在 Zipf 分布(绝大多数真实业务访问的分布形态)下,LRU 的命中率随容量增长曲线非常平缓,而 LFU 类策略能逼近 Belady 最优(淘汰未来最远才用到的)的 90% 以上。

那 LFU 是不是答案?也不是。经典 LFU 有两个硬伤:一是历史污染——去年双十一爆过的 key,今年还在缓存里占位,因为它的计数器永远很高;二是实现代价——精确 LFU 需要按频率排序的堆,每次访问 O(log n),在百万 QPS 的缓存里完全不可接受。

二、TinyLFU:用 4-bit 草图换一个"够用"的频率

TinyLFU 的核心洞察是:准入决策不需要精确频率,只需要"谁更频繁"的相对判断。于是可以用概率数据结构把频率估计压到极致。

2.1 Count-Min Sketch 与它的 4-bit 计数器

TinyLFU 用 Count-Min Sketch(CMS):一个 width × depth(如 4 × 行数)的二维数组,每行一个独立哈希。增加时 4 个位置各加一,查询时取 4 个位置的最小值——这是 CMS 的关键,取最小值能把哈希碰撞带来的高估压到可控范围。

真正让它"tiny"的是每个计数器只有 4 bit(最大值 15)。这带来两个必须处理的工程细节:

import hashlib

class FourBitCMS:
    """TinyLFU 频率草图:4-bit 计数器 + 定期 halving 的近似 LFU"""
    MAX = 15

    def __init__(self, width: int, depth: int = 4, seed: int = 0):
        self.w, self.d = width, depth
        self.seed = seed
        self.table = bytearray(width * depth)  # 每个 byte 存两个 4-bit 计数器
        self.size = 0          # 已累加的条目数,用于触发 reset
        self.sample_size = 10 * width  # 采样/重置阈值

    def _index(self, row: int, key: bytes) -> int:
        h = hashlib.sha1(bytes([row, self.seed]) + key).digest()
        return int.from_bytes(h[:8], "big") % self.w

    def _get(self, row: int, idx: int) -> int:
        b = self.table[row * self.w + idx]
        return b >> 4 if idx & 1 else b & 0x0F

    def _add(self, row: int, idx: int, delta: int):
        off = row * self.w + (idx >> 1)
        b = self.table[off]
        if idx & 1:
            v = min(self.MAX, (b >> 4) + delta)
            self.table[off] = (b & 0x0F) | (v << 4)
        else:
            v = min(self.MAX, (b & 0x0F) + delta)
            self.table[off] = (b & 0xF0) | v

    def increment(self, key: bytes):
        self.size += 1
        for r in range(self.d):
            self._add(r, self._index(r, key), 1)
        if self.size >= self.sample_size:
            self.reset()

    def estimate(self, key: bytes) -> int:
        return min(self._get(r, self._index(r, key)) for r in range(self.d))

    def reset(self):
        """老化:全表减半,size 也减半。这是频率'遗忘'的唯一机制"""
        for i in range(len(self.table)):
            b = self.table[i]
            self.table[i] = ((min(15, (b >> 4) >> 1)) << 4) | (min(15, (b & 0x0F)) >> 1)
        self.size >>= 1

4-bit 上限的处理是 saturate 而不是 wrap。 计数到 15 就停住,靠 reset() 做老化:每当累加次数达到 10 × width 次,整个草图所有计数器减半(右移一位)。这就是频率的指数衰减——一个曾经爆热但现在冷掉的 key,经过几次 halving 后计数归零,自动让位。没有这个 reset,TinyLFU 就退化成带噪声的 LFU,历史污染问题原样回归。

宽度怎么定? 经验公式是 width ≈ 预计缓存条目数 × 10,但更实用的做法是反推:CMS 的误差界是 ε = e/width,命中率损失大约与 1/width 同阶。Caffeine 的默认策略是让 sketch 占用约为缓存容量的内存开销,通常几十 KB 就能支撑百万级条目,误差带来的命中率损失 < 1%。

Doorkeeper:一个前置 Bloom filter。 Caffeine 在 CMS 前面加了一层 Bloom filter,只有已经出现过至少一次的 key 才会被写进 CMS。原因很实际:大量 key 只出现一次(one-hit wonder),把它们写进 CMS 会白白消耗计数器预算、放大碰撞噪声。Doorkeeper 用 1 bit/entry 拦掉这批"首次访问",CMS 只记录"至少见过两次"的 key,命中率估计的信噪比大幅提升。

三、W-TinyLFU:让频率做"准入官",而不是"刽子手"

这是整个设计里最精妙的一步。直觉上大家会想:用频率决定淘汰谁。但 W-TinyLFU 反过来——淘汰仍由 LRU 家族负责,频率只决定"新候选者能不能进来"。

结构分三段:

                 ┌─────────────┐
   新访问 ──────▶ │ Window LRU  │  ~1% 容量,纯 LRU,承接突发流量
                 └──────┬──────┘
                        │ 被 Window 淘汰的候选者
                        ▼
              ┌────────────────────┐
              │   TinyLFU 准入判定  │  est(new) vs est(victim)
              └─────────┬──────────┘
                        │ 新条目胜出才准入
        ┌───────────────┴───────────────┐
        ▼                               ▼
 ┌─────────────┐                 ┌─────────────┐
 │  Probation  │  ~20%           │  Protected  │  ~80%
 │   (SLRU)    │ ──再次命中────▶ │   (SLRU)    │
 └─────────────┘ ◀──被淘汰────── └─────────────┘
  • Window(窗口区,约 1%):一个极小的纯 LRU。所有新 key 先落这里。它的作用是给突发流量一个短期避风港——一个新热点刚出现时频率计数还是 0,如果直接跟老条目比频率必输;Window 给它几次"免费访问"的机会把计数刷上来。
  • Main(主区,约 99%):SLRU(Segmented LRU)结构,分 Protected(80%)和 Probation(20%)。Probation 里的条目再被命中一次就晋升到 Protected;Protected 满了就把最老的降回 Probation 尾部。SLRU 保证一个"周期性但不频繁"的 key 不会挤掉"持续高频"的 key。
  • TinyLFU 判定:Window 淘汰出的候选者 vs Main 的淘汰候选者(Probation 尾部),比较 estimate() 频率,胜者留下。注意这个比较是概率性的——Caffeine 在频率相等时会用随机化打破平局,避免草图噪声造成的系统性偏差。
def admit(candidate_key, victim_key, sketch) -> bool:
    """W-TinyLFU 准入判定:频率比较 + 平局随机化"""
    c, v = sketch.estimate(candidate_key), sketch.estimate(victim_key)
    if c > v:
        return True
    if c < v:
        return False
    return random.random() < 0.5   # 抗草图噪声

3.1 Hill Climbing:window 大小不该是常量

1% 还是 20%?这取决于负载形态:Zipf 分布越陡(如 CDN、商品详情),窗口应该越小,因为频率信号很可靠;分布越平坦(如社交 timeline、搜索),窗口应该越大,因为"新近性"才是主要信号。

Hill Climbing 的做法是把 window/max 比例当成待优化参数:每隔一定采样量(如 10000 次访问)统计一次命中率,若命中率上升则沿当前方向继续调整 window(并加大步长),若下降则反向(并减小步长)。这是一个不需要人工调参、能随负载漂移自适应的一维爬山。生产上很实用:大促期间负载形态突变,缓存策略能自己跟着变。

四、Caffeine:把理论做成纳秒级无锁的 Java 实现

Caffeine(Java 8+ 的高性能缓存库,Spring Boot 默认)是 W-TinyLFU 的生产级实现,也是它真正"落地"的原因。几个关键工程决策值得单独说:

1)读写用 striped ring buffer 削峰。 如果每次 get() 都直接更新 CMS(4 次哈希 + 4 次写),热点 key 上会产生严重的 cache line 争用。Caffeine 的做法是:读操作只往一个 striped ring buffer(默认 128 字节 × N 条带)里追加一条记录,写操作往另一个 buffer 里追加,由后台(或触发阈值的那个线程)异步批量 drain 到 CMS。这把随机写变成了顺序 append,多核扩展性从"锁竞争"变成"几乎无竞争"。代价是频率估计有短暂延迟——一个刚爆发的 key 可能在几十毫秒内频率还没刷上来,这正是 Window LRU 存在的意义,两者互补。

2)过期用分层时间轮,不是优先队列。 expireAfterWrite / expireAfterAccess 如果用 PriorityQueue,每次访问都要 O(log n) 调整。Caffeine 用 TimerWheel(分层哈希时间轮):按 1.07s / 1.1min / 1.7h / 1.2day / 1.7year 分五层,每层若干桶,条目按过期时间挂桶,推进时 O(1) 摘桶。配合惰性清理(在读写路径上顺手检查)+ 定期推进,实际开销接近零。

3)淘汰与过期异步化。 淘汰动作(写 CMS、调 eviction listener、可能触发 CacheLoader 回调)全部丢给 ForkJoinPool.commonPool() 或配置的 executor,不阻塞调用线程。这也是为什么 Caffeine 的 hit rate 在高并发下几乎不掉。

4)CaffeineSpec 字符串配置 + recordStats。 生产上建议始终打开统计并接入监控:

Cache<String, Order> cache = Caffeine.newBuilder()
    .maximumSize(500_000)                 // 触发 W-TinyLFU 的容量约束
    .expireAfterWrite(Duration.ofMinutes(10))
    .refreshAfterWrite(Duration.ofMinutes(1))   // 异步刷新,避免雪崩
    .recordStats()                        // 命中率/加载时间/淘汰数
    .executor(ThreadPoolTaskExecutor 或专用 executor)
    .removalListener((k, v, cause) -> metrics.inc("cache.evict." + cause))
    .build(key -> orderRepo.load(key));

// 观测:命中率低于 0.8 通常意味着容量不足或负载形态已变
double hitRate = cache.stats().hitRate();

5)用 refreshAfterWrite 而不是全靠 expireAfterWrite。 前者在条目过期后仍返回旧值、异步触发 reload,避免缓存击穿;后者会把请求打到 DB。对允许短暂陈旧的数据(商品库存以外的多数读场景),这是硬要求。

五、对照与边界:什么时候别用 W-TinyLFU

  • 对比 Redis:Redis 的 maxmemory-policy 里 allkeys-lru 是采样 LRU(默认取 5 个样本,3.0+ 有 16 样本候选池),allkeys-lfu 用 24-bit 中的 16-bit 存频率并带对数衰减(LFUDecayAndFetch)。它是近似实现,且淘汰池是全局共享的——单实例热点 key 的治理能力远弱于进程内 Caffeine。
  • 对比 Linux MGLRU:页缓存面对的是"文件页 + 匿名页"的多代回收,用 generation + tier 扫描,解决的是 LRU 链表在 TB 级内存下的扫描成本问题,动机与 TinyLFU 不同但殊途同归——都是用更粗粒度的近似换可扩展性。
  • 对比 ARC / LIRS:ARC(Adaptive Replacement Cache)用双队列自适应平衡 recency/frequency,命中率优秀但有专利历史、实现复杂;LIRS 用 IRR/HIR 双栈,命中率常优于 ARC。W-TinyLFU 的优势是内存开销与实现复杂度都更低,且有高质量开源实现。

W-TinyLFU 不适合的场景:缓存条目极小(< 100 条),此时 LRU 已经够用,草图的固定开销反而不划算;负载完全随机均匀(无局部性),任何策略都救不了;以及对过期精度要求到毫秒级的场景——时间轮的粒度是秒级的。

六、生产落地检查清单

  1. 先量化再优化:接 recordStats(),把 hitRate、evictionCount、averageLoadPenalty 打到 Prometheus。没有基线就调参是盲调。
  2. 容量按"工作集"而非"数据总量"设定:用 Zipf 估算,缓存 95% 命中率通常只需要覆盖工作集的 20%~30%。
  3. 警惕启动预热污染:JIT 未warm-up、冷启动批量加载会灌进大量一次性 key。解决方式是加载时走 getAll 批量接口、或临时提高 window 比例。
  4. key 设计影响草图精度:过长的 key 会增加哈希成本,建议固定长度哈希后的摘要作为缓存 key。
  5. 分层缓存要有"准入差异":L1(进程内 Caffeine)用 W-TinyLFU,L2(Redis)用 LFU,两层策略刻意错开,避免同样的淘汰逻辑造成"同频共振"式雪崩。

七、结语

缓存替换算法的演进,本质上是一场用有损近似换取可扩展性的工程实践:从精确 LRU 链表(O(1) 但无扫描免疫),到采样 LRU(可扩展但更不准),到 TinyLFU 用 4-bit 草图把频率压缩到每条目几个 bit,再到 W-TinyLFU 用"频率判准入 + LRU 判淘汰"的分工把两者的优点拼起来。

真正值得带走的不是某个参数,而是这个思路:当一个信号不可靠时,不要用它做最终决策,用它做准入筛选。 频率估计是概率性的、会错的,那就不让它直接淘汰谁,而只让它否决"明显不该进来的"。这个设计哲学,在限流、调度、甚至模型路由里都能看到同样的影子。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部