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

简介: Redis 扛不住高并发读?Caffeine+Redis 二级缓存搭配 CompletableFuture 异步刷新,读性能翻倍防雪崩,这一篇全讲透。

大家好,我是晚安code。

一个商品详情页的服务,Redis 单层缓存扛着 QPS 8000,P99 在 30ms,看起来还行。但单层缓存迟早会到瓶颈,开始调研二级缓存方案。压测验证了这个判断——QPS 拉到 3 万,P99 飙到 200ms+,Redis 的 CPU 干到 80%。问题不在 Redis 本身,而在网络:每次请求都要走一次网络往返,1~5ms 的 RTT 在 3 万 QPS 下被无限放大。于是正式搞起二级缓存:在应用进程内加一层 Caffeine 本地缓存做 L1,Redis 做 L2。从踩坑到上线跑了半年多,稳得很。今天就把它掰开揉碎讲透。

一、为什么单层 Redis 不够用

单层 Redis 缓存的架构很简单:请求进来 → 查 Redis → 命中就返回 → 未命中查 DB → 回写 Redis。这个模式在 QPS 不高的时候完全够用。

但高并发场景下,三个问题会扎堆冒出来:

① 网络开销不可忽略。 Redis 再快也是远程调用,同机房一次 GET 大概 1~5ms。一个请求如果依赖 5 个缓存 key,光 Redis RTT 就吃掉 25ms。而本地缓存——直接读 JVM 堆内存,耗时在微秒级。

② 缓存雪崩风险。 热点 key 集中过期,大量请求同时穿透到 DB,数据库连接池瞬间打满。你加分布式锁?锁本身也在 Redis 上,又是一次网络调用。

③ 带宽瓶颈。 大 value 场景下(比如商品详情 JSON 几十 KB),QPS 上去后 Redis 的网卡带宽先扛不住,而不是 CPU。

那到底什么是二级缓存?

二级缓存(Two-Level Cache):在应用进程内加一层本地缓存做 L1、分布式缓存做 L2 的架构模式,请求优先命中 L1,miss 才往下走。你可以把它想象成"楼下便利店 + 城郊仓库"——L1 是便利店出门就有,但货架小;L2 是仓库多走几步但什么都有;DB 是工厂,不到万不得已不想去。

二、二级缓存架构长什么样

先看图——一张图把请求怎么在 L1、L2 和 DB 之间流转讲清楚:

核心链路就三步:请求先查 L1(Caffeine),命中直接返回,耗时约 1 微秒;L1 未命中查 L2(Redis),命中则回填 L1 再返回,耗时约 5 毫秒;L1 和 L2 都没命中才查 DB,拿到数据后逐层回填 L2 和 L1。

这里有一个关键设计点:回填是"逐层写回"而不是"跳过 L2 直接写 L1"。因为 L2(Redis)是多个实例共享的,跳过它会导致实例 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 Cache LRU expireAfterWrite 老项目还在用
Caffeine W-TinyLFU write / access / refresh 三种 新项目首选
EHCache LFU / FIFO TTL / TTI 需要持久化时

一个最简的 Caffeine 配置长这样:

// 适用于 Spring Boot 3.x + Caffeine 3.x,2026 年 7 月验证
Cache<String, Object> caffeineCache = Caffeine.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——一个生产可用的实现骨架:

@Component
@Slf4j
public class TwoLevelCacheService {
   

    private final Cache<String, Object> caffeineCache;
    private final StringRedisTemplate redisTemplate;

    public TwoLevelCacheService(StringRedisTemplate redisTemplate) {
   
        this.redisTemplate = redisTemplate;
        this.caffeineCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .refreshAfterWrite(3, TimeUnit.MINUTES)
            .build();
    }

    @SuppressWarnings("unchecked")
    public <T> T get(String key, Class<T> type,
                     Function<String, T> dbLoader, Duration l2Ttl) {
   
        // L1: Caffeine 本地缓存,微秒级
        Object l1Val = caffeineCache.getIfPresent(key);
        if (l1Val != null) {
   
            return (T) l1Val;
        }

        // L2: Redis 分布式缓存,毫秒级
        String l2Val = redisTemplate.opsForValue().get(key);
        if (l2Val != null) {
   
            T result = JsonUtil.parse(l2Val, type);
            caffeineCache.put(key, result);  // 回填 L1
            return result;
        }

        // DB:最慢,兜底
        T dbVal = dbLoader.apply(key);
        if (dbVal != null) {
   
            String json = JsonUtil.toJson(dbVal);
            redisTemplate.opsForValue().set(key, json, l2Ttl);
            caffeineCache.put(key, dbVal);  // 逐层回填
        }
        return dbVal;
    }
}

这段代码的逻辑很直:L1 命中 → 直接返回;L1 miss → 查 L2,命中回填 L1;L2 也 miss → 查 DB,逐层回填 L2 再 L1。 每次回填都是一次"缓存预热",下次同样的 key 再来,数据已经在离请求最近的地方等着了。

五、CompletableFuture 异步刷新的正确姿势

