老炮踩坑录 · D04 · 技术深挖系列
基于「企业融合评估平台」真实源码,从三个带病的 Guava 本地缓存出发,讲清二级缓存的读写路径和失效设计
关键词:Guava Cache · Redis · 缓存击穿 · Pub/Sub 失效广播 · 本地缓存一致性
👋 欢迎阅读

🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容:Redis + Guava 二级缓存架构设计
写在前面
这篇是复盘补课,不是项目史实。项目里没有二级缓存,只有三个带病的 Guava 本地缓存。设计部分是我在复盘时推演的 "如果让我补,我会怎么设计"。
引子
上一篇预告里我写了一句话:"项目里真有一套 Redis + Guava 的二级缓存,失效策略全靠约定。"
写完预告我就后悔了,因为当时只在 pom.xml 里看到了 spring-boot-starter-data-redis,没有进行源码核实。这篇动笔之前我把整个 src 翻了一遍,得先勘个误:
这个项目里没有任何二级缓存。 Redis 相关的 Java 代码一行都没有,配置文件里也没有一个字的 redis 配置。真实存在的,是散落在三个类里的 Guava 本地缓存,各有各的病,外加一个躺平的 redis 依赖。
那就将错就错------这篇从这三个真实的本地缓存讲起,看它们各自的问题,再讲如果让我在这套系统上补一级 Redis、做成二级缓存,会怎么设计、代码怎么写。设计部分是复盘补课,当年没上线,别当成项目史实读。
三个缓存,三种病
50 个槽位,只放一个 key
SessionCacheUtils 里有这么个东西:
java
// SessionCacheUtils.java
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
.maximumSize(50)
.expireAfterWrite(60, TimeUnit.MINUTES)
.build();
全项目搜它的 put,只有一处,key 还是写死的字符串:
java
// 匿名登录,拿回来的 accessToken
CACHES.put("NEW_TOKEN", accessToken);
一个容量 50 的缓存,一辈子只存一个 key。这不算大问题,顶多说明代码是抄来的。真正的病在 TTL:是开发者凭感觉随手定的,没有任何依据,但这个 token 是其他系统那边发的,人家的有效期是多少、会不会提前作废,这边一概不知道。万一远端 token 真实寿命只有 30 分钟,第 31 分钟到第 60 分钟之间,所有匿名请求都拿着一个死 token 去调接口,全部 401,而代码里没有任何"收到 401 就清缓存重登一次"的兜底逻辑。
另外 Guava 的过期是惰性的,默认没有后台线程掐表,条目在下次读写被顺带清理。"恰好 60 分钟失效"这个心智模型本身就不精确,虽然单 key 场景影响不大。
这个缓存要修改,思路很直白:TTL 按远端返回的 expires_in 打个折来设,再预留主动失效------调远端收到鉴权失败,CACHES.invalidateAll() 后重登一次。
整个缓存是注释掉的
AuthAspect 里也有一个,配置几乎一模一样:
java
// AuthAspect.java
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
.maximumSize(50)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build();
然后看使用处,全是注释:
java
// String requestURI = request.getRequestURI();
// if (CACHES.getIfPresent(requestURI) != null) {
// return new Result<>().fail(ResultEnum.FAIL_FREQUENT_OPERATION);
// }
// CACHES.put(requestURI, requestURI);
防重复提交的逻辑,整段死掉了。死代码留在切面里,缓存对象每次发版跟着进内存,没人知道它为什么被注释、以后还会不会启用。
就算当年启用,设计也是错的:key 只用 requestURI,不带任何用户标识。A 企业提交了一次注册,这个 URI 在所有实例的缓存里占住------不对,本地缓存不共享,单机上 30 分钟内点这个接口的所有人都会收到 "操作频繁 "。防重点了防成了禁言。正确的 key 至少得是 用户标识 + URI + 参数指纹,而且这种 "短期互斥" 语义上更接近锁,TTL 应该是秒级。
拿 Map 当锁,多实例直接瞎
第三处在华为云文件服务,也是病得最重的一处:
java
// HwFileServiceImpl.java
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
// 最大缓存 100 个
.maximumSize(100)
// 设置写缓存后 24小时过期
.expireAfterWrite(24, TimeUnit.HOURS)
.build();
看用法就明白它想干什么了。上传方法进来先把本次生成的文件名塞进缓存,传完再读出来跟自己的局部变量比:
java
// 请求进来,把本次的新文件名写进去
if (i == 0) {
CACHES.put(rapplyid + type, name);
uname = name;
}
// ... 上传、更新附件表 ...
// 全部结束后再读一次
Object ifPresent = CACHES.getIfPresent(rapplyid + type);
if (ifPresent != null && !ifPresent.toString().equals(uname)) {
asyncService.cleanHwyFile(...);
throw new SystemException("不能覆盖最新的文件");
}
同一个 key,中间要是被另一个并发请求 覆盖过,读回来的就不是自己的 uname,于是判定"有人同时传了同一笔业务的附件",抛异常。这是一个用 static Map 实现的乐观并发探测,跟OBS 同名文件互相覆盖,客户拿错了别人的报告:一次文件串号的排查与重构讲的同名文件覆盖是同一个战场。
单机上它勉强能工作。
多实例部署就有问题了:A 节点收到第一个请求往自己的 Map 里写,B 节点收到第二个请求往自己的 Map 里写,两边各自读自己,都觉得天下太平,OBS 上的文件该覆盖还是覆盖。另外 maximumSize(100) 的 LRU 淘汰也可能在请求处理途中把这个 key 挤掉,虽然概率低,但拿一个容量受限的通用缓存做正确性保障,本身就把业务正确性挂在了缓存淘汰策略上。
这类问题的正解在数据库------附件表对业务键加版本号或唯一约束,让后提交的人在事务里撞墙;要体验更好一点,用 Redis 的 SET NX 做跨实例互斥。用 JVM 内存里的 Map 给分布式系统当锁,等于只给一半机器上锁。
还有一个零调用的 redis starter
xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
全项目没有 RedisTemplate、没有 @Cacheable、没有 jedis/lettuce 调用,yml 里没有 host 没有 port。引依赖的人大概率想过做共享缓存,最终也没做成,依赖也没删。它唯一的实际效果是占了一点构建体积,以及给读代码的人制造"这系统用了 Redis"的错觉------我自己写预告时就中招了。
本地缓存的天花板在哪
三个案例放一起看,问题都出在 Guava 本地缓存的能力边界被当成了系统边界:
- 不跨进程。多实例各存各的,你没法拿它做任何需要全局一致的判断------并发互斥、限流计数、分布式会话,通通不行。
- 重启即冷启动。应用一发版,缓存全没,所有请求同时回源,冷启动那几分钟数据库压力是平时的几倍。
- 容量受堆限制 。缓存吃的是业务堆内存,
maximumSize开大了挤压业务对象,触发更频繁的 Full GC。
本地缓存也有 Redis 比不了的优势:零网络开销,纳秒到微秒级;没有序列化成本。热点字典、配置项这种读得极频繁、变更极少的数据,放本地比放 Redis 划算得多。
二级缓存的思路就是把两者叠起来:L1 用 Guava 挡最热的那部分读,L2 用 Redis 兜住全量共享数据,回源只去数据库。代价是复杂度------多一级就多一层失效问题,这也是为什么很多团队上了二级缓存之后,排查"数据改了但页面不刷新"的时间比省下的数据库时间还长。
读路径:L1 → L2 → DB
基础读路径代码应该是这样:
java
@Component
@Slf4j
public class TwoLevelCache {
private static final String NULL_HOLDER = "@NULL@";
private static final String CHANNEL = "cache:invalidate";
private final StringRedisTemplate redis;
private final Cache<String, String> l1;
public TwoLevelCache(StringRedisTemplate redis,
RedisMessageListenerContainer container) {
this.redis = redis;
this.l1 = CacheBuilder.newBuilder()
.maximumSize(10_000)
// L1 故意设短:5 分钟,作为 L2 失效广播丢失时的兜底
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
// 订阅其他实例发出的失效广播,清掉自己的 L1
MessageListener listener = (msg, pattern) ->
l1.invalidate(new String(msg.getBody(), StandardCharsets.UTF_8));
container.addMessageListener(listener, new ChannelTopic(CHANNEL));
}
@SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> clazz, Supplier<T> dbLoader) {
try {
String raw = l1.get(key, () -> {
String v = redis.opsForValue().get(key);
if (v != null) {
return v;
}
return loadFromDb(key, dbLoader);
});
if (NULL_HOLDER.equals(raw)) {
return null;
}
return JSON.parseObject(raw, clazz);
} catch (ExecutionException e) {
throw new RuntimeException("缓存读取失败: " + key, e);
}
}
}
几个口径先定下来,整篇都按这个走:
- L1 存字符串,跟 L2 落地的格式一致,省掉两层各自序列化的心智负担
- L1 的 TTL 故意压到分钟级,它只是热点加速层,正确性靠 L2 和失效广播保证
recordStats()要开。命中率、淘汰数这些指标不接监控,缓存有没有在工作全靠猜
回源:跨实例只放一个请求下去
L1 没命中没关系,L2 也没命中的时候就要小心:一万个实例线程同时发现 L2 空了,一起查库,这就是缓存击穿。
Guava 的 Cache.get(key, callable) 对同一个 key 自带单飞效果,同一 JVM 里只有一个线程执行 loader。但二级缓存下单飞要跨实例,得用 Redis 锁:
java
private <T> String loadFromDb(String key, Supplier<T> dbLoader) {
String lockKey = "lock:" + key;
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
T value = dbLoader.get();
if (value == null) {
// 空结果也缓存,TTL 给短,防穿透
redis.opsForValue().set(key, NULL_HOLDER, 2, TimeUnit.MINUTES);
return NULL_HOLDER;
}
String json = JSON.toJSONString(value);
redis.opsForValue().set(key, json, redisTtl());
return json;
} finally {
redis.delete(lockKey);
}
}
// 没抢到锁:稍等一下读 L2,读到别人回源的结果就直接用
sleepBriefly();
String v = redis.opsForValue().get(key);
if (v != null) {
return v;
}
// 还没有就直接回源。宁可多查一次库,也不能让请求死等锁
T fallback = dbLoader.get();
return fallback == null ? NULL_HOLDER : JSON.toJSONString(fallback);
}
private Duration redisTtl() {
// 基准 30 分钟,±5 分钟随机,大量 key 不会同一时刻集体过期
long seconds = 30 * 60 + ThreadLocalRandom.current().nextLong(-300, 300);
return Duration.ofSeconds(seconds);
}
空值缓存这里多说一句:null 必须有专门的占位符,且 TTL 远短于正常值,两分钟足够挡住恶意刷不存在 ID 的穿透。如果 key 空间巨大且可枚举(比如连续自增 ID),再加一层布隆过滤器挡在最前面,空值缓存兜剩下的散弹。
没抢到锁的分支我没写循环等待------短暂等一次,读不到就自己回源。等待循环写不好就是惊群加超时,多一次可控的数据库查询,比把线程挂死在锁上强。
写路径:更新数据库,删除缓存
写操作的标准动作是更新库、删缓存:
java
@Transactional
public void updateDict(Long id, DictDTO dto) {
dictMapper.updateByPrimaryKeySelective(dto);
// 等事务提交成功再删,避免"删在提交前、并发请求回填旧值"
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
twoLevelCache.evict("dict:" + id);
}
});
}
删除动作一行,但它是整套设计的命门:
java
public void evict(String key) {
redis.delete(key);
// 广播给所有实例清 L1
redis.convertAndSend(CHANNEL, key);
// 本机也清
l1.invalidate(key);
}
写路径为什么只删不更新呢?
看一个并发交错的例子你就懂了:请求 A 更新数据,查库得到新值准备写缓存;请求 B 紧接着也更新,查库、写缓存都比 A 快;A 最晚落盘------缓存里留下的是 A 基于的旧值。删除没有这个交错窗口,下一次读自然回填最新数据。代价是删除后第一次读要回源,对字典类数据无所谓。
afterCommit 不能省。缓存删在事务提交之前,另一个请求恰好读缓存没命中、回源查到的还是提交前的旧数据、填回缓存,这个旧值能一直活到 TTL 到期。
Pub/Sub 会丢消息,别把命全押上
L1 的跨实例失效靠 Redis Pub/Sub 广播,听着美好,它有两个绕不过去的洞:
- 消息是即发即弃的。某个实例发布那一刻网络抖了一下、正在重连,这条失效通知就永久错过了,没有重投
- Redis 重启、订阅连接断开恢复期间的消息也不补
所以前面读路径里 L1 的 TTL 只给 5 分钟------广播正常时毫秒级生效,广播丢了,最坏 5 分钟后 L1 自己过期,脏数据有明确的存活上限。这个短 TTL 承担的就是兜底职责。
对一致性要求到分钟级都不能忍的场景(余额、库存),答案是别缓存,或者加版本号校验。二级缓存服务的是字典、配置、报表结果这类"旧一点点无害"的数据,选型时先把这个边界划清楚。
集群部署还要确认你的 Redis 版本和发布方式:Redis 7 的 Sharded Pub/Sub 跟传统 Pub/Sub 传播范围不一样,具体以自己环境的文档为准,这块我没有在生产上踩过全版本的坑,不展开装懂。
网上流传的"延迟双删"(更新后删一次,sleep 几百毫秒再删一次)我用过也见过,说下我的取舍:sleep 时长全靠拍,拍短了挡不住回填,拍长了拖慢写请求;在有 afterCommit 删除 + L1 短 TTL 的前提下,它的边际收益很小。真担心极端交错,把 L1 TTL 再压短更实在。
序列化:别让 F03 的坑在缓存里复活
L2 里存 JSON 字符串,读回来 JSON.parseObject(raw, clazz),全程不需要 autoType。这个项目原来用的是 FastJSON 1.2.37,F03 讲过它 autoType 的那串 CVE------缓存是外部可读的存储,往 Redis 里写带 @type 的序列化串,等于给反序列化漏洞留了个入口。
用 FastJSON 就只调 toJSONString 和带显式 class 的 parseObject,别碰 parse(str) 这种根据内嵌类型自动还原的写法。新系统我会直接选 Jackson 或者 JSON 字符串 + 手工转换,跟 F03 里依赖体检的结论保持一致。
还有个小坑:JVM 8 秒的时区、日期格式、Long 精度(JS 前端 ID 后几位变 0)这些问题在缓存 JSON 化时会原样暴露,DTO 上的 @JsonFormat 该加就加,别等缓存上线才发现时间字段格式变了。
放在今天,L1 还可以换 Caffeine
标题里写 Guava,因为这是项目真实用的。如实讲,如果今天新写,L1 我会用 Caffeine。
Guava Cache 现在处于维护状态,Google 官方对它的定位就是"能用",Caffeine 是它的继任者,API 几乎照搬(Caffeine.newBuilder().maximumSize().expireAfterWrite().build()),换包成本很低。差异在性能和功能:W-TinyLFU 淘汰算法命中率更高、异步刷新(refreshAfterWrite + AsyncLoadingCache)不堵请求线程、原生支持基于写入时间的抖动过期。
如果系统里已经有 Guava 且跑得好好的,没必要为了换而换;新代码没有历史包袱,直接 Caffeine。Guava 20.0 是 2016 年的包,真要留在 Guava 上,至少把版本升上去,具体受哪些 CVE 影响去 F03 那篇查 pom 体检的方法自己扫一遍。
什么时候根本不该上二级
给你再泼盆冷水。二级缓存最容易犯的错是过早上:
- 单实例、QPS 几百、字典表几百行------一个 Guava 就够,Redis 那一跳纯属浪费
- 数据写多读少------缓存活不过 TTL,命中率个位数,多出来的全是复杂度
- 强一致场景------余额、库存、状态机,老老实实查库加事务,缓存救不了一致性
这个项目当年的真实需求(系统字典、地区表、行业分类、OSS云 token)里,前三个适合 L1+L2,token 适合带主动失效的 L1。需求一共就这么大,一套 @Cacheable + Redis 都可能算重。技术选型先看数据特征,别看到"缓存"两个字就把全套架构端上来。
自查清单
| 检查项 | 怎么查 | 危险信号 |
|---|---|---|
| 本地缓存做分布式判断 | 搜 static Cache 里放业务键 | 并发互斥、限流、会话校验依赖单机 Map |
| TTL 与真实寿命脱节 | 看缓存的是什么、源头寿命多久 | token/凭证按拍脑袋时间缓存,无 401 失效 |
| 缓存对象声明了没用 | 搜 CACHES 的全部引用 | 只有声明没有 put/get,或整段注释 |
| 删缓存的时机 | 看 evict 在事务前还是 afterCommit | 提交前删,并发回填旧值 |
| L1 失效手段 | 有没有跨实例广播 + 短 TTL 双保险 | 只靠 TTL 且 TTL 很长,或只靠广播没有兜底 |
| 缓存命中率 | 看有没有 stats 和监控 | 上线后没人知道缓存命中多少 |
| 引了中间件没用 | pom 里有 starter,代码零调用 | redis/mq 依赖纯摆设,误导后来人 |
老炮点评
这三个缓存有个共同点:写的人都在用 JVM 的内存解决一个本该想清楚边界的问题:
- token 缓存是没想清楚"数据的寿命归谁管"
- 防重缓存是没想清楚"互斥域是用户还是全人类"
- 文件名缓存是没想清楚"系统有几个节点"
缓存本身没有错,错的是把作用域想小了一圈。
二级缓存的读路径没什么可说的------谁都会写,L1、L2、回源三层而已。真正难的是失效:删的时机、广播的丢失窗口、L1 TTL 兜底、空值和抖动,每一个都在跟"旧数据还能活多久"这个问题较劲。
我之前面过不少候选人聊缓存,大部分能画出读路径图,问到"广播丢了怎么办"就会卡住,这道题基本上能分出谁真的在线上用过缓存。
那个零调用的 redis starter 也给我提了个醒:代码库里的每个依赖都是一句对未来的承诺。引了不用,它会误导下一个读代码的人------证据就是我,上一篇预告就被它骗了。
下期预告:《HTML 转 PDF 三种方案对比:iTextPDF vs wkhtmltopdf vs Flying Saucer》
项目里这三个方案的痕迹都有,还混着一个 wkhtmltopdf 的进程调用封装。下期把中文乱码、样式丢失、并发时进程被打满的问题摊开讲,一张表说清各自的适用场景。
本文同步发表于公众号「Java老炮踩坑录」,欢迎关注阅读。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言。
你的每一次互动都是我继续更新的动力,我们下一篇见!🚀
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。