凌晨两点,某电商平台的核心商品详情页突然卡死,后台监控里缓存命中率直线掉到个位数,数据库连接数爆满。技术群里一片慌乱——原因很快被找到:一批热门商品的缓存过期时间都设在同一点,零点一过集体失效,所有请求直接打到数据库上,瞬间压垮了系统。这就是典型的缓存雪崩场景。
一、从一次线上事故说起
你可能会想,缓存过期这事不是挺正常吗?每个数据都有自己的生命周期,到期就清掉,再来新请求重新加载,天经地义。但问题就出在“同时”两个字上。假设你有一百个热门商品,每个商品设置了相同的过期时间,比如凌晨12点整。那么到了这个时间点,这一百个商品的缓存全部失效。如果这时候有大量用户同时访问这些商品,系统发现缓存没命中,就全部去数据库查,数据库一秒内可能收到平时十倍以上的压力,瞬间就趴窝了。
缓存雪崩的恐怖之处在于,它不是单个key失效,而是大量key在同一时间失效,导致流量直接绕过缓存冲进底层存储。它的症状和缓存击穿有点像,但击穿是只针对一个热点key,而雪崩是一批热点key同时出问题。我们通常说的“缓存穿透”是查询一个不存在的key,根本不会去缓存查,直接打数据库,那是另一回事。
生活里也有类似的场景。好比食堂开饭,大家平时错峰吃饭,排队很流畅。但突然有一天午饭时间规定全体人员必须同时去食堂打饭,窗口没变,人全挤在一起,结果就是谁都没饭吃。缓存雪崩就是这种“全员同时到点”的灾难。
二、缓存雪崩是怎么发生的
先理清一个概念:热点数据。所谓热点数据,指的是访问频率特别高的数据,比如购物车里的商品信息、热门新闻的列表、秒杀活动的库存。这些数据通常会被缓存在Redis这类内存数据库里,减少对官方数据库的查询压力。
我们在存储时,会给每个缓存key设置一个过期时间。这个时间有两种常见写法:
第一种,存的时候固定一个时间,比如:
// 设置缓存,过期时间3600秒
redisTemplate.opsForValue().set("product:1001", productData, 3600, TimeUnit.SECONDS);
第二种,定时刷新时统一设置某个整点过期,比如把所有热门商品都设置成每天凌晨0点过期。
这两种写法,只要key的数量一多,且过期时间相同,就会在那一瞬间形成“集体下岗”。所有请求发现缓存没有,就会同时去查数据库,数据库扛不住,系统就雪崩了。
还有另一种情况是,虽然设置了不同的过期时间,但由于某些业务原因,比如批量导入数据时用了同一个过期时间,或者重启服务时缓存全部清空,也会产生类似的雪崩效果。所以防雪崩的核心思路就两条:要么让过期时间分散开,要么让缓存重建过程更安全,避免大量请求直接压到数据库。
三、解决思路一:随机过期时间
3.1 基本做法
随机过期时间,说起来特别简单——就是给每个key的过期时间加上一个随机数,让它们的过期点不再整齐划一。还是拿食堂打饭举例,不再规定同一时刻,而是让大家随机分几波去,这样就不会挤在一起了。
比如原来所有商品都设3600秒过期,现在给每个商品在3600秒基础上加一个0到300秒的随机数,有的3602秒过期,有的3800秒过期。这样过期时间点被摊开,虽然还是会有部分key同时过期,但数量少了很多,数据库的压力也分散了。
3.2 代码示例
技术栈:Java + Spring Boot + Redis
下面这段代码演示了如何给缓存key设置随机过期时间。
import org.springframework.data.redis.core.RedisTemplate;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class RandomExpireService {
private final RedisTemplate<String, Object> redisTemplate;
private final Random random = new Random();
public RandomExpireService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 写入缓存,并添加随机过期时间
* @param key 缓存key
* @param value 缓存value
* @param baseExpireSeconds 基础过期时间(秒),例如3600
* @param randomRange 随机数范围(秒),例如300
*/
public void setWithRandomExpire(String key, Object value, long baseExpireSeconds, long randomRange) {
// 生成0到randomRange之间的随机数
long randomExtra = (long) (random.nextDouble() * randomRange);
long finalExpire = baseExpireSeconds + randomExtra;
// 写入redis,过期时间使用最终计算出来的值
redisTemplate.opsForValue().set(key, value, finalExpire, TimeUnit.SECONDS);
// 方便观察,打印过期时间
System.out.println("key: " + key + ",过期时间: " + finalExpire + "秒");
}
public static void main(String[] args) {
// 假设已有RedisTemplate实例
// 模拟批量设置热门商品缓存,基础过期1小时,随机范围0-300秒
RandomExpireService service = new RandomExpireService(null); // 真正使用时需注入RedisTemplate
for (int i = 1; i <= 10; i++) {
service.setWithRandomExpire("product:100" + i, "商品数据" + i, 3600, 300);
}
}
}
注释写得很清晰,核心逻辑就是那三行:生成随机数、计算最终过期时间、写入缓存。你可能会问,随机范围设多大合适?一般来说,基础过期时间的5%到10%作为随机区间比较合理。比如一小时3600秒,随机300秒,大概在5%到8%左右。如果随机范围太大,可能有些key存活时间过短,导致缓存命中率下降;如果太小,效果又不明显。
3.3 优缺点分析
随机过期时间最大的优点是实现极其简单,几乎不引入额外代码,也不需要改变读取逻辑。它适合绝大多数缓存场景,尤其是数据更新不频繁但访问量大的场景。缺点也很明显:它只能降低同时过期的概率,并不能完全避免。如果系统里只有几个热点key,这几个key恰好随机到了差不多的过期时间,那么依然可能发生击穿。另外,随机过期并没有真正解决缓存重建期间并发请求的问题。假设某个热点key失效了,在它失效后的几毫秒内,如果有100个请求同时到达,它们会同时去查数据库,依然可能给数据库造成压力。
所以随机过期更像是一个“缓冲”手段,而不是“根治”方案。要根治,还得配合互斥锁。
四、解决思路二:互斥锁重建缓存
4.1 基本做法
互斥锁的思路是:当某个热点key过期,发现缓存没命中时,先尝试获取一把“锁”。只有拿到锁的请求才能去查数据库并重建缓存,其他没拿到锁的请求暂时等待,或者直接返回旧数据、降级数据。这样数据库只被一个请求查询,压力瞬间下降。
还是拿食堂打饭举例。假设一个菜窗口前突然挤满了人,但窗口只允许一个人进去打菜,其他人排队等着。那个人打完菜出来,把菜端到窗口,大家就可以直接吃现成的了。互斥锁就是“只让一个人进厨房”的机制。
4.2 代码示例
技术栈:Java + Spring Boot + Redis
下面演示使用Redis的SET NX EX命令实现分布式锁,然后重建缓存。
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class MutexLockService {
private final RedisTemplate<String, Object> redisTemplate;
public MutexLockService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 获取缓存,如果不存在则通过互斥锁重建
* @param key 缓存key
* @return 缓存值
*/
public Object getDataWithLock(String key) {
// 第一次查缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value; // 缓存命中,直接返回
}
// 缓存未命中,尝试获取锁
String lockKey = "lock:" + key; // 锁的key和业务key区分开
String requestId = UUID.randomUUID().toString(); // 每个线程持有唯一标识
// 尝试加锁,setIfAbsent相当于SETNX,设置过期时间防止锁一直不释放
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 拿到锁后需要再次检查缓存是否被其他线程重建了(双重检查)
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 真正去数据库查询
value = queryFromDataBase(key);
// 重建缓存,设置过期时间
redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
return value;
} finally {
// 释放锁,需要用Lua脚本保证原子性,防止误删别人的锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
}
} else {
// 没拿到锁,可能是其他线程正在重建缓存,这里做降级处理
// 比如等待一小会儿后再重试获取缓存
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 重试获取缓存,也可以设置最大重试次数
return getDataWithLock(key);
}
}
/**
* 模拟数据库查询
*/
private Object queryFromDataBase(String key) {
// 为了演示,这里直接返回字符串
System.out.println("从数据库查询: " + key);
return "数据库中的数据-" + key;
}
}
这段代码有几个关键点:
- 使用
setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS)实现加锁,30秒是锁的自动过期时间,防止持有锁的线程崩溃导致死锁。 - 获取锁成功后,再次检查缓存,这叫“双重检查”,因为可能其他线程拿到锁后已经重建好了缓存,避免重复查询。
- 释放锁时使用Lua脚本,先判断当前锁的值是不是自己的requestId,是才删除,避免误删其他线程的锁。
- 没拿到锁的线程采用自旋重试的方式,sleep 50毫秒后递归调用自己。实际项目中建议限制重试次数,比如重试3次后返回默认值或者抛出异常。
4.3 优缺点分析
互斥锁的优点是能精准保护数据库,不管有多少个请求同时打到同一个key上,最终只会有一个请求真正去查数据库,其他请求等待或降级。这对热点key特别有效,能够彻底避免缓存击穿。缺点是增加了一定的复杂度和延迟。比如获取锁本身就有网络开销,而且没拿到锁的线程需要等待,如果热点key重建缓存比较慢(比如数据库查询要几百毫秒),那么等待的请求可能堆积,响应时间变长。另外,锁粒度设计要小心,如果所有key都共用一把锁,就会把并发性能压得很低;如果每个key单独一把锁,又可能造成内存里锁对象过多。
还有一个隐患:如果持有锁的线程在查询数据库时出现异常,锁可能不能及时释放。虽然有过期时间兜底,但过期时间内其他线程依然会阻塞。所以使用互斥锁时,务必要把缓存重建逻辑放快一些,比如尽量减少数据库查询耗时,或使用异步重建。
五、两种方案对比与实现细节
我们现在把两个方案放在一起仔细对比,从多个维度分析。
5.1 实现复杂度
随机过期时间:几乎为零。只需要在写入缓存时多一行生成随机数的代码,不需要改读缓存逻辑。互斥锁:相对复杂。需要引入分布式锁,处理锁的获取、释放、重试、双重检查等,代码量多了几倍,还要考虑锁的可靠性,比如Redis主从切换可能丢锁。
5.2 对数据库的压力
随机过期时间:只能降低并发峰值,不能消除。如果有1000个key同时过期,分散到不同时间后,每个时间点依然有几十个key同时失效,假设每个key被100个请求访问,那数据库还是会面临几千的并发。互斥锁:对单个key可以做到100%的拦截,同一时刻只有一个请求查数据库,其他key也一样。但要注意,互斥锁只保护“热点key”,如果很多key同时失效,每个key都需要锁,那么数据库的并发等于同时失效的key数量,依然可能很高。所以最理想的是把两者结合。
5.3 对业务响应的影响
随机过期时间:完全不影响响应,因为不需要等待锁,请求立即查到缓存或直接查库(查库可能慢但也是直接)。互斥锁:没拿到锁的请求需要sleep后重试,可能增加几十到几百毫秒的延迟。如果数据库查询很慢,这个等待时间可能更长。
5.4 适用场景
随机过期时间适合大部分数据访问相对均匀、不太极端的情况;互斥锁适合极端热点key,比如秒杀商品、爆款新闻,它们一过期就可能被海量请求同时访问。
由此我们可以看出,单独使用任何一种方案都有短板。随机过期像“大锅饭分时段”,互斥锁像“单间小灶”,但把两者结合起来,才能做出最稳的防雪崩设计。
六、最佳组合:防击穿设计
6.1 组合策略
最佳组合可以概括为三个层次:
第一,在源头让过期时间随机化。这是基础防线,负责把雪崩扩散成小股击穿。
第二,针对每个热点key,在重建缓存时使用互斥锁。即使随机过后仍有个别key同时失效,锁也能保证数据库只被查一次。
第三,增加“逻辑过期”机制。所谓逻辑过期,就是缓存不设置物理过期时间,而是在value值中存储一个逻辑过期时间戳,比如“应该在10分钟后过期”。每次读取时,先看逻辑过期是否到了,如果没到,直接返回。如果到了,则返回旧数据的同时,尝试获取互斥锁去后台异步刷新缓存。这样即使热点key过期了,旧的缓存数据还能继续用,不会让请求直接透穿到数据库。
这三种手段合在一起,就能实现“过期时间分散 + 并发重建隔离 + 旧值兜底”的立体防护。
6.2 完整代码示例
下面给出一个结合了随机过期、互斥锁和逻辑过期的完整Java示例。这个示例采用最常用的方案:value存的是封装了数据和时间戳的对象。
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class CacheAntiCrashService {
private final RedisTemplate<String, Object> redisTemplate;
private final Random random = new Random();
public CacheAntiCrashService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 内部类:逻辑缓存,包含业务数据和逻辑过期时间
*/
private static class CacheData {
Object data; // 真实业务数据
long expireTime; // 逻辑过期时间戳(毫秒)
CacheData(Object data, long expireTime) {
this.data = data;
this.expireTime = expireTime;
}
}
/**
* 写入缓存:设置随机物理过期时间,同时写入逻辑过期时间
* @param key 缓存key
* @param data 业务数据
* @param logicSec 逻辑过期时长(秒)
* @param maxMissSec 随机物理过期额外缓冲(秒)
*/
public void writeCache(String key, Object data, long logicSec, long maxMissSec) {
long now = System.currentTimeMillis();
// 逻辑过期时间 = 当前时间 + 逻辑时长 + 随机缓冲
long logicExpire = now + (logicSec + random.nextLong(maxMissSec)) * 1000;
CacheData cacheData = new CacheData(data, logicExpire);
// 物理过期时间 = 逻辑时长 + 随机缓冲,再加额外180秒保护
long physicalExpire = logicSec + maxMissSec + 180;
redisTemplate.opsForValue().set(key, cacheData, physicalExpire, TimeUnit.SECONDS);
}
/**
* 读取缓存:逻辑过期判断 + 互斥锁异步重建
* @param key 缓存key
* @return 业务数据
*/
public Object readCache(String key) {
CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
if (cacheData == null) {
// 物理过期或者key不存在,直接加锁重建(因为逻辑过期已经防不住这种裸空)
return rebuildCacheByLock(key);
}
long now = System.currentTimeMillis();
if (now <= cacheData.expireTime) {
// 逻辑过期时间未到,直接返回数据,这里用new Object是演示,实际直接返回业务对象
return cacheData.data;
}
// 逻辑过期已到,先返回旧数据,再尝试异步更新
tryRefreshCacheAsync(key, cacheData);
return cacheData.data; // 返回旧数据,保证响应不等待
}
/**
* 通过互斥锁重建缓存(仅处理缓存缺失的场景)
*/
private Object rebuildCacheByLock(String key) {
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS));
if (locked) {
try {
// 双重检查
CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
if (cacheData != null) {
return cacheData.data;
}
Object dbData = loadFromDatabase(key);
long now = System.currentTimeMillis();
// 写入新的逻辑缓存,例如10秒有效
CacheData newData = new CacheData(dbData, now + 10000);
redisTemplate.opsForValue().set(key, newData, 190, TimeUnit.SECONDS);
return dbData;
} finally {
releaseLock(lockKey, requestId);
}
} else {
// 拿不到锁,说明其他线程正在重建,稍微等待后重试(可加次数限制)
try {
Thread.sleep(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
return (cacheData != null) ? cacheData.data : rebuildCacheByLock(key);
}
}
/**
* 异步刷新缓存:拿到锁后重新生成数据,不需要等待
*/
private void tryRefreshCacheAsync(String key, CacheData oldData) {
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
// 尝试加锁,失败说明有其他线程在刷新
boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS));
if (!locked) {
return; // 已经有人刷新,直接放弃
}
try {
// 再次检查逻辑时间是否已被人刷新(双重检查)
CacheData current = (CacheData) redisTemplate.opsForValue().get(key);
if (current != null && current.expireTime > System.currentTimeMillis()) {
return; // 已被其他人刷新
}
// 模拟异步线程池中执行数据库查询
new Thread(() -> {
try {
Object dbData = loadFromDatabase(key);
long now = System.currentTimeMillis();
CacheData newData = new CacheData(dbData, now + 10000);
redisTemplate.opsForValue().set(key, newData, 190, TimeUnit.SECONDS);
} finally {
releaseLock(lockKey, requestId);
}
}).start();
} catch (Exception e) {
releaseLock(lockKey, requestId);
}
}
/**
* 释放锁的Lua脚本
*/
private void releaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
}
/**
* 模拟数据库查询
*/
private Object loadFromDatabase(String key) {
System.out.println("数据库查询: " + key + ",线程: " + Thread.currentThread().getName());
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "最新数据-" + key;
}
}
这段代码展示了一个相对完整的防击穿设计。写缓存时既有逻辑过期时间,又有随机物理过期时间,物理过期是逻辑过期加一个随机缓冲,确保即使有旧值长期存在,最终也能被清理。读缓存时,如果逻辑没过期,直接返回;如果逻辑过期,返回旧值同时触发异步刷新。只有当缓存彻底消失时,才走同步加锁重建。这样做的好处是,用户感知不到任何卡顿,数据库压力也被分散。
当然,实际生产环境里,异步刷新通常会使用线程池,而不是直接new Thread()。这里为了简化演示,用Thread示意,但在博客中要说明。
6.3 其他补充手段
除了上述组合,还有两个常见的辅助手段。
一是多级缓存。在Redis前面加一层本地缓存(如Caffeine),本地缓存设置的过期时间更短,比如1分钟。当Redis大规模失效时,本地缓存还能顶上,形成第二道保护层。本地缓存与业务机器数量有关,每台机器只缓存自己服务的数据,所以Redis雪崩时本地缓存可以拦截掉大部分流量。
二是热点数据永不过期。对于极少数的“超级热点”key,干脆不设置物理过期时间,只通过逻辑过期来更新。比如某个明星的新闻详情页,你可以设计成逻辑上每5分钟更新一次,但物理上这个key永远存在。这样即使并发再大,也不会出现缓存空窗期。
这些手段可以结合使用,但要注意成本。多级缓存会引入缓存一致性维护问题,热点永不过期则依赖后台定期更新,都可能增加开发量。
七、注意事项
在实际落地时,有几个坑一定要避开。
锁的过期时间设置。如果锁过期时间太短,比如1秒,但数据库查询花了2秒,锁自动释放后其他线程又抢到锁,相当于锁失效。通常把锁过期时间设置为预估操作时间的3到5倍,同时加上监控报警。
随机过期时间的随机范围不能太大。比如基础过期时间是3600秒,你随机范围给了3600秒,那有些key实际存活只有一小会儿,缓存命中率会大幅下降。建议随机范围控制在基础过期时间的5%到20%之间。
互斥锁的粒度。如果锁的key粒度太粗,比如所有商品共用一把锁,那不同商品的数据重建也会互相阻塞,严重影响吞吐。应该做到“一key一锁”。
自旋重试要有上限。互斥锁方案里,没拿到锁的请求如果无限重试,一旦数据库恢复正常后,大量线程同时涌进来,仍然可能造成压力。设置最大重试次数,比如3次,超过后直接返回兜底数据或空值,并打日志告警。
注意Redis故障时的降级。如果Redis本身挂了,那么随机过期和互斥锁都失效,这时候需要启动本地熔断机制,直接返回默认数据或空页面,而不是继续重试引发雪上加霜。
八、文章总结
缓存雪崩是分布式系统里非常典型的故障,它告诉我们一个道理:任何集中失效的事件,都要提前想好对策。随机过期时间用“分散风险”的方式降低雪崩概率,互斥锁用“串行化”的方式保护数据库不被同一时刻的并发击穿,而把它们组合起来,再加上逻辑过期、多级缓存等手段,才能构建出一道可靠的防洪堤。
实际开发中,没有银弹,但理解了这两种基础方案的原理,你就能够根据业务场景灵活组合。希望这篇文章能帮你避开缓存雪崩的巨坑,让系统在流量高峰下依然稳如老狗。
评论
围绕“热门数据同时过期引发缓存雪崩,随机过期与互斥锁重建方案各有优劣,比较实现细节并给出防击穿设计最佳组合。”参与讨论