从三个带病的 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 个槽位,只放一个 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
ThinkerQAQ_1 小时前
并发编程(七):volatile——从语言规则到 CPU
java·python·go
蜗牛互联网1 小时前
Python Responses API函数调用实战:工具白名单、参数校验与预算
java·人工智能·后端
鶴哥只手遮天1 小时前
从零搭建光电仿真引擎(四):光学仿真与辐射传输
后端
imDwAaY1 小时前
Redis Set 如何节省内存?从整数集合到哈希表的设计取舍
数据结构·redis·散列表
韩曙亮1 小时前
【JavaEE】MyBatis 基本用法 ⑤ ( 单条件查询 | 多条件查询 | 多条件动态查询 | 单条件动态查询 )
java·sql·java-ee·mybatis·mapper·sql映射
开开心心就好1 小时前
二维码批量生成导出工具,离线可用完全免费
java·前端·人工智能·智能手机·github·excel·visual studio
ly76891 小时前
Spring Boot 集成 Redis 企业级实践:连接池、序列化与缓存穿透雪崩的工程化防御
spring boot·redis·缓存·缓存穿透·布隆过滤器·lettuce 连接池
wangbing11251 小时前
开发指南147-WebSocket-前后关联关系
java
Wx-bishekaifayuan1 小时前
springboot陨石鉴收系统96265-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·spring·课程设计