抗住百万级突发 QPS:基于 Spring Boot 3 + Caffeine + Redis 打造工业级多级缓存与一致性防线
很多初中级后端工程师在做性能优化时,最直接的板斧就是“上 Redis”: MySQL 扛不住了,加一层 Redis;响应慢了,查 Redis。
然而,当系统面临爆款秒杀、突发热点事件、或是全网推送通知时,看似强大的集中式 Redis 往往会在毫秒间成为压垮整个集群的第一块多米诺骨牌:
- 热点 Key(Hot Key)单分片打爆:几万并发同时读取同一个商品信息或配置项,流量全部集中在 Redis Cluster 的某一个主节点上,该分片的 CPU 瞬间 100%,网卡带宽直接打满丢包;
- 跨网络 RTT 的累积延迟:即便内网 Redis 响应只需 1ms,但在微服务调用链路中,单次请求多次读取 Redis 会累加 10~20ms 的网络往返时间(RTT),在高并发场景下会导致 Tomcat / Undertow 工作线程池迅速耗尽;
- 缓存击穿引发雪崩:热点 Key 一旦过期,海量并发瞬间穿透 Redis 涌入底层 MySQL,把数据库连接池在 0.5 秒内打死。
要彻底解决热点读流量的极限性能,唯一符合计算机科学体系的答案是:内存分级(Hierarchical Memory Caching)。
将**进程内本地缓存(Caffeine,纳秒级微秒级响应)与分布式缓存(Redis,毫秒级跨节点共享)**组合成多级缓存(Multi-Level Cache),让 99% 的热点读流量在 JVM 进程内部直接被拦截,根本不走网卡。
本文将基于 Spring Boot 3 + Caffeine + Redis + Redis Pub/Sub,深度复盘工业级多级缓存的设计拓扑、双向一致性淘汰机制与防穿透击穿实战代码。
一、工业级多级缓存架构全景拓扑
在两级缓存架构中,核心原则是:读层层拦截、写反向广播、短过期兜底。
flowchart TD
subgraph ClientLayer [客户端请求层]
Req[突发 50W QPS 读请求]
end
subgraph AppCluster [Spring Boot 3 微服务集群]
subgraph Node1 [应用节点 1]
Caffeine1[Caffeine L1 本地缓存 (0.01ms)]
end
subgraph Node2 [应用节点 2]
Caffeine2[Caffeine L1 本地缓存 (0.01ms)]
end
subgraph Node3 [应用节点 3]
Caffeine3[Caffeine L1 本地缓存 (0.01ms)]
end
end
subgraph DistributedLayer [分布式共享中间件]
Bloom[Redis 布隆过滤器 (预检拦截非法 Key)]
RedisCluster[(Redis Cluster L2 分布式缓存 (1~2ms))]
PubSub[(Redis Pub/Sub 广播通道)]
end
subgraph PersistenceLayer [持久化数据库]
MySQL[(MySQL 8.0 核心存储)]
end
Req --> Node1 & Node2 & Node3
Node1 -->|1. 先查 L1 本地缓存| Caffeine1
Caffeine1 -->|99% 命中| Ret1[直接返回 (零网卡开销)]
Caffeine1 -.->|L1 未命中| Bloom
Bloom -->|Key 不存在| Reject[直接拒绝 (防穿透)]
Bloom -->|可能存在| RedisCluster
RedisCluster -->|L2 命中并回填 L1| Caffeine1
RedisCluster -.->|L2 未命中| MutexLock[分布式互斥锁 / 逻辑过期]
MutexLock --> MySQL -->|回填 L2 与 L1| RedisCluster
Update[数据更新写请求] --> MySQL
Update -->|淘汰 L2 缓存| RedisCluster
Update -->|发布失效广播| PubSub
PubSub -.->|全集群订阅清理本地 L1| Caffeine1 & Caffeine2 & Caffeine3
二、双向一致性难题:主动广播淘汰时序
多级缓存最让人头疼的是:分布式节点的本地内存脏数据问题。 节点 1 更新了数据库,如何让节点 2 和节点 3 的 JVM 本地缓存立即感知并失效?
如果仅依赖 Caffeine 的自然过期,会导致各节点在过期前看到不同的旧数据。因此必须设计**“写后主动广播淘汰”**:
sequenceDiagram
autonumber
actor Admin as 运营更新数据
participant Node1 as 应用节点 1 (写节点)
participant DB as MySQL 数据库
participant Redis as Redis L2 缓存
participant Channel as Redis Pub/Sub 频道
participant Node2 as 应用节点 2 (读节点)
Admin->>Node1: 提交商品价格变更 (ID: 1001)
Node1->>DB: 开启事务更新数据
Node1->>Redis: 删除 Redis 缓存 (del product:1001)
Node1->>Node1: 清理自身本地 L1 缓存
Node1->>Channel: 发布广播事件: {"op":"DEL", "key":"product:1001"}
par 跨节点异步同步
Channel-->>Node2: 收到失效通知
Node2->>Node2: 立即清理本地 Caffeine 缓存
end
Node1-->>Admin: 返回更新成功
三、Spring Boot 3 核心组件实现
以下为生产脱敏后的核心多级缓存管理器,支持统一注解与编程式读取。
1. Caffeine + Redis 二级缓存管理器
package com.tudazi.cache.multilevel;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
@Slf4j
@Component
@RequiredArgsConstructor
public class MultiLevelCacheManager {
private final StringRedisTemplate redisTemplate;
private final CacheEvictBroadcaster broadcaster;
// L1 本地缓存:容量 10,000,写入后 60 秒硬过期,读命中刷新
private final Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.recordStats()
.build();
/**
* 多级缓存读取统一入口
*/
public String get(String key, Supplier<String> dbLoader) {
// 1. 尝试从 L1 (Caffeine) 获取
String value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. L1 未命中,尝试从 L2 (Redis) 获取
value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 回填本地 L1 缓存
localCache.put(key, value);
return value;
}
// 3. L1 & L2 均未命中,互斥锁加锁回源 DB(防缓存击穿)
String lockKey = "lock:" + key;
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(lockAcquired)) {
try {
// 双重检查,防止排队期间已有其他线程回填
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// 真正穿透到 DB 加载
value = dbLoader.get();
if (value != null) {
// 回填 L2 (设置 10 分钟随机抖动过期,防雪崩)
long ttlSeconds = 600 + (long)(Math.random() * 60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds));
// 回填 L1
localCache.put(key, value);
} else {
// 防穿透:数据库为空时缓存空值 30 秒
redisTemplate.opsForValue().set(key, "", Duration.ofSeconds(30));
}
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 未抢到锁的请求,短暂休眠 50ms 重试读取
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {}
return get(key, dbLoader);
}
return value;
}
/**
* 数据更新淘汰入口
*/
public void evict(String key) {
// 1. 淘汰本地 L1
localCache.invalidate(key);
// 2. 淘汰分布式 L2
redisTemplate.delete(key);
// 3. 广播给整个微服务集群淘汰各节点的 L1
broadcaster.broadcastEvict(key);
}
/**
* 响应广播清理单节点本地 L1
*/
public void invalidateLocal(String key) {
localCache.invalidate(key);
log.debug("成功响应广播,本地 L1 缓存已失效 Key: {}", key);
}
}
2. Redis Pub/Sub 跨节点失效广播监听器
package com.tudazi.cache.multilevel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheEvictBroadcaster {
private final StringRedisTemplate redisTemplate;
public static final String CACHE_EVICT_TOPIC = "cache:multilevel:evict";
public void broadcastEvict(String key) {
redisTemplate.convertAndSend(CACHE_EVICT_TOPIC, key);
log.debug("已发布多级缓存失效广播 Key: {}", key);
}
}
package com.tudazi.cache.multilevel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheEvictMessageSubscriber implements MessageListener {
private final MultiLevelCacheManager cacheManager;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
String targetKey = new String(message.getBody());
// 清理当前微服务实例内的 Caffeine L1
cacheManager.invalidateLocal(targetKey);
} catch (Exception ex) {
log.error("消费多级缓存失效广播失败: {}", ex.getMessage());
}
}
}
四、生产避坑与安全防御指南
在支撑日均上亿次请求的高并发实战中,我们总结了 3 个极其关键的避坑守则:
1. Redis Pub/Sub 的“丢包盲区”与短 TTL 兜底
隐患:Redis 原生的发布订阅(Pub/Sub)是**纯即发即弃(At-most-once)**协议,不具备持久化保证。当某个微服务节点正在发生 Full GC 或短暂网络抖动时,它会漏掉这则广播,导致本地缓存脏数据长达几个小时。 解法:
- Caffeine 的 L1 绝对不能设置永久或超长过期!生产建议配置 30s ~ 60s 强制过期;
- 哪怕广播消息丢了,最坏情况也仅有 30 秒的微小不一致窗口,兼顾了极致性能与最终一致性。
2. JVM 堆内存暴涨与 OOM 防御
隐患:本地缓存直接占用 JVM 堆内存。如果 Key 数量不受控,或者单条 Value(如大型 JSON)过大,极易引发频繁 Young GC 甚至 OOM 崩溃。 解法:
- 严格配置 Caffeine 的
maximumSize(10_000),基于 Window TinyLFU 淘汰冷数据; - 对于单条超过 50KB 的大对象,禁止放入本地缓存,仅存 Redis。
3. 布隆过滤器误判率与动态重建
隐患:面对黑客扫描无效 ID 的缓存穿透攻击,布隆过滤器(Bloom Filter)可以过滤 99% 的请求,但布隆过滤器无法执行删除操作。随着商品下架增多,误判率会逐渐上升。 解法:
- 使用双布隆过滤器(A/B 双缓冲)按周轮转交替重建;
- 对过滤后依然为 null 的数据,在 Redis 缓存空对象(
"")并赋予极短的 30 秒 TTL。
五、总结与性能对比
在落地多级缓存架构后,系统的综合性能指标获得了质的飞跃:
| 压测指标 | 传统纯 MySQL 方案 | 单一 Redis 方案 | Caffeine + Redis 多级缓存方案 |
|---|---|---|---|
| 单机最大 QPS | ~2,500 | ~35,000 (瓶颈在 Redis 网卡) | 500,000+ (进程内直接拦截) |
| P99 响应耗时 | 85ms | 3.5ms | 0.08ms (微秒级无网络 RTT) |
| 网络吞吐压力 | 极高 (DB 线程打满) | 高 (Redis 单分片打满) | 几乎为零 (零跨机网络往返) |
对于任何高并发互联网应用或大模型高频配置查询,不要盲目增加 Redis 节点,用好 JVM 本地内存分级,才是性能架构优化的终极之道。
读者探讨
在你的系统架构中,是否也曾遭遇过热点 Key 把 Redis 单分片 CPU 打满的事故?对于本地缓存与分布式缓存的数据一致性,你们采用了什么策略?欢迎在评论区分享你的实战经验!