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"); // 刷新期间返回旧值,不阻塞
三个易踩点:
refreshAfterWrite必须与 loader 一起用才有意义,没有 loader 的Cache只会等下次get时由调用方自己重建;- 刷新失败会保留旧值(默认),也可能按
expireAfterWrite硬过期——取决于是否配置了写过期; - 刷新线程用的是 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. 自测
- W-TinyLFU 相比 LRU 解决了什么问题?
expireAfterAccess与expireAfterWrite在淘汰时机上差在哪?- 配了
refreshAfterWrite,刷新期间get会阻塞吗? - 多实例部署时,L1 不一致的两种主流兜底手段是什么?
- 为什么大对象和不适合放本地缓存的两类数据不要放 L1?
- 用 OHC 做堆外缓存,代价体现在哪两个维度?