大家好,我是晚安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,别走缓存。
二级缓存的核心价值:用少量的一致性代价,换取读性能的显著提升。 对于读多写少、允许秒级延迟的业务场景(商品详情、内容页、配置数据等),这是性价比最高的二级缓存方案,没有之一。
参考链接
- Caffeine 官方 Wiki(搜:Caffeine cache github)
- Spring Boot Cache 官方文档(搜:Spring Boot caching docs)
我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你在项目里用的是什么缓存方案,踩过什么坑?