2499 字
约 8 分钟
22
S12-X04 · Caffeine 与二级缓存:本地缓存该放什么

Redis 一次往返按毫秒算,本地缓存按纳秒算。把热点数据从 Redis 挪到 JVM 堆里,QPS 能上一个量级——但代价是每个实例一份副本,任何"改了要立刻可见"的数据都会被多副本放大成事故。以下按 Caffeine 3.x 的 API 核对。


1. W-TinyLFU:为什么比 LRU 好

纯 LRU 的两个老问题:一次全表扫描的冷数据会把热数据全部挤出;突发流量的偶发访问会污染缓存。Caffeine 的做法是 Window + TinyLFU + Segmented LRU 三段结构。

组成 作用
Window Cache(约 1%) 新到数据先进窗口,给突发访问一个机会
Main Cache Segmented LRU(protected 80% / probation 20%)
TinyLFU Admission 用 Count-Min Sketch 记录访问频率,决定新条目能否挤掉旧条目
频率衰减 长期保留全局访问热度,随数据量周期性衰减

命中率对比(公开 benchmark 的量级参考,非本机实测):在搜索类与数据库类 trace 上,W-TinyLFU 通常比 LRU 高几个到十几个百分点,且抗扫描能力显著更强。真正值钱的是"抗扫描"——避免一次批处理把线上缓存打成 0 命中。


2. 五个参数的真实语义

参数 语义 何时触发淘汰
maximumSize(n) 最多 n 个条目 插入超过 n 时按策略淘汰
maximumWeight(n) + weigher 按权重上限(逻辑上) 权重和超过 n
expireAfterWrite(d) 写入后 d 过期 时间到,读也拿到不到
expireAfterAccess(d) 最后一次访问后 d 过期 闲置才过期,热数据永不过期
refreshAfterWrite(d) 写入后 d 触发异步刷新 过期但先返回旧值
Cache<String, Sku> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .refreshAfterWrite(Duration.ofSeconds(30))
        .recordStats()
        .build(key -> skuLoader.load(key));
组合 效果
只有 expireAfterWrite 到点硬过期,下一个读阻塞回源(有尖刺)
expireAfterWrite + refreshAfterWrite 到刷新点异步回源,读不阻塞;写过期是兜底
maximumWeight + 大对象 防止 OOM,但要自己实现 weigher

3. refreshAfterWrite 为什么不阻塞读

刷新是"过期但不失效":get 命中仍返回旧值,同时后台线程(ForkJoinPool.commonPool,可换 executor)调用 loader 更新。

LoadingCache<String, Sku> c = Caffeine.newBuilder()
        .maximumSize(10_000)
        .refreshAfterWrite(Duration.ofSeconds(30))
        .build(k -> skuLoader.load(k));   // 同步 loader

Sku v = c.get("S1001");    // 刷新期间返回旧值,不阻塞

三个易踩点:

  1. refreshAfterWrite 必须与 loader 一起用才有意义,没有 loader 的 Cache 只会等下次 get 时由调用方自己重建;
  2. 刷新失败会保留旧值(默认),也可能按 expireAfterWrite 硬过期——取决于是否配置了写过期;
  3. 刷新线程用的是 commonPool,回源慢会占用它,最好显式指定 executor。

4. CacheLoader 与 AsyncCacheLoader

类型 构建方法 阻塞行为
CacheLoader build(k -> load(k)) 同步,get 期间调用线程等待
AsyncCacheLoader buildAsync((k, e) -> loadAsync(k, e)) 返回 CompletableFuture,get 直接拿 future
无 loader build() + get(k, k -> ...) 由调用方提供映射函数
AsyncLoadingCache<String, Sku> ac = Caffeine.newBuilder()
        .maximumSize(10_000)
        .refreshAfterWrite(Duration.ofSeconds(30))
        .buildAsync((key, executor) ->
                CompletableFuture.supplyAsync(() -> skuLoader.load(key), executor));

异步 loader 的意义不只是"不阻塞":它把"多个并发请求同一个 key"合并成一次回源(single-flight),比在业务里手写分布式锁轻得多。这一点在多实例场景下尤其重要——本地缓存天然自带进程内单飞,Redis 层则要靠其他手段。


5. 开统计:命中率是唯一硬指标

Cache<String, Sku> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .recordStats()
        .build();

CacheStats s = cache.stats();
double hitRate = s.hitRate();          // hitCount / (hit + miss)
long evictions = s.evictionCount();
double loadPenaltyMs = s.averageLoadPenalty() / 1_000_000.0;
指标 含义 该关注什么
hitRate() 命中率 低于 0.7 说明容量或 key 设计有问题
evictionCount() 淘汰次数 与插入次数比值高 → maximumSize 偏小
averageLoadPenalty() 平均回源耗时 决定 refreshAfterWrite 该设多少
loadFailureRate() 回源失败率 异常被吞会给缓存填垃圾

recordStats() 有开销(每次读写要打点),生产上建议开,但用它做 Micrometer 指标聚合而不是每次请求打印。


6. 二级架构:L1 短 TTL + L2 长 TTL

