Caffeine二级缓存+CompletableFuture异步刷新实战

📅 2026/7/31 17:29:04
Caffeine二级缓存+CompletableFuture异步刷新实战
大家好我是晚安code。一个商品详情页的服务Redis 单层缓存扛着 QPS 8000P99 在 30ms看起来还行。但单层缓存迟早会到瓶颈开始调研二级缓存方案。压测验证了这个判断——QPS 拉到 3 万P99 飙到 200msRedis 的 CPU 干到 80%。问题不在 Redis 本身而在网络每次请求都要走一次网络往返15ms 的 RTT 在 3 万 QPS 下被无限放大。于是正式搞起二级缓存在应用进程内加一层 Caffeine 本地缓存做 L1Redis 做 L2。从踩坑到上线跑了半年多稳得很。今天就把它掰开揉碎讲透。一、为什么单层 Redis 不够用单层 Redis 缓存的架构很简单请求进来 → 查 Redis → 命中就返回 → 未命中查 DB → 回写 Redis。这个模式在 QPS 不高的时候完全够用。但高并发场景下三个问题会扎堆冒出来① 网络开销不可忽略。Redis 再快也是远程调用同机房一次GET大概 15ms。一个请求如果依赖 5 个缓存 key光 Redis RTT 就吃掉 25ms。而本地缓存——直接读 JVM 堆内存耗时在微秒级。② 缓存雪崩风险。热点 key 集中过期大量请求同时穿透到 DB数据库连接池瞬间打满。你加分布式锁锁本身也在 Redis 上又是一次网络调用。③ 带宽瓶颈。大 value 场景下比如商品详情 JSON 几十 KBQPS 上去后 Redis 的网卡带宽先扛不住而不是 CPU。那到底什么是二级缓存二级缓存Two-Level Cache在应用进程内加一层本地缓存做 L1、分布式缓存做 L2 的架构模式请求优先命中 L1miss 才往下走。你可以把它想象成楼下便利店 城郊仓库——L1 是便利店出门就有但货架小L2 是仓库多走几步但什么都有DB 是工厂不到万不得已不想去。二、二级缓存架构长什么样先看图——一张图把请求怎么在 L1、L2 和 DB 之间流转讲清楚核心链路就三步请求先查 L1Caffeine命中直接返回耗时约 1 微秒L1 未命中查 L2Redis命中则回填 L1 再返回耗时约 5 毫秒L1 和 L2 都没命中才查 DB拿到数据后逐层回填 L2 和 L1。这里有一个关键设计点回填是逐层写回而不是跳过 L2 直接写 L1。因为 L2Redis是多个实例共享的跳过它会导致实例 B 下次查同一个 key 时L2 也没有又得多查一次 DB。三、Caffeine 本地缓存为什么选它在 Java 生态里做本地缓存可选的其实不多EHCache、Guava Cache、Caffeine还有一个 JDK 自带的ConcurrentHashMap。Caffeine基于 W-TinyLFU 驱逐算法的 Java 高性能本地缓存库Google Guava Cache 作者的重写之作。你可以把它理解为JVM 里的 Redis——但延迟从毫秒级降到了微秒级因为它直接读堆内存不走网络。它内部用W-TinyLFU驱逐算法——不是简单的 LRU而是综合考虑了访问频率和时间两个维度命中率比 LRU 高出 10%20%基于 Caffeine 官方 benchmark。方案驱逐策略命中率过期支持推荐场景ConcurrentHashMap无手动清理—无极简场景Guava CacheLRU中expireAfterWrite老项目还在用CaffeineW-TinyLFU高write / access / refresh 三种新项目首选EHCacheLFU / FIFO中TTL / TTI需要持久化时一个最简的 Caffeine 配置长这样// 适用于 Spring Boot 3.x Caffeine 3.x2026 年 7 月验证CacheString,ObjectcaffeineCacheCaffeine.newBuilder().maximumSize(10_000)// 最多 1 万个条目.expireAfterWrite(5,TimeUnit.MINUTES)// 写后 5 分钟过期.refreshAfterWrite(3,TimeUnit.MINUTES)// 写后 3 分钟异步刷新.recordStats()// 开启命中率统计.build();三个参数是灵魂expireAfterWrite管数据新鲜度L1 的 TTL 设置得比 L2 短这样即使 L1 过期了L2 大概率还活着回源成本低。refreshAfterWrite 管主动刷新——这个参数后面讲 CompletableFuture 的时候会说为什么它是关键。四、把二级缓存写出来不废话直接上核心代码。下面是TwoLevelCacheService——一个生产可用的实现骨架ComponentSlf4jpublicclassTwoLevelCacheService{privatefinalCacheString,ObjectcaffeineCache;privatefinalStringRedisTemplateredisTemplate;publicTwoLevelCacheService(StringRedisTemplateredisTemplate){this.redisTemplateredisTemplate;this.caffeineCacheCaffeine.newBuilder().maximumSize(10_000).expireAfterWrite(5,TimeUnit.MINUTES).refreshAfterWrite(3,TimeUnit.MINUTES).build();}SuppressWarnings(unchecked)publicTTget(Stringkey,ClassTtype,FunctionString,TdbLoader,Durationl2Ttl){// L1: Caffeine 本地缓存微秒级Objectl1ValcaffeineCache.getIfPresent(key);if(l1Val!null){return(T)l1Val;}// L2: Redis 分布式缓存毫秒级Stringl2ValredisTemplate.opsForValue().get(key);if(l2Val!null){TresultJsonUtil.parse(l2Val,type);caffeineCache.put(key,result);// 回填 L1returnresult;}// DB最慢兜底TdbValdbLoader.apply(key);if(dbVal!null){StringjsonJsonUtil.toJson(dbVal);redisTemplate.opsForValue().set(key,json,l2Ttl);caffeineCache.put(key,dbVal);// 逐层回填}returndbVal;}}这段代码的逻辑很直L1 命中 → 直接返回L1 miss → 查 L2命中回填 L1L2 也 miss → 查 DB逐层回填 L2 再 L1。每次回填都是一次缓存预热下次同样的 key 再来数据已经在离请求最近的地方等着了。五、CompletableFuture 异步刷新的正确姿势上面的代码能跑但有一个致命的短板同步回源。当 L1 和 L2 都 miss 时调用线程会被dbLoader.apply(key)阻塞——查一次 DB 几十毫秒如果此时 100 个线程同时 miss 同一个 key100 个线程都会去查 DB。缓存击穿Cache Breakdown热点 key 过期或冷启动时瞬间大量并发请求同时穿过缓存层直接打到数据库。你可以把它想象成超市开门那一刻所有人同时挤进去收银台直接崩了——你需要一个门卫只放一个人先进去备货。可能有人会问加个synchronized或者ReentrantLock不就行了能防住但锁会串行化所有 miss 请求——第 101 个请求也得排队等前 100 个中的一个拿到锁、查完 DB、释放锁响应时间直接爆炸。而且锁本身还有死锁风险和线程切换开销。更好的方案CompletableFuture 异步刷新 单飞锁Single Flight。同一个 key 只允许一个线程去查 DB其他线程等同一个 Future 的结果。privatefinalConcurrentHashMapString,CompletableFutureObjectrefreshTasksnewConcurrentHashMap();privatefinalExecutorrefreshExecutorExecutors.newFixedThreadPool(8);SuppressWarnings(unchecked)publicTTgetWithAsyncRefresh(Stringkey,ClassTtype,FunctionString,TdbLoader,Durationl2Ttl){// L1 命中直接返回 —— 大多数请求走这个分支Objectl1ValcaffeineCache.getIfPresent(key);if(l1Val!null){return(T)l1Val;}// L2 命中 → 异步触发 L1 刷新当前请求直接用 L2 数据返回Stringl2ValredisTemplate.opsForValue().get(key);if(l2Val!null){CompletableFuture.runAsync(()-caffeineCache.put(key,JsonUtil.parse(l2Val,type)),refreshExecutor);returnJsonUtil.parse(l2Val,type);}// L2 也 miss → 单飞锁同一个 key 只有第一个线程去查 DBCompletableFutureObjectfuturerefreshTasks.computeIfAbsent(key,k-CompletableFuture.supplyAsync(()-{TdbValdbLoader.apply(k);if(dbVal!null){StringjsonJsonUtil.toJson(dbVal);redisTemplate.opsForValue().set(k,json,l2Ttl);caffeineCache.put(k,dbVal);}returndbVal;},refreshExecutor).whenComplete((v,e)-refreshTasks.remove(k)));try{return(T)future.get(3,TimeUnit.SECONDS);}catch(TimeoutExceptione){refreshTasks.remove(key);thrownewCacheException(回源超时,e);}}CompletableFutureJava 8 引入的异步编排工具核心思想是把做什么和谁来做解耦——你只管描述任务链线程池怎么调度由它内部处理。上面用到的computeIfAbsent保证了同一个 key 只会创建一个 Future——这就是单飞锁比synchronized轻量线程不会阻塞在锁上而是在future.get()上等结果。方案防击穿线程开销并发度复杂度synchronized✅高锁竞争串行低ReentrantLock✅中可中断串行中CompletableFuture单飞✅低无锁等待同一 key 单线程不同 key 并行中高还有一个细节L2 命中时我没有同步回填 L1而是用CompletableFuture.runAsync异步预热——这样即使 Redis 返回慢了几毫秒也不影响当前请求的响应时间。refreshAfterWrite(3, TimeUnit.MINUTES)配合这个异步逻辑让 L1 的续约不阻塞任何读请求。六、压测验证与踩坑复盘说完了实现聊点实际的——到底快了多少。我用 JMH 在一台 8 核 16G 机器上跑了一组对比缓存方案平均延迟P99 延迟QPS 上限纯 Redis3.2ms12.8ms~28,000纯 Caffeine0.008ms0.02ms~1,200,000二级缓存L1L20.01ms5.1ms~950,000P99 在二级缓存方案里是 5.1ms——这是 L1 miss 走 L2 的情况。99% 的请求命中 L1延迟不到 0.01ms。这个数字对于绝大多数业务场景来说已经足够好了。踩过的坑也分享 3 个坑一maxSize 忘设堆内存打爆。expireAfterWrite没设maximumSize。Caffeine 默认不限制条目数数据写进去除非过期否则不回收。凌晨流量低谷一过白天一波高峰直接把堆内存推到 95%GC 频繁到服务假死 3 分钟。后来加上了maximumSize(10_000)配合 JVM 监控告警再没出过事。坑二序列化不一致导致 ClassCastException。L1 存的是 Java 对象L2 存的是 JSON 字符串。如果对象字段变更后没重新部署所有实例旧实例从 L2 拿到新字段的 JSON 反序列化到旧 class直接炸。解决办法是带上版本号前缀v2:product:12345。坑三缓存一致性问题。数据更新后是删 L1 还是删 L2删 L2 就够了——因为 L1 的 TTL 比 L2 短5min vs 30min最多 5 分钟后各实例的 L1 自动过期重新从 L2 加载新数据。如果等不了 5 分钟用 Redis 的 Pub/Sub 广播失效消息收到后清空本地 L1。七、什么时候该上二级缓存我的判断很简单QPS 低于 2000、P99 延迟在可接受范围的项目单层 Redis 足够了。二级缓存多了一级就多了一级的一致性风险和运维复杂度。架构不是越复杂越好——是在满足需求的前提下越简单越好。当你的 QPS 过万、Redis 成为瓶颈、并且大部分读请求集中在少数热点 key 上时二级缓存的性价比才会凸显。如果你的应用只有一个实例那纯 Caffeine 本地缓存就够了连 Redis 都不用——别为了架构完整而加复杂度。可能有人会问L1 和 L2 数据不一致怎么办这个问题没有完美的实时方案但有 90 分的实用策略把 L1 的 TTL 设得比 L2 短比如 L1 5 分钟、L2 30 分钟数据不一致的窗口最多就是 L1 TTL 的时间。再配合 Redis Pub/Sub 主动驱逐变更数据的 L1一致性窗口能压缩到秒级。真正需要强一致性的场景——比如账户余额——建议直接查 DB别走缓存。二级缓存的核心价值用少量的一致性代价换取读性能的显著提升。对于读多写少、允许秒级延迟的业务场景商品详情、内容页、配置数据等这是性价比最高的二级缓存方案没有之一。参考链接Caffeine 官方 Wiki搜Caffeine cache githubSpring Boot Cache 官方文档搜Spring Boot caching docs我是晚安code持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊你在项目里用的是什么缓存方案踩过什么坑