上面的代码能跑,但有一个致命的短板:同步回源。 当 L1 和 L2 都 miss 时,调用线程会被 dbLoader.apply(key) 阻塞——查一次 DB 几十毫秒,如果此时 100 个线程同时 miss 同一个 key,100 个线程都会去查 DB。

缓存击穿(Cache Breakdown):热点 key 过期或冷启动时,瞬间大量并发请求同时穿过缓存层,直接打到数据库。你可以把它想象成"超市开门那一刻所有人同时挤进去,收银台直接崩了"——你需要一个门卫,只放一个人先进去备货。

可能有人会问:加个 synchronized 或者 ReentrantLock 不就行了?

能防住,但锁会串行化所有 miss 请求——第 101 个请求也得排队等前 100 个中的一个拿到锁、查完 DB、释放锁,响应时间直接爆炸。而且锁本身还有死锁风险和线程切换开销。

更好的方案:CompletableFuture 异步刷新 + 单飞锁(Single Flight)。同一个 key 只允许一个线程去查 DB,其他线程等同一个 Future 的结果。

private final ConcurrentHashMap<String, CompletableFuture<Object>>
    refreshTasks = new ConcurrentHashMap<>();
private final Executor refreshExecutor =
    Executors.newFixedThreadPool(8);

@SuppressWarnings("unchecked")
public <T> T getWithAsyncRefresh(String key, Class<T> type,
        Function<String, T> dbLoader, Duration l2Ttl) {
   

    // L1 命中直接返回 —— 大多数请求走这个分支
    Object l1Val = caffeineCache.getIfPresent(key);
    if (l1Val != null) {
   
        return (T) l1Val;
    }

    // L2 命中 → 异步触发 L1 刷新,当前请求直接用 L2 数据返回
    String l2Val = redisTemplate.opsForValue().get(key);
    if (l2Val != null) {
   
        CompletableFuture.runAsync(
            () -> caffeineCache.put(key, JsonUtil.parse(l2Val, type)),
            refreshExecutor);
        return JsonUtil.parse(l2Val, type);
    }

    // L2 也 miss → 单飞锁:同一个 key 只有第一个线程去查 DB
    CompletableFuture<Object> future = refreshTasks.computeIfAbsent(key,
        k -> CompletableFuture.supplyAsync(() -> {
   
            T dbVal = dbLoader.apply(k);
            if (dbVal != null) {
   
                String json = JsonUtil.toJson(dbVal);
                redisTemplate.opsForValue().set(k, json, l2Ttl);
                caffeineCache.put(k, dbVal);
            }
            return dbVal;
        }, refreshExecutor).whenComplete((v, e) -> refreshTasks.remove(k))
    );

    try {
   
        return (T) future.get(3, TimeUnit.SECONDS);
    } catch (TimeoutException e) {
   
        refreshTasks.remove(key);
        throw new CacheException("回源超时", e);
    }
}

CompletableFuture:Java 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 上限
纯 Redis 3.2ms 12.8ms ~28,000
纯 Caffeine 0.008ms 0.02ms ~1,200,000
二级缓存(L1+L2) 0.01ms 5.1ms ~950,000

P99 在二级缓存方案里是 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,别走缓存。

二级缓存的核心价值:用少量的一致性代价,换取读性能的显著提升。 对于读多写少、允许秒级延迟的业务场景(商品详情、内容页、配置数据等),这是性价比最高的二级缓存方案,没有之一。


参考链接

我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你在项目里用的是什么缓存方案,踩过什么坑?

目录
相关文章
|
8天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2259 12
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
8天前
|
云安全 人工智能 安全
|
8天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max-Preview深度全解析:2.4万亿参数旗舰MoE模型+Token Plan限时优惠完整落地指南
2026年7月,全新旗舰级混合专家大模型Qwen3.8-Max-Preview正式开放抢先体验,作为通义千问Qwen3系列规格最高、综合推理能力顶尖的新一代模型,该模型总参数量达到2.4万亿(2.4T),是当前线上可调用的原生多模态旗舰模型,综合推理水准对标海外顶级Fable 5模型,在复杂工程开发、长文档深度分析、多步骤智能体自治、跨境多语言创作、海量数据挖掘五大高难度业务场景实现跨越式性能提升。
1018 2
|
10天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
1018 44
|
8天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
1044 0
|
7天前
|
自然语言处理 测试技术 API
通义千问Qwen3.8-Max-Preview全功能解析:2.4万亿参数旗舰模型深度使用指南
在大模型技术持续迭代的当下,通义千问推出的Qwen3.8-Max-Preview作为新一代旗舰预览版模型,凭借2.4万亿参数的超大规模、多模态融合能力与全场景适配特性,成为开发者与企业用户探索AI应用的核心工具。该模型采用稀疏混合专家(MoE)架构,是通义千问首个突破万亿参数的多模态模型,可同时处理文本、图像、视频与文档等多种数据形态,在全栈代码开发、复杂逻辑推理、长文档分析与多智能体协作等场景实现跨越式升级。本文将全面拆解Qwen3.8-Max-Preview的核心功能,详解API调用流程与配置方法,覆盖多场景实战技巧,帮助用户快速掌握这款旗舰模型的使用方法,充分释放其性能潜力。
501 1
|
10天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
700 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南