分布式缓存架构设计——多级缓存一致性方案与缓存击穿防护
分布式缓存架构设计——多级缓存一致性方案与缓存击穿防护
一、缓存架构的核心矛盾
在分布式系统中,缓存的引入本质上是在解决一个矛盾:数据的访问速度与存储容量/成本之间的权衡。内存的读写速度是磁盘的数千倍,但单位成本也高出数十倍。缓存的使命就是以有限的昂贵存储,换取绝大部分读请求的低延迟响应。
但缓存一旦引入,就带来了新的问题:数据一致性如何保证?缓存不可用时系统如何降级?热点数据如何防护?这些问题构成了分布式缓存架构设计的主线。我从生产实践中总结出,一个好的缓存架构需要回答三个问题:数据怎么放、数据怎么一致、数据怎么保护。
二、多级缓存架构模型
单级缓存(如直接使用 Redis)在中小规模系统中表现良好,但在高并发场景下存在明显的短板:Redis 本身成为瓶颈、网络 RTT 开销无法忽略、Redis 故障时全量请求穿透到数据库。多级缓存通过在应用层和远程缓存层之间引入本地缓存层,可以有效缓解这些问题。
graph TB subgraph App[应用实例] L1[L1: 本地缓存<br/>Caffeine / Guava Cache] AppLogic[业务逻辑] end subgraph Remote[远程缓存集群] R1[Redis 主节点] R2[Redis 从节点-1] R3[Redis 从节点-2] end subgraph Storage[持久化存储] DB[(MySQL 主库)] DB2[(MySQL 从库)] end AppLogic -->|1. 查询| L1 L1 -->|未命中| R1 R1 -->|未命中| DB DB -->|回写| R1 R1 -->|回写| L1 R2 -->|主从同步| R1 R3 -->|主从同步| R1 DB -->|主从同步| DB2 subgraph Notify[一致性通知] MQ[消息队列] end AppLogic -->|数据变更通知| MQ MQ -->|监听变更| AppL1 本地缓存使用 Caffeine 或 Guava Cache,容量通常控制在 100MB~1GB 范围,存储热点数据。本地缓存的访问延迟在微秒级,远低于 Redis 的毫秒级网络延迟。但本地缓存最大的挑战是一致性——当某个应用实例更新了数据,其他实例的本地缓存如何感知?
L2 远程缓存使用 Redis 集群,提供统一的缓存视图。所有实例共享同一份远程缓存,不存在本地缓存的一致性分裂问题。但网络开销和 Redis 自身的性能瓶颈使其不适合超高并发场景。
三、缓存一致性方案:从理论到实践
缓存一致性是分布式系统中最棘手的问题之一。经典的 Cache-Aside 模式(旁路缓存)是使用最广泛的方案,但它在高并发场景下存在两个缺陷:
缺陷一:先删缓存再更新数据库可能导致不一致。当请求 A 删除缓存后,在更新数据库之前,请求 B 读取到旧数据并写回缓存,导致缓存中是旧数据。
缺陷二:先更新数据库再删缓存同样有风险。当请求 A 更新数据库后,在删除缓存之前,请求 B 读取到的是更新前的数据。
推荐的实践方案是延迟双删策略:
- 先删除缓存
- 更新数据库
- 延迟一段时间(如 500ms)后再次删除缓存
延迟的这段时间是为了等待可能存在的并发读请求完成。更严谨的做法是使用 Canal 监听 MySQL binlog,由 binlog 消费者异步删除/更新缓存,将一致性保障从应用层下沉到数据层。
四、缓存击穿的防护设计
缓存击穿是指某个热点 key 在过期瞬间,大量并发请求同时穿透到数据库。防护的核心思路是"互斥锁"或"永不过期"。
互斥锁方案:当缓存未命中时,使用分布式锁确保只有一个线程去加载数据,其他线程等待锁释放后从缓存读取:
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; /** * 缓存击穿防护——基于 Redis 分布式锁的互斥加载方案 */ @Component public class CacheBreakdownProtector { private static final String LOCK_PREFIX = "cache:lock:"; private static final long LOCK_TIMEOUT = 5; // 锁超时时间(秒) private static final long RETRY_INTERVAL = 100; // 重试间隔(毫秒) private final RedisTemplate<String, Object> redisTemplate; public CacheBreakdownProtector(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } /** * 带击穿保护的缓存查询 * * @param cacheKey 缓存键 * @param loader 数据加载函数 * @param expireSeconds 缓存过期时间(秒) * @return 缓存数据 */ @SuppressWarnings("unchecked") public <T> T getWithProtection(String cacheKey, CacheLoader<T> loader, long expireSeconds) { // 步骤1:尝试从缓存获取 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (T) cached; } String lockKey = LOCK_PREFIX + cacheKey; boolean locked = false; try { // 步骤2:缓存未命中,尝试获取分布式锁 locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", LOCK_TIMEOUT, TimeUnit.SECONDS); if (locked) { // 步骤3a:获取到锁,加载数据并写入缓存 // Double-check:再次检查缓存(可能其他线程已经加载完成) cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (T) cached; } T data = loader.load(); if (data != null) { redisTemplate.opsForValue().set(cacheKey, data, expireSeconds, TimeUnit.SECONDS); } return data; } else { // 步骤3b:未获取到锁,等待并重试 return retryGet(cacheKey, loader, expireSeconds); } } catch (Exception e) { // 异常降级:根据业务场景返回默认值或抛出异常 System.err.println("缓存加载异常 [key=" + cacheKey + "]: " + e.getMessage()); // 安全返回 null,业务层需处理 null 场景 return null; } finally { // 步骤4:释放锁 if (locked) { try { redisTemplate.delete(lockKey); } catch (Exception e) { System.err.println("释放锁失败 [key=" + lockKey + "]: " + e.getMessage()); } } } } /** * 重试获取缓存,带退避策略 */ @SuppressWarnings("unchecked") private <T> T retryGet(String cacheKey, CacheLoader<T> loader, long expireSeconds) { int maxRetries = 30; // 最多重试 30 次,总计约 3 秒 for (int i = 0; i < maxRetries; i++) { try { Thread.sleep(RETRY_INTERVAL); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } // 每次重试先查缓存 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (T) cached; } } // 重试超时,最后的兜底:再次尝试加载(此时锁大概率已释放) System.err.println("缓存加载重试超时 [key=" + cacheKey + "],执行兜底加载"); try { T data = loader.load(); if (data != null) { redisTemplate.opsForValue().set(cacheKey, data, expireSeconds, TimeUnit.SECONDS); } return data; } catch (Exception e) { System.err.println("兜底加载也失败了 [key=" + cacheKey + "]: " + e.getMessage()); return null; } } /** * 数据加载函数式接口 */ @FunctionalInterface public interface CacheLoader<T> { T load() throws Exception; } }逻辑过期方案可以进一步提升性能。将过期时间存储在 value 中而非依赖 Redis 的 TTL,当发现数据逻辑上已过期时,先返回旧数据给用户,同时异步开启一个线程去加载新数据。这种方案避免了互斥锁方案中的等待时间,用户体验更好,但实现复杂度更高。
五、多级缓存的一致性问题
引入 L1 本地缓存后,一致性问题的复杂度升级了一个数量级。当应用实例 A 更新了数据库并删除了 L2 Redis 缓存,应用实例 B 的 L1 本地缓存中仍然持有旧数据。解决这个问题的常见方案有以下几种:
方案一:基于消息广播的缓存失效。数据变更方通过 Redis Pub/Sub 或 MQ 广播缓存失效消息,所有实例监听到消息后主动清除本地缓存。这种方案的时效性较好(百毫秒级),但增加了系统复杂度。
方案二:缩短本地缓存过期时间。将 L1 缓存的 TTL 设置得足够短(如 3-5 秒),使不一致窗口控制在业务可接受的范围内。这种方案最简单,但牺牲了本地缓存的命中率。
方案三:版本号机制。在每个缓存 key 的元数据中维护一个版本号,当数据变更时版本号递增。读取时对比本地缓存版本与 Redis 中的版本,不一致则重新加载。这种方案的一致性最强,但每次查询都需要额外查 Redis 获取版本号。
在实际项目中,我通常采用方案一和方案二的组合:对一致性要求高的数据(如价格、库存)使用消息广播模式,对一致性要求低的数据(如商品描述、用户昵称)使用短 TTL 方案。
缓存穿透(查询不存在的数据导致每次请求都打到数据库)使用布隆过滤器防护;缓存雪崩(大量 key 同时过期)使用过期时间随机化(在原有过期时间基础上增加 1-5 分钟的随机偏移量)来避免集中失效。这两个问题相对简单,此处不再展开。
