多级缓存架构
问题
什么是多级缓存?如何设计多级缓存架构?
答案
多级缓存是指在请求链路上设置多层缓存,离用户越近的缓存越快,离数据库越远的缓存容量越大。每一层缓存都作为下一层的保护屏障。
一、多级缓存架构
| 层级 | 技术 | 延迟 | 容量 | 适合数据 |
|---|---|---|---|---|
| L0: 浏览器缓存 | HTTP Cache | 0ms | 小 | 静态资源 |
| L1: 本地缓存 | Caffeine/Guava | <1ms | 中 | 热点不常变数据 |
| L2: 分布式缓存 | Redis/Memcached | 1~5ms | 大 | 通用缓存 |
| L3: 数据库 | MySQL/PostgreSQL | 10~100ms | 极大 | 持久化数据 |
多级缓存的价值不仅是提升性能,更重要的是提高可用性:即使 Redis 宕机,L1 本地缓存仍能提供服务,避免所有请求打到数据库。
二、本地缓存选型
Caffeine(推荐)
Caffeine 是 Java 中性能最好的本地缓存库,被 Spring Boot 默认集成。
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000) // 最大条目数
.expireAfterWrite(5, TimeUnit.MINUTES) // 写后过期
.recordStats() // 开启统计
.build();
}
}
本地缓存对比
| 特性 | Caffeine | Guava Cache | EhCache |
|---|---|---|---|
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 淘汰算法 | W-TinyLFU | LRU | LRU/LFU/FIFO |
| 异步加载 | ✅ | ✅ | ✅ |
| 持久化 | ❌ | ❌ | ✅ |
| Spring 集成 | ✅(默认) | ✅ | ✅ |
Caffeine 使用 W-TinyLFU(Window TinyLFU)淘汰算法,综合了 LRU 和 LFU 的优势:
- Window Cache:新数据先进入 Window 区(LRU),防止频繁访问的新数据被立即淘汰
- Main Cache:通过频率过滤(TinyLFU)决定是否晋升到 Main 区
- 命中率显著高于纯 LRU 或纯 LFU
三、多级缓存读写流程
读流程
写流程
完整实现
@Service
public class MultiLevelCacheService {
@Autowired
private Cache<String, Object> localCache; // L1: Caffeine
@Autowired
private RedisTemplate<String, Object> redis; // L2: Redis
@Autowired
private UserMapper userMapper; // L3: MySQL
/**
* 多级缓存读取
*/
public Object get(String key) {
// 1. 查 L1 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 查 L2 Redis
value = redis.opsForValue().get(key);
if (value != null) {
// 回填 L1
localCache.put(key, value);
return value;
}
// 3. 查数据库
value = userMapper.selectById(extractId(key));
if (value != null) {
// 回填 L2 + L1
redis.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
localCache.put(key, value);
}
return value;
}
/**
* 多级缓存写入:先更新 DB,再逐级删除缓存
*/
@Transactional
public void update(String key, Object newValue) {
// 1. 更新数据库
userMapper.updateById(newValue);
// 2. 删除 L2 Redis
redis.delete(key);
// 3. 删除 L1 本地缓存
localCache.invalidate(key);
// 4. 通知其他节点删除 L1(见下文)
publishCacheInvalidation(key);
}
}
四、本地缓存一致性问题
多实例部署时,每个 JVM 有独立的本地缓存。数据更新后,只有当前节点清除了本地缓存,其他节点仍持有旧数据。
解决方案
方案一:Redis Pub/Sub 广播
@Component
public class CacheInvalidationBroadcast {
@Autowired
private RedisTemplate<String, String> redis;
@Autowired
private Cache<String, Object> localCache;
private static final String CHANNEL = "cache:invalidation";
// 发布缓存失效消息
public void publishInvalidation(String key) {
redis.convertAndSend(CHANNEL, key);
}
// 订阅缓存失效消息
@Bean
public MessageListenerAdapter listenerAdapter() {
return new MessageListenerAdapter((MessageListener) (message, pattern) -> {
String key = new String(message.getBody());
// 收到消息后清除本地缓存
localCache.invalidate(key);
});
}
}
方案二:RocketMQ / Kafka 广播
适合大规模集群,消息可靠性更高:
@RocketMQMessageListener(
topic = "cache-invalidation",
consumerGroup = "${spring.application.name}",
messageModel = MessageModel.BROADCASTING // 广播模式:每个节点都消费
)
public class CacheInvalidationConsumer implements RocketMQListener<String> {
@Autowired
private Cache<String, Object> localCache;
@Override
public void onMessage(String key) {
localCache.invalidate(key);
}
}
方案三:短 TTL 容忍不一致
如果业务能容忍几秒的不一致,本地缓存设置较短的 TTL(如 5~10 秒)即可:
Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.SECONDS) // 5秒过期,最多不一致5秒
.build();
| 方案 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|
| Redis Pub/Sub | 较好 | 中 | 中小集群 |
| MQ 广播 | 好 | 高 | 大规模集群 |
| 短 TTL | 弱 | 低 | 容忍秒级不一致 |
五、缓存预热
缓存预热是在服务启动或缓存失效后,提前加载热点数据到缓存,避免冷启动时大量请求穿透。
@Component
public class CacheWarmer implements ApplicationRunner {
@Autowired
private RedisTemplate<String, Object> redis;
@Autowired
private ProductMapper productMapper;
@Override
public void run(ApplicationArguments args) {
// 应用启动后自动预热 Top 1000 热点商品
List<Product> hotProducts = productMapper.selectTopProducts(1000);
hotProducts.forEach(p -> {
String key = "product:" + p.getId();
redis.opsForValue().set(key, p, 30, TimeUnit.MINUTES);
});
log.info("缓存预热完成,共加载 {} 条数据", hotProducts.size());
}
}
六、多级缓存框架
JetCache(阿里开源)
JetCache 原生支持多级缓存,注解驱动:
@Cached(
name = "user:",
cacheType = CacheType.BOTH, // 本地 + 远程 二级缓存
localExpire = 60, // 本地缓存 60 秒
expire = 3600 // 远程缓存 1 小时
)
public User getUser(Long id) {
return userMapper.selectById(id);
}
@CacheInvalidate(name = "user:", key = "#user.id")
public void updateUser(User user) {
userMapper.updateById(user);
}
常见面试问题
Q1: 多级缓存中各层的 TTL 如何设置?
答案:
原则:越靠近用户的缓存 TTL 越短,越靠近数据库的缓存 TTL 越长。
| 层级 | TTL | 理由 |
|---|---|---|
| L1 本地缓存 | 5~60 秒 | 数据量有限,需要尽快与 Redis 同步 |
| L2 Redis | 5~60 分钟 | 作为主要缓存层,TTL 适中 |
L1 TTL 短是因为本地缓存跨节点不一致,需要尽快过期重新从 Redis 加载。
Q2: 多级缓存如何保证数据一致性?
答案:
- 写操作:先更新 DB → 删除 Redis → 广播删除所有节点的本地缓存
- L1 一致性:通过 Redis Pub/Sub 或 MQ 广播通知其他节点清除本地缓存
- 兜底机制:本地缓存设置短 TTL(5~60s),即使广播消息丢失,最多不一致 TTL 时间
- 最终底线:Redis 的 TTL 作为最终兜底
Q3: 本地缓存用 HashMap 行不行?
答案:
不推荐,存在三个问题:
- 无淘汰策略:HashMap 没有 LRU/LFU,可能导致 OOM
- 无过时机制:数据永远不过期,一致性问题严重
- 无并发控制:
HashMap非线程安全,ConcurrentHashMap虽然线程安全但仍缺失淘汰和过期
应使用 Caffeine 或 Guava Cache,它们提供了容量限制、过期策略、统计信息等完善的缓存功能。
Q4: Caffeine 的 W-TinyLFU 为什么比 LRU 好?
答案:
LRU 的问题是偶发访问会挤掉高频数据。例如全表扫描会把大量冷数据加入缓存,导致真正的热点数据被淘汰。
W-TinyLFU 的改进:
- 新数据先进入 Window 区(类似 LRU,占 1%)
- 从 Window 淘汰的数据,与 Main 区(占 99%)的候选数据做频率对比
- 频率更高的留下,更低的淘汰
这样既保留了 LRU 对新数据的快速响应,又有 LFU 对频率的判断能力。
Q5: 多级缓存架构的缺点是什么?
答案:
- 复杂度增加:需要管理多层缓存的读写、失效、监控
- 一致性更难保证:层级越多,数据不一致的窗口和概率越大
- 排查困难:出现数据问题时需要逐层排查
- 内存消耗:本地缓存占用 JVM 堆内存
建议只在真正需要极致性能的场景(高并发读、Redis 流量大)才引入多级缓存。普通场景 Redis 单级缓存 + TTL 已经足够。