Java 缓存方案:Redis / Caffeine / 本地缓存多级架构

最长的电影
最长的电影 正式会员正式会员
发布于 2026-10-06 22:54 ·6 浏览 ·2 回复

学完这篇你能拿到一套可直接落地的 Java 两级缓存骨架:Caffeine 扛本地热点、Redis 做跨节点共享,并附带读路径、失效广播和常见缓存故障的处理写法。

第一步:先想清楚哪一层放什么

Caffeine 是进程内缓存,读取是纳秒级,代价是每个 JVM 一份副本,多实例部署会出现数据不一致;Redis 跨进程共享,毫秒级,但有网络和序列化开销。所以定位很明确:热点、小体积、能容忍秒级不一致的数据进 L1,共享数据和全量数据放 L2。

一组常用参数:

  • L1 Caffeine:maximumSize = 10_000,expireAfterWrite = 30~60s
  • L2 Redis:TTL 10~30 分钟,再叠加随机抖动

适合加二级缓存的是字典、站点配置、榜单前几名、用户基础资料;订单状态、库存这种强一致数据不要进本地缓存。

注意:L1 的 TTL 直接决定最坏情况下各节点数据不一致的时长。本地 TTL 建议 30~60 秒,设成 10 分钟等于允许别人看 10 分钟旧数据。

第二步:加依赖

<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
  <version>3.1.8</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

第三步:读路径写成 L1 → L2 → 回源

private final Cache<String, Object> local = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(60))
        .recordStats()
        .build();

public <T> T get(String key, Class<T> type, Duration redisTtl, Supplier<T> loader) {
    Object l1 = local.getIfPresent(key);
    if (l1 != null) return unwrap(l1);

    String json = redis.opsForValue().get(key);
    if (json != null) {
        T v = JsonUtil.parse(json, type);
        local.put(key, v);
        return v;
    }
    T v = loader.get();                 // 回源查数据库
    if (v == null) {
        local.put(key, NULL_HOLDER);    // 空值也缓存,防穿透
        redis.opsForValue().set(key, NULL_JSON, Duration.ofSeconds(60));
    } else {
        local.put(key, v);
        redis.opsForValue().set(key, JsonUtil.toJson(v), withJitter(redisTtl));
    }
    return v;
}

Key 统一成 业务:对象:{id},例如 user:profile:10086,方便按前缀批量排查和清理。

第四步:写路径——先落库,再删缓存,最后广播失效

public void update(UserProfile p) {
    userMapper.update(p);
    redis.delete(key(p.getId()));            // 删而不是更新
    redis.convertAndSend("cache:evict", key(p.getId())); // 通知所有节点清 L1
}

每个实例订阅 cache:evict 频道,收到消息就 local.invalidate(key)。没有这一步,A 节点改了数据,B 节点的本地缓存还会继续吐旧值,直到 TTL 到期。

注意:不要用“先删缓存再写数据库”,并发下别的线程会把旧值重新读回缓存。删除失败要进重试队列,别只记一行日志。

第五步:防穿透、击穿、雪崩

  • 穿透:不存在的 ID 每次都打到库。做法是空值缓存 60 秒,或前置布隆过滤器。
  • 击穿:热点 key 过期瞬间大量请求同时回源。用 Redis 短锁(setIfAbsent,超时 3 秒)保证只有一个线程重建,其余线程短暂等待后重读;也可以给 value 存“逻辑过期时间”,过期后由异步线程刷新,旧值先扛着。
  • 雪崩:大批 key 同时失效。TTL 加 0~300 秒随机抖动即可。

第六步:序列化和容量别踩坑

Redis 里存 JSON 或 Protobuf,不要用 JDK 原生序列化,跨语言不通、体积还大一截。Caffeine 的 maximumSize 按条数计,不按字节,如果缓存的是几百 KB 的大列表,一万条就是几个 G,要单独压小上限。

第七步:看效果,而不是凭感觉

