Guava 本地缓存踩坑复盘:三个真实案例,推演 Redis + Guava 二级缓存架构设计(附完整代码)
老炮踩坑录 · 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 个槽位只放一个 keySessionCacheUtils里有这么个东西// SessionCacheUtils.javaprivatestaticfinalCacheString,ObjectCACHESCacheBuilder.newBuilder().maximumSize(50).expireAfterWrite(60,TimeUnit.MINUTES).build();全项目搜它的 put只有一处key 还是写死的字符串// 匿名登录拿回来的 accessTokenCACHES.put(NEW_TOKEN,accessToken);一个容量 50 的缓存一辈子只存一个 key。这不算大问题顶多说明代码是抄来的。真正的病在 TTL是开发者凭感觉随手定的没有任何依据但这个 token 是其他系统那边发的人家的有效期是多少、会不会提前作废这边一概不知道。万一远端 token 真实寿命只有 30 分钟第 31 分钟到第 60 分钟之间所有匿名请求都拿着一个死 token 去调接口全部 401而代码里没有任何收到 401 就清缓存重登一次的兜底逻辑。另外 Guava 的过期是惰性的默认没有后台线程掐表条目在下次读写被顺带清理。恰好 60 分钟失效这个心智模型本身就不精确虽然单 key 场景影响不大。这个缓存要修改思路很直白TTL 按远端返回的expires_in打个折来设再预留主动失效——调远端收到鉴权失败CACHES.invalidateAll()后重登一次。整个缓存是注释掉的AuthAspect里也有一个配置几乎一模一样// AuthAspect.javaprivatestaticfinalCacheString,ObjectCACHESCacheBuilder.newBuilder().maximumSize(50).expireAfterWrite(30,TimeUnit.MINUTES).build();然后看使用处全是注释// 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 当锁多实例直接瞎第三处在华为云文件服务也是病得最重的一处// HwFileServiceImpl.javaprivatestaticfinalCacheString,ObjectCACHESCacheBuilder.newBuilder()// 最大缓存 100 个.maximumSize(100)// 设置写缓存后 24小时过期.expireAfterWrite(24,TimeUnit.HOURS).build();看用法就明白它想干什么了。上传方法进来先把本次生成的文件名塞进缓存传完再读出来跟自己的局部变量比// 请求进来把本次的新文件名写进去if(i0){CACHES.put(rapplyidtype,name);unamename;}// ... 上传、更新附件表 ...// 全部结束后再读一次ObjectifPresentCACHES.getIfPresent(rapplyidtype);if(ifPresent!null!ifPresent.toString().equals(uname)){asyncService.cleanHwyFile(...);thrownewSystemException(不能覆盖最新的文件);}同一个 key中间要是被另一个并发请求覆盖过读回来的就不是自己的uname于是判定有人同时传了同一笔业务的附件抛异常。这是一个用 static Map 实现的乐观并发探测跟 Java OBS 对象存储同名文件覆盖排查文件名唯一性方案与源码分析 讲的同名文件覆盖是同一个战场。单机上它勉强能工作。多实例部署就有问题了A 节点收到第一个请求往自己的 Map 里写B 节点收到第二个请求往自己的 Map 里写两边各自读自己都觉得天下太平OBS 上的文件该覆盖还是覆盖。另外maximumSize(100)的 LRU 淘汰也可能在请求处理途中把这个 key 挤掉虽然概率低但拿一个容量受限的通用缓存做正确性保障本身就把业务正确性挂在了缓存淘汰策略上。这类问题的正解在数据库——附件表对业务键加版本号或唯一约束让后提交的人在事务里撞墙要体验更好一点用 Redis 的SET NX做跨实例互斥。用 JVM 内存里的 Map 给分布式系统当锁等于只给一半机器上锁。还有一个零调用的 redis starter!-- pom.xml --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-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基础读路径代码应该是这样ComponentSlf4jpublicclassTwoLevelCache{privatestaticfinalStringNULL_HOLDERNULL;privatestaticfinalStringCHANNELcache:invalidate;privatefinalStringRedisTemplateredis;privatefinalCacheString,Stringl1;publicTwoLevelCache(StringRedisTemplateredis,RedisMessageListenerContainercontainer){this.redisredis;this.l1CacheBuilder.newBuilder().maximumSize(10_000)// L1 故意设短5 分钟作为 L2 失效广播丢失时的兜底.expireAfterWrite(5,TimeUnit.MINUTES).recordStats().build();// 订阅其他实例发出的失效广播清掉自己的 L1MessageListenerlistener(msg,pattern)-l1.invalidate(newString(msg.getBody(),StandardCharsets.UTF_8));container.addMessageListener(listener,newChannelTopic(CHANNEL));}SuppressWarnings(unchecked)publicTTget(Stringkey,ClassTclazz,SupplierTdbLoader){try{Stringrawl1.get(key,()-{Stringvredis.opsForValue().get(key);if(v!null){returnv;}returnloadFromDb(key,dbLoader);});if(NULL_HOLDER.equals(raw)){returnnull;}returnJSON.parseObject(raw,clazz);}catch(ExecutionExceptione){thrownewRuntimeException(缓存读取失败: key,e);}}}几个口径先定下来整篇都按这个走L1 存字符串跟 L2 落地的格式一致省掉两层各自序列化的心智负担L1 的 TTL 故意压到分钟级它只是热点加速层正确性靠 L2 和失效广播保证recordStats()要开。命中率、淘汰数这些指标不接监控缓存有没有在工作全靠猜回源跨实例只放一个请求下去L1 没命中没关系L2 也没命中的时候就要小心一万个实例线程同时发现 L2 空了一起查库这就是缓存击穿。Guava 的Cache.get(key, callable)对同一个 key自带单飞效果同一 JVM 里只有一个线程执行 loader。但二级缓存下单飞要跨实例得用 Redis 锁privateTStringloadFromDb(Stringkey,SupplierTdbLoader){StringlockKeylock:key;Booleanlockedredis.opsForValue().setIfAbsent(lockKey,1,10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(locked)){try{TvaluedbLoader.get();if(valuenull){// 空结果也缓存TTL 给短防穿透redis.opsForValue().set(key,NULL_HOLDER,2,TimeUnit.MINUTES);returnNULL_HOLDER;}StringjsonJSON.toJSONString(value);redis.opsForValue().set(key,json,redisTtl());returnjson;}finally{redis.delete(lockKey);}}// 没抢到锁稍等一下读 L2读到别人回源的结果就直接用sleepBriefly();Stringvredis.opsForValue().get(key);if(v!null){returnv;}// 还没有就直接回源。宁可多查一次库也不能让请求死等锁TfallbackdbLoader.get();returnfallbacknull?NULL_HOLDER:JSON.toJSONString(fallback);}privateDurationredisTtl(){// 基准 30 分钟±5 分钟随机大量 key 不会同一时刻集体过期longseconds30*60ThreadLocalRandom.current().nextLong(-300,300);returnDuration.ofSeconds(seconds);}空值缓存这里多说一句null必须有专门的占位符且 TTL 远短于正常值两分钟足够挡住恶意刷不存在 ID 的穿透。如果 key 空间巨大且可枚举比如连续自增 ID再加一层布隆过滤器挡在最前面空值缓存兜剩下的散弹。没抢到锁的分支我没写循环等待——短暂等一次读不到就自己回源。等待循环写不好就是惊群加超时多一次可控的数据库查询比把线程挂死在锁上强。写路径更新数据库删除缓存写操作的标准动作是更新库、删缓存TransactionalpublicvoidupdateDict(Longid,DictDTOdto){dictMapper.updateByPrimaryKeySelective(dto);// 等事务提交成功再删避免删在提交前、并发请求回填旧值TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){twoLevelCache.evict(dict:id);}});}删除动作一行但它是整套设计的命门publicvoidevict(Stringkey){redis.delete(key);// 广播给所有实例清 L1redis.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.37F03 讲过它 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 淘汰算法命中率更高、异步刷新refreshAfterWriteAsyncLoadingCache不堵请求线程、原生支持基于写入时间的抖动过期。如果系统里已经有 Guava 且跑得好好的没必要为了换而换新代码没有历史包袱直接 Caffeine。Guava 20.0 是 2016 年的包真要留在 Guava 上至少把版本升上去具体受哪些 CVE 影响去 F03 那篇查 pom 体检的方法自己扫一遍。什么时候根本不该上二级给你再泼盆冷水。二级缓存最容易犯的错是过早上单实例、QPS 几百、字典表几百行——一个 Guava 就够Redis 那一跳纯属浪费数据写多读少——缓存活不过 TTL命中率个位数多出来的全是复杂度强一致场景——余额、库存、状态机老老实实查库加事务缓存救不了一致性这个项目当年的真实需求系统字典、地区表、行业分类、OSS云 token里前三个适合 L1L2token 适合带主动失效的 L1。需求一共就这么大一套Cacheable Redis 都可能算重。技术选型先看数据特征别看到缓存两个字就把全套架构端上来。自查清单检查项怎么查危险信号本地缓存做分布式判断搜 static Cache 里放业务键并发互斥、限流、会话校验依赖单机 MapTTL 与真实寿命脱节看缓存的是什么、源头寿命多久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 的进程调用封装。下期把中文乱码、样式丢失、并发时进程被打满的问题摊开讲一张表说清各自的适用场景。如果本文对你有帮助欢迎 点赞 ⭐ 收藏 关注作者 留言。你的每一次互动都是我继续更新的动力我们下一篇见我是老炮18 年 Java 老兵仍在一线。关注「Java老炮踩坑录」不错过每一篇真实案例少踩坑。