本地缓存 + Redis 二级缓存架构
问题:为什么单层 Redis 缓存扛不住?
先算一笔账。一个商品详情页 QPS 10 万,全部走 Redis 读取:
- 单次网络往返:服务→Redis 平均 0.5ms(同机房)~ 2ms(跨可用区)
- Redis 单线程处理:10 万 QPS 下,单个热 Key 的请求排队,响应时间从 0.5ms 涨到 50ms+
- 服务端线程阻塞:Tomcat 200 线程池,每个线程等 50ms,很快就满了,新请求排队等线程
生产事故案例:某电商大促期间,商品详情页 Redis 热 Key(某个爆款 SKU)被 300+ 线程同时请求,Redis 单线程处理,P99 延迟从 2ms 飙升到 800ms,服务端线程池打满,连非热 Key 的请求也拿不到线程,最终导致整个商品服务雪崩。
二级缓存架构就是要在服务进程内把热数据先挡一层:本地缓存(L1,进程内缓存) + Redis 缓存(L2,分布式缓存)。读请求优先查本地,命中直接返回,0 网络开销;未命中再查 Redis 并回填本地。
核心流程:读请求的完整路径
时序图(文字版)
Client → Service Thread
│
├─ 1. localCache.get(key) ──→ 命中 → 返回(0ms 延迟)
│
└─ 未命中 →
├─ 2. redisCache.get(key) ──→ 命中 →
│ └─ 回填 localCache(设置 TTL 5min)
│ └─ 返回(0.5~2ms 延迟)
│
└─ 未命中 →
├─ 3. DB.query(key)(5~20ms 延迟)
├─ 4. 回填 redisCache(TTL 1h)
└─ 5. 回填 localCache(TTL 5min)
└─ 返回代码实现
public class MultiLevelCache {
// Caffeine 本地缓存,配置见下方
private final Cache<String, Object> localCache;
private final RedisTemplate<String, Object> redisCache;
public Object get(String key) {
// 1. 查本地缓存——零网络开销
Object val = localCache.getIfPresent(key);
if (val != null) return val;
// 2. 查 Redis——一次网络往返
val = redisCache.opsForValue().get(key);
if (val != null) {
// 回填本地缓存,设置短过期时间兜底
localCache.put(key, val);
return val;
}
// 3. 查数据库——最慢的一步,加互斥锁防止击穿
synchronized (key.intern()) {
// 双重检查:第一个线程查完 DB 回填后,第二个线程直接拿本地缓存
val = localCache.getIfPresent(key);
if (val != null) return val;
val = queryDB(key);
redisCache.opsForValue().set(key, val, 1, TimeUnit.HOURS);
localCache.put(key, val);
}
return val;
}
}关键细节:synchronized (key.intern()) 做互斥锁,防止同一时刻多个线程同时查 DB。但注意 String.intern() 在大量不同 key 时会有性能问题,生产上可以用 StripedLock 替代。
Caffeine 配置:为什么选 W-TinyLFU?
Caffeine 内部使用 W-TinyLFU 淘汰算法,比 LRU 好在哪?
| 指标 | LRU | W-TinyLFU |
|---|---|---|
| 突发流量 | 大量新 key 涌入会淘汰老热点 | 频率窗口过滤,突发 key 不会冲掉热 key |
| 扫描抵抗 | 全表扫描时缓存被污染 | 频率统计 + 衰减,扫描数据很快被淘汰 |
| 命中率 | 随机访问场景下降 | 维持 90%+ 命中率 |
| 内存开销 | 无额外开销 | 额外 10KB 左右频率布隆 |
生产配置示例:
Cache<String, Object> localCache = Caffeine.newBuilder()
.initialCapacity(5000) // 预分配,减少扩容
.maximumSize(10_000) // 最多 1 万条,按业务估算
.expireAfterWrite(5, TimeUnit.MINUTES) // 写后 5 分钟过期——一致性兜底
.recordStats() // 开启命中率统计
.build();- initialCapacity:预分配多少槽位,减少扩容开销。按业务预估:比如商品详情最多 5000 个 SKU,设 5000。
- maximumSize:限制最大条目数,防止 OOM。1 万条对象 ≈ 几十 MB,合理。
- expireAfterWrite:最关键的一致性兜底——即使广播失效没收到,最多 5 分钟自动过期。
- recordStats():监控命中率,线上可以配 Grafana 面板看本地缓存命中率,低于 80% 说明配置不合理。
一致性问题:多实例部署,本地缓存怎么同步?
多实例部署下,写操作的流程是:更新数据库 → 删除/更新 Redis 缓存。但本地缓存呢?A 实例更新了数据,B 实例的本地缓存还是旧值。
三种方案,从简单到可靠:
方案一:短过期兜底(默认方案,推荐)
本地缓存 TTL 设 3~5 分钟,脏数据最多存活 5 分钟。适合大部分业务场景:商品详情、文章内容、用户信息,5 分钟不一致完全能接受。
优点:零额外代码,零额外基础设施。 缺点:一致性延迟 5 分钟,实时性要求高的场景不行。
方案二:Redis Pub/Sub 广播失效(中等方案)
// 写操作之后,发布失效消息
public void updateData(String key, Object newVal) {
db.update(key, newVal);
redisCache.delete(key);
// 广播本地缓存失效
redisTemplate.convertAndSend("cache:invalidate", key);
}
// 所有实例启动时订阅
@PostConstruct
public void subscribeInvalidation() {
redisTemplate.getConnectionFactory().getConnection()
.subscribe(new MessageListener() {
@Override
public void onMessage(Message msg, byte[] pattern) {
String key = new String(msg.getBody());
log.info("收到失效通知,清除本地缓存: {}", key);
localCache.invalidate(key);
}
}, "cache:invalidate".getBytes());
}注意坑:
- Redis Pub/Sub 是即发即忘,不持久化。如果订阅者当时不在线,消息就丢了。
- 网络抖动会导致订阅临时断开,重连后中间的消息全丢。
- 生产上**必须配合方案一(短过期兜底)**一起用,Pub/Sub 作为辅助,不能依赖它做唯一保证。
方案三:MQ 广播(最可靠)
用 RocketMQ/Kafka 广播模式,每个实例消费一条失效消息:
// 生产者
rocketMQTemplate.syncSend("cache-invalidate-topic",
MessageBuilder.withPayload(key).build());
// 消费者——每个实例都会消费
@RocketMQMessageListener(topic = "cache-invalidate-topic",
consumerGroup = "cache-invalidate-group",
messageModel = MessageModel.BROADCASTING)
public class CacheInvalidateConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String key) {
localCache.invalidate(key);
}
}对比:
| 方案 | 一致性延迟 | 消息可靠性 | 额外依赖 | 推荐场景 |
|---|---|---|---|---|
| 短过期 | 3~5min | 无(靠 TTL) | 无 | 默认首选 |
| Pub/Sub | 毫秒级 | 低(丢消息不持久化) | Redis本身 | 辅助手段 |
| MQ广播 | 毫秒级 | 高(持久化+重试) | RocketMQ/Kafka | 对一致性要求高 |
有一条原则记牢:强一致性场景不做二级缓存。订单状态、库存扣减、余额变更——这些直接走 Redis + DB,不走本地缓存。二级缓存只适合「最终一致性」的业务。
缓存击穿在二级缓存下的表现
单层 Redis 缓存击穿(热 Key 过期瞬间,大量并发请求打穿到 DB)在二级缓存下反而被缓解了——因为本地缓存还在。
但极端情况:所有实例的本地缓存同时过期,所有请求同时打到 Redis,Redis 再一起打到 DB。
如何避免:
本地缓存加随机过期时间:
expireAfterWrite(3, 7, TimeUnit.MINUTES)随机 3~7 分钟,各实例过期时间错开。refreshAfterWrite:在过期前异步刷新,不是等过期了再同步加载:
LoadingCache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES) // 1 分钟后开始异步刷新
.build(key -> loadFromDB(key)); // 查不到时调用refreshAfterWrite 和 expireAfterWrite 配合:前者在过期前做异步刷新,后者做兜底过期。如果刷新失败,数据还能用(还没过期)。
- 布隆过滤器:查询前先用 BloomFilter 判断 key 是否存在,不存在直接返回 null,不查 Redis 和 DB。J2Cache 内置了这个。
热 Key 场景:本地缓存收益最大化的地方
极端案例:微博热搜第一条,每秒 50 万次查询。如果全部走 Redis,Redis 节点带宽打满(50万 × 1KB = 500MB/s),CPU 单线程 100%,响应时间 200ms+。
本地缓存解法:100 个实例各存一份,读取无网络开销。每个实例只需要处理 5000 QPS,完全没问题。
热 Key 检测 + 主动刷新:
@Component
public class HotKeyManager {
private final Cache<String, Object> localCache;
private final ConcurrentHashMap<String, AtomicLong> accessCount = new ConcurrentHashMap<>();
// 每个请求进来,计数+1
public Object get(String key) {
accessCount.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
return localCache.get(key, k -> loadFromRedis(key));
}
// 定时任务:每 10 秒检测热 Key
@Scheduled(fixedRate = 10_000)
public void detectAndRefresh() {
accessCount.entrySet().stream()
.filter(e -> e.getValue().get() > 1000) // 10 秒内超过 1000 次访问
.forEach(e -> {
// 热 Key 永不过期 + 主动刷新
Object val = redisCache.opsForValue().get(e.getKey());
if (val != null) {
localCache.put(e.getKey(), val);
}
log.info("热 Key 刷新: {}, 10s 内访问 {} 次", e.getKey(), e.getValue().get());
});
accessCount.clear();
}
}生产注意:accessCount 是 ConcurrentHashMap,高并发下 computeIfAbsent 可能成为瓶颈。可以用 LongAdder + 分段数组替代,或者直接用 Redis 的 hotkeys 命令辅助检测。
开源方案对比:J2Cache vs JetCache
| 特性 | J2Cache | JetCache(阿里巴巴) |
|---|---|---|
| L1 实现 | Caffeine / Ehcache | Caffeine / LinkedHashMap |
| L2 实现 | Redis / Memcached | Redis / Tair |
| 缓存穿透防护 | 内置布隆过滤器 | 无原生支持 |
| 广播机制 | Redis Pub/Sub | TTL 兜底 + 可选广播 |
| 消息可靠性 | 低(不持久化) | 中(可配 MQ) |
| 注解支持 | @Cacheable 风格 | @Cached 多级注解 |
| 监控 | 无 | 集成 Micrometer |
| 学习成本 | 低,半小时上手 | 中,需理解注解体系 |
| 适用场景 | 中小型项目快速集成 | 大型项目灵活配置与监控 |
选型建议:
- 项目小、不想引太多依赖 → J2Cache,内置 Bloom Filter 是加分项
- 已经用了 Spring Boot + AliCloud → JetCache,注解开发体验好
- 两者核心思路一致:消息通知 + 短过期兜底
面试常见追问
Q:本地缓存 OOM 怎么办? A:Caffeine 的 maximumSize 限制条目数,配合 weigher 控制总内存大小。另外监控 localCache.stats().evictionCount() 看淘汰率,如果频繁淘汰说明容量不够。
Q:多级缓存下的数据一致性如何测试? A:写一个测试:A 实例写数据,B 实例读数据,验证一段时间后 B 实例读到新值。配合 chaos engineering:杀掉某个实例的 Pub/Sub 订阅,验证 TTL 兜底生效。
Q:为什么不直接用 Caffeine 的 loadingCache 做自动刷新? A:loadingCache 的 refreshAfterWrite 只在访问时触发刷新。如果某个 key 长时间没人访问,数据不会自动刷新。所以 refreshAfterWrite 必须配合 expireAfterWrite 使用——前者做访问时预刷新,后者做兜底过期。
总结
- 二级缓存 = Caffeine(L1)+ Redis(L2),读请求优先本地,零网络延迟
- 一致性兜底:短过期时间(3~5 分钟)是默认方案,Pub/Sub 或 MQ 广播作为辅助
- 热 Key 收益最大:本地缓存天然扛热点,几十个实例各存一份,Redis 压力骤降
- 强一致性场景不做二级缓存:订单、库存、余额,直连 Redis + DB
- Caffeine 选 W-TinyLFU 不选 LRU:扫描抵抗好,热点维持高
- 开源方案:J2Cache 适合快速集成,JetCache 适合大型系统
面试话术:『先画一个 Caffeine + Redis 的读写流程图,然后说一致性方案——短过期兜底是默认的,实时性要求高的场景用 Redis Pub/Sub 广播失效,但必须配合 MQ 做可靠补偿。最后强调:强一致性数据不放进二级缓存,这是架构取舍。』
参考
Caffeine GitHub Wiki(expireAfterWrite / refreshAfterWrite);J2Cache 源码(oschina/j2cache);JetCache 文档(alibaba/jetcache);Redis Pub/Sub 官方文档