Administrator
发布于 2021-11-30 / 1833 阅读
10

本地缓存 Caffeine 实战与命中率优化

压测报告里那行 P99 把我叫住了

11 月中旬做双十一前的容量压测,商品详情接口 3000 QPS 下 P99 是 42 ms,还算好看。压到 6000 QPS 的时候 P99 直接跳到 180 ms,而且曲线是锯齿状往上爬,不是平稳的。同时 Redis 集群 CPU 到了 61%,带宽 780 Mbps。

同事凑过来看了一眼:「Guava Cache 换 Caffeine 到底值不值?我们本地缓存不是已经有了吗?」

我们那个"本地缓存"是 Guava Cache,缓存的是商品的类目树,只有 2000 多个 key,命中率确实很高,但真正的热点商品详情根本没走本地——因为它变化频繁,大家怕一致性出问题,全都落在 Redis 上。

我先抓了个火焰图,发现一次详情请求里有 6 次 Redis 调用,每次 RTT 平均 0.8 ms,加上序列化,光 Redis 就吃掉 11 ms。6000 QPS 下这些调用全被放大了。

先换掉 Guava Cache,把热点扛在本地

Caffeine 的 API 跟 Guava 几乎一样,迁移成本几乎为零。但我没用 getIfPresent + put 这种写法,因为它没有原子性,会有缓存击穿。

Cache<Long, ItemDetail> itemCache = Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofMinutes(2))
        .recordStats()
        .build(new CacheLoader<Long, ItemDetail>() {
            @Override
            public ItemDetail load(Long itemId) {
                return itemRemoteService.load(itemId);   // 走 Redis → 走 DB
            }
        });

ItemDetail detail = itemCache.get(itemId);   // 原子加载,单 key 单线程

这里有个我踩过的坑:refreshAfterWrite 只有在构造 LoadingCache(也就是传了 CacheLoader)时才生效。如果你用 Caffeine.newBuilder().build() 建了个空缓存,配了 refreshAfterWrite 也不会有任何刷新动作,条目只会等到 expireAfterWrite 到期后被删掉,下一次访问就是一次硬加载。

另外 refresh 是异步的(默认用 ForkJoinPool.commonPool()),刷新期间其他线程拿到的是旧值而不是阻塞等待。这点跟 expire 完全不同:过期是阻塞加载,刷新是不阻塞返回旧值。商品详情这种场景,返回 2 分钟前的库存数字可以接受,所以我选了「10 分钟过期 + 2 分钟刷新」的组合。

W-TinyLFU 到底比 LRU 强在哪

同事的第二问:凭什么说它命中率更高?

传统 LRU 有个致命缺陷:一次批量扫描就会把热点全部挤出去。我们运营后台每天 0 点跑一次全量商品对账,扫 20 万条数据,Guava Cache 的命中率第二天上午能从 92% 掉到 40% 多,要缓半天才爬回来。

Caffeine 用的是 W-TinyLFU,结构上是三段:

Window LRU      1%     接纳新数据,快速淘汰一次性访问
   ↓ 晋升(频率更高才进得来)
Main Protected  80%    真正的热点区,LRU 淘汰
Main Probation  20%    候选区,跟 Window 淘汰下来的条目 PK 频率

关键的"频率"不是精确计数,是 Count-Min Sketch,一个 4 行、每行 maximumSize 个格子的二维 long 数组,每个条目哈希到 4 个格子做 +1,取 4 个格子的最小值作为频率估计。最小值的意义是:哈希冲突只会让计数偏大,取最小能压低误判。整个结构只占 maximumSize × 4 × 4 字节,5 万条也才 800 KB。

它还有一个老化的动作:总计数超过阈值时,所有格子右移一位(相当于全部减半),保证最近的热度权重更高。这就是 TinyLFU 里的 "Tiny"。

实际效果,同一个下午的对比:

缓存实现容量命中率(日常)命中率(对账扫描后 1 小时)
Guava Cache (LRU)5000091.4%43.7%
Caffeine (W-TinyLFU)5000096.2%93.8%

日常只高 4.8 个点,被扫描污染之后差距拉开到 50 个点,这就是准入机制的价值。

命中率不能靠猜,要看 stats

recordStats() 开着会有约 5% 的吞吐损耗,我在线上是常开的,然后每 30 秒打一次到 Micrometer:

@Scheduled(fixedDelay = 30_000)
public void reportCacheStats() {
    CacheStats stats = itemCache.stats();
    log.info("itemCache hitRate={} evictionCount={} loadFailureRate={} avgLoadPenalty={}ms",
            String.format("%.4f", stats.hitRate()),
            stats.evictionCount(),
            String.format("%.4f", stats.loadFailureRate()),
            stats.averageLoadPenalty() / 1_000_000);
}
2021-11-18 14:03:22 INFO  itemCache hitRate=0.9621 evictionCount=1832 loadFailureRate=0.0000 avgLoadPenalty=4.82ms

averageLoadPenalty 的单位是纳秒,我除以 1_000_000 换成毫秒。这个值突然变大,说明后端(Redis 或 DB)慢了,比接口告警来得早。

告警规则我配了两条:

  • hitRate < 0.85 持续 5 分钟 → 警告(容量不够或者被扫描污染了)
  • evictionCount 5 分钟增量 > 5000 → 警告(maximumSize 配小了)

跟 Redis 怎么配合

本地缓存最大的问题是失效广播。我们用的是 Redis 的 Pub/Sub,改数据的服务发一条消息,所有节点订阅后清除本地条目:

// 写服务:商品价格变更后
redisTemplate.convertAndSend("cache:invalidate", "item:" + itemId);

// 每个节点:订阅
redisTemplate.execute((RedisCallback<Object>) conn -> {
    conn.subscribe((message, pattern) -> {
        String key = new String(message.getBody(), StandardCharsets.UTF_8);
        if (key.startsWith("item:")) {
            itemCache.invalidate(Long.valueOf(key.substring(5)));
        }
    }, "cache:invalidate".getBytes());
    return null;
});

这里我没用 invalidateAll(),是因为全清会让命中率瞬间归零,6000 QPS 全部打到 Redis 上,容易引发雪崩。只失效单个 key 影响面小得多。

还有一点:本地缓存必须设过期时间兜底。Pub/Sub 是 fire-and-forget,订阅者掉线那几秒的消息会永久丢失,靠 expireAfterWrite 10 分钟兜底,最坏情况是数据陈旧 10 分钟。价格这种强一致的东西我不放本地缓存,只放类目、品牌、规格参数这种改得少的。

小结

  • refreshAfterWrite 必须配 CacheLoader 才生效,且是异步刷新,期间返回旧值;expireAfterWrite 是到期删除,下次访问阻塞加载。两者可以叠加使用。
  • W-TinyLFU 的 1% Window 做准入、80% Protected 保护热点,频率统计用 Count-Min Sketch,只占 maximumSize × 16 字节。它真正强在抗扫描污染,我们实测被 20 万条批量扫描后命中率仍有 93.8%,LRU 只有 43.7%。
  • recordStats() 有约 5% 开销,但 hitRateaverageLoadPenalty 两个指标值这个价。
  • 用 Pub/Sub 做失效时,逐 key 失效而不是 invalidateAll(),并且一定要有过期时间做兜底。
  • 强一致的数据别进本地缓存,这是设计约束,不是技术问题。

改完之后那轮压测,6000 QPS 下 P99 从 180 ms 降到 63 ms,Redis CPU 从 61% 降到 23%。同事看完数据,当天下午就把他负责的库存服务也换过去了。

参考