
分布式缓存架构设计——多级缓存一致性方案与缓存击穿防护一、缓存架构的核心矛盾在分布式系统中缓存的引入本质上是在解决一个矛盾数据的访问速度与存储容量/成本之间的权衡。内存的读写速度是磁盘的数千倍但单位成本也高出数十倍。缓存的使命就是以有限的昂贵存储换取绝大部分读请求的低延迟响应。但缓存一旦引入就带来了新的问题数据一致性如何保证缓存不可用时系统如何降级热点数据如何防护这些问题构成了分布式缓存架构设计的主线。我从生产实践中总结出一个好的缓存架构需要回答三个问题数据怎么放、数据怎么一致、数据怎么保护。二、多级缓存架构模型单级缓存如直接使用 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 RedisTemplateString, Object redisTemplate; public CacheBreakdownProtector(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } /** * 带击穿保护的缓存查询 * * param cacheKey 缓存键 * param loader 数据加载函数 * param expireSeconds 缓存过期时间秒 * return 缓存数据 */ SuppressWarnings(unchecked) public T T getWithProtection(String cacheKey, CacheLoaderT 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, CacheLoaderT 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 CacheLoaderT { 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 分钟的随机偏移量来避免集中失效。这两个问题相对简单此处不再展开。