Caffeine 打开 recordStats(),定期打日志看 hitRate();Redis 侧看 INFO stats 的命中率与网络往返延迟。命中率低于 60% 说明 key 粒度太粗或 TTL 太短,先调这两个,别急着加机器。

小结

  • 分层定位:Caffeine 放热点小对象,Redis 放共享数据,强一致数据两者都不放。
  • 读路径 L1 → L2 → 回源,空值也要缓存。
  • 写路径:先更新数据库,再删 Redis,再通过 Pub/Sub 广播清各节点 L1。
  • L1 的 TTL 就是不一致窗口,控制在 30~60 秒。
  • 穿透靠空值和布隆过滤器,击穿靠分布式锁或逻辑过期,雪崩靠 TTL 抖动。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-729.html
转载请注明出处,版权归原作者所有。

全部回复 2

zero
zero 见习用户见习用户 1楼 2026-10-06 23:03

方向是对的,但这份骨架要能直接上线,还得补三件事:加载要单飞(single-flight)、失效要广播、空值和穿透要分档处理。

先说读路径。别用 getIfPresent + 手动 put,并发下同一个 key 会有 N 个线程同时打到 Redis/DB,正是你想防的击穿。改成 Caffeine 的 local.get(key, k -> loadFromL2OrDb(k)),它是原子合并的,同 key 只会有一个线程做加载。唯一要小心的是 mappingFunction 里不要再回头访问同一个 cache 实例,会直接抛异常。

再说失效广播,这是两级缓存真正的坑:A 节点更新了数据,B 节点的 L1 还捧着旧副本。标准写法是「先更新 DB → 删 Redis → 广播 key」,各节点收到后 local.invalidate(key)。广播可以用 Redis Pub/Sub,但它不保证送达(订阅端断线期间的 key 就丢了),所以必须配一道兜底——L1 的 TTL 就是你的最后防线,30~60 秒这个值别放大。要求更高就换 Redis Stream / MQ,或者直接用 Redisson 的 RLocalCachedMap,它内置了这套失效通知。

最后是你代码截断的那块:空值缓存千万别和正常值共用一个 TTL。空值建议单独存 30 秒左右、用独立的 holder 标记,否则 DB 里刚写入的数据会被空值挡到 TTL 到期;另外空值反序列化成 Class<T> 会直接抛异常,得在 parse 之前判掉。

延伸两点:TTL 抖动要落到代码里(base + random(base/5)),别只在注释里飘;recordStats() 既然开了,顺手把命中率打到监控上——L1 长期低于 50% 命中,说明 maximumSize 或 key 设计有问题,加了等于白加。帖子末尾的代码块没闭合,补全一下大家好看完整版。

不语
不语 正式会员正式会员认证极客认证极客 #462 2楼 2026-10-06 23:08
zero:方向是对的,但这份骨架要能直接上线,还得补三件事:**加载要单飞(single-flight)、失效要广播、空值和穿透要分档处理**。 先说读路径。别用 `g…

三条都认同,但按上线翻车概率排,我的优先级是:失效广播的竞态 > 单飞 > 空值分档。

单飞用 local.get(k, this::load) 是对的,补一点:loader 里同步打 Redis + DB(几十到几百 ms)会占住等待同一个 key 的线程,高压下线程池容易顶满。这种场景直接上 AsyncCache,get(k, (key, exec) -> CompletableFuture.supplyAsync(...)),等待线程立刻释放,比单纯调大 maximumSize 管用得多。

广播那块,Pub/Sub 丢消息其实不算大事(TTL 本来就是兜底),真正会长期脏的是读线程和广播的竞态:A 线程读到 DB 旧值但还没 put 进 L1,B 线程已经更新 DB、删 Redis、广播完 invalidate 了,A 的 put 才落地——这条 L1 会一路脏到 TTL 到期。解法是 value 带版本号(或 Redis 里的更新序号),put 之前比对本地版本,落后就直接丢弃。