Redis 缓存穿透、击穿与雪崩:体系化防护方案的生产级实践
一、缓存三大故障的根因剖析:从现象到本质
Redis 缓存在高并发系统中承担着 80% 以上的读取流量,一旦缓存层出现故障,请求将直接穿透到数据库,轻则响应变慢,重则数据库连接池耗尽导致系统崩溃。缓存故障有三种典型模式,每种模式的根因和防护策略截然不同。
缓存穿透:请求查询的数据在缓存和数据库中都不存在,每次请求都会穿透到数据库。典型场景是恶意攻击者用大量不存在的 ID 发起查询,或者业务逻辑缺陷导致频繁查询空值。穿透的根因不是缓存失效,而是缓存根本没有数据可缓存。
缓存击穿:某个热点 Key 在缓存过期的瞬间,大量并发请求同时到达,这些请求发现缓存失效后全部穿透到数据库加载。击穿的根因是热点 Key 的过期时间集中在一个时间点,且没有互斥机制防止并发重建。
雪崩:大量 Key 在同一时间集中过期,或者 Redis 节点宕机,导致大量请求同时穿透到数据库。雪崩的根因是 Key 过期时间的集中性,或者缓存基础设施的单点故障。
三种故障的本质区别在于:穿透是"无数据可缓存",击穿是"单点热点过期",雪崩是"大面积过期或宕机"。防护方案必须针对各自的根因设计,而非一刀切。
二、三层防护体系的机制剖析
flowchart TD CLIENT[客户端请求] --> GATEWAY[网关层<br/>布隆过滤器前置拦截] GATEWAY -->|合法请求| CACHE_CHECK{Redis 缓存查询} GATEWAY -->|非法请求| REJECT[返回空值/拒绝] CACHE_CHECK -->|命中| RETURN[返回缓存数据] CACHE_CHECK -->|未命中| LOCK_CHECK{互斥锁检查} LOCK_CHECK -->|获取锁成功| DB_QUERY[查询数据库] LOCK_CHECK -->|获取锁失败| WAIT[等待并重试<br/>自旋50ms] DB_QUERY -->|数据存在| WRITE_CACHE[回写缓存<br/>随机过期时间] DB_QUERY -->|数据不存在| WRITE_NULL[写入空值缓存<br/>短过期时间] WRITE_CACHE --> RETURN WRITE_NULL --> RETURN_EMPTY[返回空值] subgraph 雪崩防护 RANDOM_TTL[随机过期时间<br/>baseTTL + random(0, 300s)] HA_CLUSTER[Redis Cluster<br/>主从 + 哨兵] end WRITE_CACHE --> RANDOM_TTL CACHE_CHECK -.-> HA_CLUSTER style GATEWAY fill:#e74c3c,color:#fff style LOCK_CHECK fill:#e67e22,color:#fff style RANDOM_TTL fill:#27ae60,color:#fff style HA_CLUSTER fill:#3498db,color:#fff第一层——布隆过滤器(穿透防护):在请求到达 Redis 之前,先通过布隆过滤器判断查询的 Key 是否可能存在。布隆过滤器是一个空间效率极高的概率型数据结构,如果判断 Key 不存在,则一定不存在;如果判断 Key 存在,则可能存在(有误判率)。将所有合法数据的 ID 预加载到布隆过滤器中,可以拦截绝大部分穿透请求。
第二层——互斥锁重建(击穿防护):当缓存失效时,只允许一个线程查询数据库并重建缓存,其他线程等待缓存重建完成后直接读取缓存。互斥锁通过 Redis 的SETNX命令实现,设置超时时间防止死锁。
第三层——随机过期时间与高可用集群(雪崩防护):为每个 Key 的过期时间添加随机偏移量,避免大量 Key 在同一时间过期。同时部署 Redis Cluster 主从集群,确保单节点宕机不影响整体可用性。
三、生产级代码实现
3.1 布隆过滤器防穿透
/** * 基于 Redisson 布隆过滤器的穿透防护 * 核心设计:数据写入时同步更新布隆过滤器 * 查询时先校验布隆过滤器,不存在的 Key 直接拒绝 */ @Service @Slf4j public class BloomFilterProtection { private final RBloomFilter<Long> itemBloomFilter; public BloomFilterProtection(RedissonClient redissonClient) { // 初始化布隆过滤器 // expectedInsertions: 预期插入量,设为 1000 万 // falseProbability: 误判率,设为 0.01(1%) // 误判率越低,内存占用越大,1% 是性价比较高的选择 this.itemBloomFilter = redissonClient.getBloomFilter( "itemBloomFilter" ); this.itemBloomFilter.tryInit(10_000_000L, 0.01); } /** * 查询前校验:Key 是否可能存在 * 布隆过滤器返回 false 表示一定不存在,直接拒绝 * 返回 true 表示可能存在,继续查询缓存和数据库 */ public boolean mightExist(Long itemId) { boolean exists = itemBloomFilter.contains(itemId); if (!exists) { log.info("布隆过滤器拦截, itemId={}", itemId); // 记录拦截指标,用于监控穿透率 PENETRATION_REJECT_COUNTER.inc(); } return exists; } /** * 数据写入时更新布隆过滤器 * 必须在数据库写入成功后同步更新,保证一致性 */ public void addToBloomFilter(Long itemId) { itemBloomFilter.add(itemId); log.debug("布隆过滤器更新, itemId={}", itemId); } }3.2 互斥锁防击穿
/** * 互斥锁缓存重建器 * 核心设计:缓存失效时,通过 SETNX 争抢锁 * 获取锁的线程负责查库重建缓存,其他线程等待后重试 */ @Service @Slf4j public class MutexCacheRebuilder { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private ItemMapper itemMapper; // 锁的过期时间:10 秒,防止死锁 // 设为 10 秒是因为数据库查询 + 缓存回写通常在 1 秒内完成 // 但要留出足够余量,避免锁提前释放导致并发重建 private static final long LOCK_EXPIRE_SECONDS = 10; // 等待重试的间隔:50 毫秒 // 不宜过短(CPU 空转),不宜过长(增加延迟) private static final long RETRY_INTERVAL_MS = 50; // 最大等待时间:3 秒 // 超过此时间直接返回空值,避免用户长时间等待 private static final long MAX_WAIT_MS = 3000; public Item getItemWithMutex(Long itemId) { String cacheKey = "item:" + itemId; String lockKey = "lock:item:" + itemId; // 1. 查询缓存 Item item = (Item) redisTemplate.opsForValue().get(cacheKey); if (item != null) { return item; } // 2. 缓存未命中,尝试获取互斥锁 long startTime = System.currentTimeMillis(); while (System.currentTimeMillis() - startTime < MAX_WAIT_MS) { // SETNX 尝试获取锁 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 获取锁成功,负责重建缓存 try { // 双重检查:获取锁后再次确认缓存是否已被其他线程重建 item = (Item) redisTemplate.opsForValue() .get(cacheKey); if (item != null) { return item; } // 查询数据库 item = itemMapper.selectById(itemId); if (item != null) { // 回写缓存:随机过期时间防雪崩 long ttl = 3600 + ThreadLocalRandom.current() .nextLong(0, 600); redisTemplate.opsForValue().set( cacheKey, item, ttl, TimeUnit.SECONDS ); } else { // 空值缓存:防止穿透,短过期时间 redisTemplate.opsForValue().set( cacheKey, "NULL", 60, TimeUnit.SECONDS ); } return item; } finally { // 释放锁 redisTemplate.delete(lockKey); } } // 获取锁失败,等待后重试 try { Thread.sleep(RETRY_INTERVAL_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } // 等待超时,返回降级数据 log.warn("互斥锁等待超时, itemId={}", itemId); return null; } }3.3 随机过期时间防雪崩
/** * 缓存写入工具类 * 核心设计:所有缓存写入都通过此工具类,自动添加随机过期时间 * 避免开发人员直接调用 RedisTemplate 导致过期时间集中 */ @Component public class CacheWriteUtil { @Autowired private RedisTemplate<String, Object> redisTemplate; // 基础过期时间:1 小时 private static final long BASE_TTL_SECONDS = 3600; // 随机偏移范围:0-600 秒(10 分钟) // 这样即使同一时刻写入的 Key,过期时间也分散在 1h-1h10min 之间 private static final long RANDOM_OFFSET_SECONDS = 600; /** * 写入缓存,自动添加随机过期时间 * @param key 缓存 Key * @param value 缓存值 */ public void putWithRandomTTL(String key, Object value) { long ttl = BASE_TTL_SECONDS + ThreadLocalRandom.current().nextLong(RANDOM_OFFSET_SECONDS); redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); } /** * 写入空值缓存,使用较短的过期时间 * 空值缓存是为了防穿透,不需要长期占用内存 * @param key 缓存 Key */ public void putNullValue(String key) { // 空值缓存 60 秒过期,足够防穿透,又不会长期占用内存 redisTemplate.opsForValue().set(key, "NULL", 60, TimeUnit.SECONDS); } /** * 批量写入缓存 * 使用 Pipeline 减少网络往返,提升批量写入性能 */ public void batchPutWithRandomTTL(Map<String, Object> entries) { redisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (Map.Entry<String, Object> entry : entries.entrySet()) { long ttl = BASE_TTL_SECONDS + ThreadLocalRandom.current() .nextLong(RANDOM_OFFSET_SECONDS); byte[] keyBytes = redisTemplate.getStringSerializer() .serialize(entry.getKey()); byte[] valueBytes = redisTemplate.getValueSerializer() .serialize(entry.getValue()); connection.setEx(keyBytes, ttl, valueBytes); } return null; }); } }四、防护方案的代价:内存开销、延迟增加与一致性窗口
布隆过滤器的内存开销:1000 万条数据、1% 误判率的布隆过滤器约占用 12MB 内存。误判率降到 0.1% 时内存占用翻倍。此外,布隆过滤器不支持删除操作——如果数据被物理删除,布隆过滤器中的标记无法移除,只能重建整个过滤器。对于频繁删除的数据场景,需要改用 Counting Bloom Filter,但内存占用增加 4 倍。
互斥锁的延迟增加:缓存失效时,未获取锁的线程需要等待 50ms 后重试。在极端情况下(大量并发请求同时到达),等待时间可能累积到秒级。对于延迟敏感的场景,可以使用逻辑过期方案替代互斥锁——缓存永不过期,但在值中存储逻辑过期时间,发现逻辑过期后异步更新缓存,用户始终读到旧数据但延迟为零。
空值缓存的内存浪费:如果攻击者使用大量不同的无效 ID 发起查询,空值缓存会占用大量 Redis 内存。解决方案是对空值缓存设置较短的过期时间(30-60 秒),并配合布隆过滤器前置拦截,减少空值缓存的数量。
五、总结
缓存穿透、击穿和雪崩是三种根因不同的故障模式,防护策略必须对症下药。布隆过滤器在入口层拦截不存在的 Key,从根源上消除穿透;互斥锁确保缓存失效时只有一个线程重建缓存,避免击穿引发的数据库压力;随机过期时间和高可用集群分别从时间维度和基础设施维度防止雪崩。三层防护协同工作,才能构建真正可靠的缓存防线。
落地路线建议:第一步,梳理业务中的热点 Key,为热点数据配置互斥锁重建策略;第二步,实现布隆过滤器组件,在数据写入时同步更新过滤器;第三步,统一缓存写入工具类,强制所有缓存写入使用随机过期时间;第四步,部署 Redis Cluster 高可用集群,确保单节点故障不影响整体可用性;第五步,建立缓存命中率、穿透率、重建耗时的监控看板,持续优化防护参数。