层级 位置 TTL 容量 命中延迟
L1 JVM 堆(Caffeine) 5–60 秒 千到万级 纳秒
L2 Redis 5–30 分钟 百万级 毫秒
L3 MySQL — — 十毫秒级
public Sku get(long id) {
    return l1.get(id, k -> {                     // L1 未命中
        Sku v = redis.get("sku:" + k, Sku.class);
        if (v != null) return v;
        v = skuMapper.selectById(k);             // L2 未命中回源
        redis.set("sku:" + k, v, Duration.ofMinutes(10));
        return v;
    });
}

好处是回源次数被压到"每实例每 TTL 一次",Redis 的 QPS 直接掉一个数量级。代价是每台机器各有一份,失效要广播或多等一个 L1 TTL。


7. 多实例下 L1 怎么保持一致

方案 做法 一致性 成本
短 TTL 容忍 L1 TTL 设 5–30 秒 差 ≤ TTL 几乎为零
广播失效 Redis Pub/Sub 订阅 cache:invalidate 毫秒级,可能丢消息 低
版本号探测 value 带版本,读时校验 需额外读 L2 中
不缓存 私有/强一致数据直接读 L2 最强 无
// 广播失效:订阅端直接 invalidate,不等待
redisTemplate.listen(new ChannelTopic("cache:invalidate"), (msg) -> {
    l1.invalidate(msg.getBody());            // body 是 key
});
// 发布端在数据变更后
redisTemplate.convertAndSend("cache:invalidate", key);

Pub/Sub 在 Redis 7 里仍是"至多一次"投递,实例断连期间的消息会丢。所以标准做法是 广播 + 短 TTL 双保险:广播负责降低大部分窗口,TTL 负责兜住丢消息的那部分。


8. 本地缓存不该放什么

数据类型 为什么不该放 L1 替代
大对象(>100KB 的 JSON/列表) 堆占用与 GC 压力,且复用率低 放 L2 或直接查库
强一致数据(余额、库存、订单状态) 多副本无法即时一致 L2 + 版本校验,或直读库
用户私有敏感数据(身份证、手机号) 堆转储可导出、日志易泄漏 不放缓存或不进 L1
结果集无界(分页数据) 命中率低,纯占内存 按需查
频繁变更的热点(计数器) 每写必失效,命中率≈0 直接 L2

一句话判断标准:这个 key 的"陈旧几秒"能否被业务接受,以及它是否属于某个用户私有。两个问题都是"是"才考虑 L1。


9. 堆外缓存与 GC 压力

方案 位置 GC 影响 序列化成本
Caffeine 堆内 计入堆,大对象加剧老年代晋升 无
OHC(Off-Heap Cache) 堆外直接内存 不触发 GC,但受 -XX:MaxDirectMemorySize 限制 需要序列化/反序列化
本地磁盘(RocksDB) 磁盘 + 页缓存 几乎无 需要

OHC 的收益在"对象数量极大且存活时间长"的场景:几百万条记录放堆内会让老年代长期占用高位、每次 Full GC 都要标记它们。放堆外后,堆内只留少量索引,GC 时间显著下降。代价是每次读都要反序列化,CPU 换 GC。

如果用 OHC,务必设置 MaxDirectMemorySize 并监控直接内存使用,否则 OOM 会以 OutOfMemoryError: Direct buffer memory 的形式出现,而不是普通堆 OOM。


10. 排错:五种本地缓存异常

现象 原因 处理
命中率长期低于 0.3 容量太小 / key 基数太大 调 maximumSize 或换 L2
Full GC 变频繁,老年代降不下来 缓存对象过大、条目过多 限 size、改 maximumWeight、移堆外
数据"改了没生效",重启才好 L1 未失效且 TTL 过长 广播失效 + 缩短 L1 TTL
应用内存缓慢上涨 无上限的 build() + get(k, fn) 必须配 maximumSize
刷新时出现整块延迟 refreshAfterWrite 用 commonPool 且回源慢 指定独立 executor

11. 边界:什么时候不需要本地缓存

场景 更好的选择 理由
数据量超过千万且随机访问 只用 Redis 命中率太低,堆被白占
单实例部署 本地缓存等于单级缓存 无需分层,简化运维
变更频率高于读频率 不缓存或只缓存秒级 失效开销大于收益
需要跨实例强一致 直读库或 L2 本地副本无法满足
极致冷启动性能 预热 + 持久化快照 单纯 L1 会有一段全 miss

本地缓存的本质是用内存换延迟,用一致性换吞吐。 判断需不需要,先问"这个 key 的基数 × 大小能不能装进堆的 5%",再问"陈旧 30 秒是否会引发用户可见的错误"。


12. 自测

  1. W-TinyLFU 相比 LRU 解决了什么问题?
  2. expireAfterAccess 与 expireAfterWrite 在淘汰时机上差在哪?
  3. 配了 refreshAfterWrite,刷新期间 get 会阻塞吗?
  4. 多实例部署时,L1 不一致的两种主流兜底手段是什么?
  5. 为什么大对象和不适合放本地缓存的两类数据不要放 L1?
  6. 用 OHC 做堆外缓存,代价体现在哪两个维度?
S12-X04 · Caffeine 与二级缓存:本地缓存该放什么
https://www.clxhxhhr.top/posts/5674/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。