在 Redis 前面再放一层 Caffeine,几乎成了高并发 Java 服务的标准动作。理由很直观:一次 Redis 往返即使在同一可用区也要零点几毫秒,而本地缓存命中通常在微秒级;当某个热点 key 每秒被读几十万次时,这层本地缓存能挡掉绝大部分网络与序列化开销。于是很多团队在压测报告里看到 P99 下降,就默认这层缓存是纯收益。
但缓存从来不只带来收益。每增加一层缓存,系统里就多了一份需要收敛的状态,也多了一个可能失效的组件。原来只有「数据库 + Redis」时只需要处理一种不一致,加了本地缓存之后就变成「数据库 + Redis + N 个进程的堆内副本」:失效通知会丢、GC 会停顿、堆会被撑爆。这篇文章想回答的不是「要不要加本地缓存」,而是在什么条件下加、加了之后靠什么机制收敛、以及怎么判断这层缓存到底有没有白加。
先给结论
本地缓存适合挡「读多写少、允许秒级陈旧、单个 key 访问极度集中」的热点数据;如果一条数据的正确性窗口在百毫秒级以内,或者写操作频繁,那么本地缓存带来的收敛成本通常高于它省下的那点延迟。
核心判断:多级缓存的难点不在「加一层」,而在「管住多出来的那份状态」——每份本地副本都必须有明确的失效路径和兜底 TTL,否则它就是一份不受控的脏数据。
需要说明本文的信息边界:本次没有可引用的站内讨论,下文不涉及任何具体帖子或线上案例,讨论基于通用的缓存机制与工程实践;文中出现的 TTL、命中率等数字只是量级示意,落地时必须用自己系统的压测与监控数据校准,不能直接照搬。
一次读请求要穿过几层,失效就在哪几层变形
先看链路。加上本地缓存之后,一次读请求的判定路径大致如下:
流程图
渲染中...
这条链路把原本集中的「缓存问题」拆到了三个位置,而每个位置的表现并不一样。缓存穿透指查询一个数据库里也不存在的 key,请求绕过两层缓存直击 DB;缓存击穿指某个热点 key 过期的瞬间,大量并发同时回源;缓存雪崩指大批 key 在同一时刻失效。在单层 Redis 结构里,这三种问题都有成熟解法(布隆过滤器、互斥重建、TTL 抖动),但加了本地缓存后,它们的作用位置和代价都变了。
穿透的变形最容易被忽略:如果沿用「空值也缓存」的思路,把空对象写进本地缓存,内存风险会被放大 N 倍。Redis 里存一批空值只是多占几十 MB,可控;但本地缓存是每个实例堆内各存一份,而且 Caffeine 的容量上限按条目数计算而非字节数,maximumSize(100_000) 配上结构稍大的 value,很容易变成几百 MB 的堆占用,直接推高 Full GC 频率。更稳的分工是:布隆过滤器或空值缓存放在 Redis 层,本地层只缓存确认存在过的热点对象,并给本地条目数设硬上限。
击穿的变形在于「不同步」。多实例部署时,同一个 key 在各实例本地副本上的写入时刻不同,TTL 自然也不同;但当流量洪峰到来,这些副本往往在相近的时间窗口一起失效,回源流量瞬间压到 Redis,若 Redis 也恰好过期,压力就直落数据库。单机内的并发可以用 Caffeine 的 get(key, mappingFunction) 天然合并——同一个 key 的并发计算会被串行化,等价于一个轻量 singleflight;跨实例则要靠逻辑过期加异步刷新,或至少给 TTL 加随机抖动,避免同一批 key 同时到期。
雪崩的变形则来自「批量」。商品详情、类目字典这类数据常在启动时预热,如果预热脚本用同一个固定 TTL 写入,几小时后这批 key 会集体过期,形成一次人为制造的流量脉冲。解决办法不复杂但必须写进代码:TTL 基础值加随机偏移,例如 300 秒基准加 0 到 120 秒随机数,让失效时刻在时间轴上摊开。
三种常见做法的取舍
社区里常见的三种做法,本质是对「延迟」和「状态收敛成本」的不同取舍:
| 做法 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 只用 Redis | 状态单一,失效确定 | 每次读都有一次网络往返 | 一致性要求高、QPS 中等 |
| Redis + Caffeine 读穿透 | 热点 key 延迟最低 | 多份副本,需广播失效 | 读多写少的热点详情 |
| 本地缓存为主 + 异步刷新 | 能扛住极端读流量 | 数据可能长时间陈旧 | 字典、榜单等只读数据 |
第二种是大多数团队的默认选择,它的前提是能接受最坏情况下陈旧一个 TTL。一旦业务方要求「后台改了配置,前台立刻生效」,这个前提就不成立了,需要补上主动失效通道,成本随之上升。第三种只适合天然可陈旧的场景,比如地区字典、商品类目树,这类数据变更频率以天计,本地缓存为主反而简单;但如果把它用在订单状态或库存上,就是在给自己埋雷。
值得强调的是,三种做法之间不存在「先进」与「落后」之分。一个 QPS 只有几百的后台服务,加本地缓存省下的那点延迟远不如它带来的排查成本;反过来,一个每秒几十万次读取的热点接口,不加上本地缓存,Redis 集群的带宽和连接数就会先撑不住。判断标准是流量形态和数据时效要求,而不是架构图好不好看。
一致性:更新顺序、失效广播与兜底 TTL
确定要加本地缓存之后,最核心的问题变成「写操作发生后,N 份副本怎么收敛」。标准的 Cache Aside 顺序是先更新数据库、再删缓存,这个顺序在单层 Redis 下已被讨论很多;到了多级结构,删缓存这一步必须同时作用于 Redis 和所有实例的本地副本,于是引出广播通道的选择:Redis Pub/Sub 轻量但可能丢消息(订阅端重连期间的发布收不到),MQ 可靠但有延迟,配置中心推送适合变更频率低的配置类数据。三者都不保证「一定送达并执行」,所以兜底 TTL 不是可选项,而是这套机制的安全底线。
一个务实的读路径实现大致如下,重点在「空值只在 Redis 层落盘」和「回源用本地缓存的原子计算合并并发」这两点:
Java
public ProductDetail get(long id) {
String key = "product:detail:" + id;
ProductDetail local = localCache.getIfPresent(key);
if (local != null) {
return local;
}
String raw = redis.get(key);
if (raw != null) {
ProductDetail remote = NULL_TAG.equals(raw) ? null : decode(raw);
if (remote != null) {
localCache.put(key, remote);
}
return remote;
}
return loadWithMutex(key, id);
}
private ProductDetail loadWithMutex(String key, long id) {
return localCache.get(key, k -> {
ProductDetail db = queryFromDb(id);
if (db == null) {
redis.setex(key, 60, NULL_TAG);
return null;
}
int ttl = 300 + ThreadLocalRandom.current().nextInt(120);
redis.setex(key, ttl, encode(db));
return db;
});
}
另一条路是版本号方案:在 value 里带上 version 或 updatedAt,读本地副本时顺带校验一次版本(可以合并进定时刷新任务),版本落后就丢弃重读。它把「广播必须送达」的强依赖降级成「最终会对齐」,代价是多一次轻量校验或一个后台刷新线程。对于跨机房、跨可用区部署、广播通道本身就不太可靠的系统,版本号往往比纯广播更省心。
落地时可以先做的几件事
把上面的判断落成动作,可以按下面这份清单推进,不必一次做全:
- 先量化收益:统计目标接口的 QPS、Redis 往返耗时占比、热点 key 集中度(Top 100 key 覆盖多少请求),收益不明显就不要加。
- 给本地缓存设两个上限:条目数上限和单条 value 的体积上限,避免大对象把堆吃穿。
- 所有写入路径统一走「先更新 DB、再删 Redis、再广播删本地」,广播失败只记日志不重试,由 TTL 兜底。
- 所有 TTL 加随机抖动,预热脚本同样适用。
- 空值只写 Redis,本地层不缓存空对象;存在性判断用布隆过滤器前置。
- 回源统一用
get(key, mappingFunction)做单机合并,禁止在业务代码里手写无保护的「查库再写缓存」。
这份清单里最容易被跳过的是第一条。很多团队是先加了缓存、再回头解释为什么加,结果压测环境里热点分布和线上完全不同,上线后命中率只有个位数,白付了一层复杂度。先用数据证明热点存在,再决定加不加,这个顺序不能反。
同样要避免的是「一次上全套」:布隆过滤器、广播失效、版本号、逻辑过期全部堆上去,出问题时反而不知道是哪一层在起作用。更稳的做法是先加本地缓存和 TTL,观察命中率与回源 QPS 的变化,再按实际出现的脏读投诉补失效通道。
观测与压测:怎么证明这层缓存有用
加了缓存不等于有效。判断它是否值得保留,至少要盯住下面几组指标:
| 指标 | 看什么 | 异常信号 |
|---|---|---|
| 本地缓存命中率 | 请求被本地层挡掉的比例 | 长期低于 50% 说明热点不集中 |
| 回源 QPS | 落到 Redis 与 DB 的请求量 | 加了缓存后未明显下降 |
| P99 延迟 | 尾延迟而非均值 | 均值降了但 P99 变差,多为 GC 或锁竞争 |
| 堆与 GC | 老年代占用、Full GC 次数 | 本地缓存上线后 Full GC 频率上升 |
| 数据陈旧时长 | 从 DB 更新到各副本对齐的时间 | 存在长尾,说明广播有丢失 |
其中「数据陈旧时长」最容易被漏掉,但它恰恰是本地缓存最该被量化的成本。可以在写路径打点记录更新时间,在读路径比较本地副本的 updatedAt,把差值做成直方图;如果 P99 陈旧时长超过了业务容忍窗口,就说明 TTL 设长了或者广播不可靠,需要收紧。
压测环节也有个常见陷阱:单机压测时本地缓存命中率接近 100%,看起来收益巨大,但线上是多实例加负载均衡,同一个 key 会被分散到不同实例,每个实例都要各自经历一次冷启动。更接近真实的做法是按线上实例数起多个压测进程,并使用真实的热点 key 分布(而不是均匀随机),否则测出来的命中率没有参考价值。
总结与讨论
回到最初的问题:本地缓存该不该加?三条结论可以带走。第一,它优化的是延迟,付出的是状态收敛成本,只有当热点足够集中、数据允许秒级陈旧时,这笔交易才划算。第二,穿透、击穿、雪崩在多层结构下会变形,空值缓存要留在 Redis 层、并发回源要靠单机合并、批量失效要靠 TTL 抖动,这三条是底线而不是优化。第三,兜底 TTL 是整套机制的安全网,任何依赖「广播一定送达」的设计,都要假设它会丢。
最后留一个值得讨论的问题:如果业务方要求「后台改价后 1 秒内所有实例生效」,你会选择放弃本地缓存、退回单层 Redis,还是保留本地缓存并用版本号加短 TTL 把陈旧窗口压到 1 秒以内?这两种选择在故障时的表现完全不同,欢迎说说你在实际系统里是怎么权衡的。