缓存雪崩是Redis使用过程中最容易引发线上事故的问题之一。典型场景是某个热点时间点大量缓存key同时过期,或者Redis节点宕机导致缓存整体失效,成千上万的请求在同一瞬间直接穿透到数据库,数据库连接池瞬间耗尽,进而拖垮整个服务链路。应对缓存雪崩有不少思路,其中互斥重建是一种非常实用的手段:缓存失效时只允许一个请求去数据库加载数据并回写缓存,其他请求排队等待或降级返回。本文围绕Redis缓存雪崩的互斥重建方案展开,从成因分析到代码实现逐层展开。

缓存雪崩到底是怎么发生的
缓存雪崩通常有两类诱因。第一类是大量key集中过期。比如某个运营活动预热时,一次性把几十万个商品数据写入缓存,并且统一设置了30分钟的过期时间,30分钟后这批key同时失效,所有请求立刻转向数据库,数据库的查询QPS可能从几百直接飙升到几万,很容易触发连接超时甚至宕机。
第二类是Redis实例本身不可用。主从切换的间隙、网络抖动、内存溢出被操作系统杀掉进程等情况,都会导致应用层拿不到缓存服务。如果应用没有做降级处理,所有请求同样会全部落到数据库上。这类问题的破坏力往往比第一类更强,因为影响的是全部缓存而不仅仅是某一批key。
两者的共同点是:短时间内数据库承受了本该由缓存挡住的流量。理解了这一点,就能明白为什么互斥重建有效——它的核心思想是控制并发回源的数量,把几百个并发的数据库查询压缩成一个,其余请求共享这一份查询结果。
常见应对策略对比
除了互斥重建,业界还有几种常见方案,各有适用场景。过期时间加随机值是最简单的做法,在基础TTL上加一个随机偏移量,让key的失效时间打散分布,从源头上避免集中过期。它实现成本极低,但只能预防第一类雪崩,对Redis实例宕机无能为力。
多级缓存架构指的是本地缓存加Redis的两层结构。本地缓存(如Caffeine)作为第一道防线,即使Redis短暂不可用,本地缓存中仍然留存着一部分热点数据,可以争取到缓冲时间。代价是数据一致性维护更复杂,需要考虑本地缓存的失效通知机制。
缓存永不过期加异步刷新是另一种思路:物理上不设置TTL,逻辑上由后台任务定期更新数据。这种方案适合那些更新频率可控的配置类、字典类数据,但对高频变化的数据不太友好,可能返回偏旧的数据。互斥重建的优势在于通用性强,既能应对集中过期时的并发回源,也能配合熔断降级处理实例故障,且不需要引入额外的组件,仅依靠Redis本身就能实现。
互斥重建的核心原理与实现
互斥重建的关键在于一把分布式互斥锁。当请求发现缓存未命中时,先尝试用SET key value NX EX seconds命令获取锁:只有第一个请求能成功拿到锁,它去查询数据库并回写缓存,最后释放锁;其他请求拿锁失败,则短暂休眠后重试读取缓存,一旦缓存被填充就能直接返回。来看一段Java实现的示例:
public String getDataWithMutex(String key) {
String value = redis.get(key);
if (value != null) {
return value; // 缓存命中,直接返回
}
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取互斥锁,超时时间10秒
boolean locked = redis.set(lockKey, lockValue, "NX", "EX", 10);
if (locked) {
// 双重检查:拿到锁后再查一次缓存,可能别的线程已经写好
value = redis.get(key);
if (value != null) {
return value;
}
// 只有一个请求执行数据库查询
value = queryFromDatabase(key);
redis.setex(key, 300 + new Random().nextInt(60), value);
return value;
} else {
// 拿锁失败,休眠后重试
Thread.sleep(50);
return getDataWithMutex(key); // 递归重试
}
} finally {
// 释放锁前校验value,防止误删别人的锁
String current = redis.get(lockKey);
if (lockValue.equals(current)) {
redis.del(lockKey);
}
}
}
这段代码有几个细节值得注意。首先是双重检查:拿到锁之后要再查一次缓存,因为在你等待锁的这段时间里,前一个持有锁的线程可能已经完成了重建,直接返回即可,不必再查一次数据库。其次是锁的过期时间必须设置,否则持有锁的线程如果异常崩溃,锁会一直存在,后续所有请求都会卡死。最后是释放锁时的value校验:直接DEL可能删掉别人的锁(比如自己的锁已超时释放,另一个线程拿到了锁),比较唯一的lockValue可以避免误删。
递归重试在生产环境中建议改成有限次数的循环,避免极端情况下栈溢出。同时休眠时间不宜过长,一般50毫秒左右比较合适,既给重建线程留出执行时间,又不至于让用户请求等待太久。如果重试若干次后缓存仍不可用,应该走降级逻辑,比如返回默认值、返回上一次的旧数据,或者直接友好提示稍后再试。
互斥重建的落地注意事项
互斥锁方案虽然有效,但也不是拿来就能用的。第一,锁的粒度要按key划分,而不是全局一把锁。全局锁会让所有未命中的请求都排队,吞吐量急剧下降;按key加锁则只影响同一个数据的并发请求,不同key之间互不干扰。第二,要考虑锁超时时间与数据库查询耗时的关系。如果数据库查询需要8秒而锁5秒就过期了,第二个线程会在第一个还没写完缓存时拿到锁,造成重复回源。稳妥的做法是把锁超时设置得比最慢查询时间更长,或者使用Redisson等支持看门狗自动续期的客户端。
第三,热点key的等待请求也要有上限。某个极热key失效时,可能有几千个请求同时在自旋等待,这些请求本身也占用连接和线程资源。可以在网关或应用层做请求合并、限流,把等待队列控制在合理范围内。第四,对于Redis实例整体宕机的场景,应用侧需要配合熔断降级:当获取Redis连接连续失败时,快速失败并走降级响应,而不是让每个请求都等待超时,那样会耗尽线程池。
总结一下,互斥重建通过控制并发回源数量,在缓存失效的瞬间保护了数据库,是实现缓存高可用体系中性价比很高的一环。实际工程中,建议把过期时间随机化作为默认规范,互斥锁作为并发兜底,再叠加多级缓存和熔断降级,多管齐下才能真正抵御缓存雪崩带来的冲击。