跳到主要内容

多级缓存架构

问题

什么是多级缓存?如何设计多级缓存架构?

答案

多级缓存是指在请求链路上设置多层缓存,离用户越近的缓存越快,离数据库越远的缓存容量越大。每一层缓存都作为下一层的保护屏障。

一、多级缓存架构

层级技术延迟容量适合数据
L0: 浏览器缓存HTTP Cache0ms静态资源
L1: 本地缓存Caffeine/Guava<1ms热点不常变数据
L2: 分布式缓存Redis/Memcached1~5ms通用缓存
L3: 数据库MySQL/PostgreSQL10~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();
}
}

本地缓存对比

特性CaffeineGuava CacheEhCache
性能⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
淘汰算法W-TinyLFULRULRU/LFU/FIFO
异步加载
持久化
Spring 集成✅(默认)
W-TinyLFU

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 Redis5~60 分钟作为主要缓存层,TTL 适中

L1 TTL 短是因为本地缓存跨节点不一致,需要尽快过期重新从 Redis 加载。

Q2: 多级缓存如何保证数据一致性?

答案

  1. 写操作:先更新 DB → 删除 Redis → 广播删除所有节点的本地缓存
  2. L1 一致性:通过 Redis Pub/Sub 或 MQ 广播通知其他节点清除本地缓存
  3. 兜底机制:本地缓存设置短 TTL(5~60s),即使广播消息丢失,最多不一致 TTL 时间
  4. 最终底线:Redis 的 TTL 作为最终兜底

Q3: 本地缓存用 HashMap 行不行?

答案

不推荐,存在三个问题:

  1. 无淘汰策略:HashMap 没有 LRU/LFU,可能导致 OOM
  2. 无过时机制:数据永远不过期,一致性问题严重
  3. 无并发控制HashMap 非线程安全,ConcurrentHashMap 虽然线程安全但仍缺失淘汰和过期

应使用 Caffeine 或 Guava Cache,它们提供了容量限制、过期策略、统计信息等完善的缓存功能。

Q4: Caffeine 的 W-TinyLFU 为什么比 LRU 好?

答案

LRU 的问题是偶发访问会挤掉高频数据。例如全表扫描会把大量冷数据加入缓存,导致真正的热点数据被淘汰。

W-TinyLFU 的改进:

  1. 新数据先进入 Window 区(类似 LRU,占 1%)
  2. 从 Window 淘汰的数据,与 Main 区(占 99%)的候选数据做频率对比
  3. 频率更高的留下,更低的淘汰

这样既保留了 LRU 对新数据的快速响应,又有 LFU 对频率的判断能力。

Q5: 多级缓存架构的缺点是什么?

答案

  1. 复杂度增加:需要管理多层缓存的读写、失效、监控
  2. 一致性更难保证:层级越多,数据不一致的窗口和概率越大
  3. 排查困难:出现数据问题时需要逐层排查
  4. 内存消耗:本地缓存占用 JVM 堆内存

建议只在真正需要极致性能的场景(高并发读、Redis 流量大)才引入多级缓存。普通场景 Redis 单级缓存 + TTL 已经足够。

相关链接