4、缓存雪崩问题:定义与危害、解决方案
4.1 什么是缓存雪崩?
缓存雪崩,说白了就是大量缓存同时失效。
想象一下这个场景:你的系统里有一批热点数据,比如商品详情、用户信息,它们设置了相同的过期时间。某个时间点一到,这些缓存齐刷刷地过期了。紧接着,成千上万的请求发现缓存里没数据,一股脑全打到数据库上。
结果呢?数据库瞬间被压垮,连接池爆满,请求超时,服务不可用。这就是缓存雪崩。
我遇到过最夸张的一次,某个电商平台的大促活动,零点刚过,首页推荐数据的缓存全部过期。数据库 CPU 直接飙到 100%,整个系统瘫痪了将近 10 分钟。那损失,你想想看。
4.2 缓存雪崩的危害
危害其实很直接,我列几个关键点:
- 数据库被打爆:大量请求同时涌入,数据库扛不住
- 服务雪崩:一个服务挂了,依赖它的服务也跟着挂
- 恢复困难:数据库重启后,又会有一波请求打过来,形成恶性循环
- 用户体验极差:页面加载超时,用户直接流失
嗯,这里要注意,雪崩的破坏力往往比穿透大得多。穿透是零星请求,雪崩是海啸级别的。
4.3 解决方案一:随机过期时间
这是最常用的手段。思路很简单:不要让所有缓存同时过期。
具体做法是,在设置过期时间时,加一个随机偏移量。比如原本是 1 小时,现在改成 1 小时 ± 10 分钟。
// 伪代码示例
// 原本的做法
cache.set(key, value, 3600);
// 改进后的做法
int baseExpire = 3600; // 1小时
int randomOffset = new Random().nextInt(600) - 300; // ±5分钟
cache.set(key, value, baseExpire + randomOffset);
这样,缓存失效的时间点就分散开了。数据库的压力自然就小了。
4.4 解决方案二:多级缓存
多级缓存,说白了就是给数据多穿几层「防弹衣」。
常见的架构是两层:
- 一级缓存(本地缓存):放在应用进程内,比如 Caffeine、Guava Cache
- 二级缓存(分布式缓存):放在 Redis 里
请求来了,先查本地缓存。本地没有,再查 Redis。Redis 也没有,才去查数据库。
这样做的好处是:即使 Redis 里的缓存全部失效,本地缓存还能扛一阵子。而且本地缓存的过期时间可以设置得比 Redis 短一些,形成错峰。
// 多级缓存查询示例
public Object getData(String key) {
// 1. 查本地缓存
Object localData = localCache.get(key);
if (localData != null) {
return localData;
}
// 2. 查 Redis
Object redisData = redisCache.get(key);
if (redisData != null) {
// 回填本地缓存
localCache.put(key, redisData);
return redisData;
}
// 3. 查数据库
Object dbData = database.query(key);
if (dbData != null) {
// 回填 Redis 和本地缓存
redisCache.put(key, dbData, 3600);
localCache.put(key, dbData);
}
return dbData;
}
4.5 其他辅助手段
除了上面两种,还有一些辅助方案:
| 方案 | 说明 | 适用场景 |
|---|---|---|
| 加锁/限流 | 缓存失效后,只允许一个线程去查数据库 | 高并发场景,防止数据库被打穿 |
| 缓存预热 | 提前加载热点数据到缓存 | 大促活动、定时任务 |
| 永不过期 | 缓存不设置过期时间,后台异步更新 | 数据变化不频繁的场景 |
我曾经在一个项目中用过「永不过期 + 异步更新」的方案。缓存永远不删,后台有个定时任务每隔一段时间去刷新数据。这样数据库的压力非常平稳,但缺点是实现复杂,而且数据有短暂的不一致。
4.6 实战代码示例
下面是一个完整的缓存雪崩防护示例,结合了随机过期时间和多级缓存:
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
public class CacheAvalancheProtector {
// 模拟本地缓存
private ConcurrentHashMap<String, Object> localCache = new ConcurrentHashMap<>();
// 模拟 Redis 缓存
private ConcurrentHashMap<String, Object> redisCache = new ConcurrentHashMap<>();
private Random random = new Random();
public Object getData(String key) {
// 1. 查本地缓存
Object localData = localCache.get(key);
if (localData != null) {
return localData;
}
// 2. 查 Redis
Object redisData = redisCache.get(key);
if (redisData != null) {
// 回填本地缓存,设置随机过期时间
int expire = 300 + random.nextInt(60); // 5分钟 ± 30秒
localCache.put(key, redisData);
return redisData;
}
// 3. 查数据库(这里用 synchronized 模拟加锁)
synchronized (this) {
// 双重检查,防止重复查询
redisData = redisCache.get(key);
if (redisData != null) {
return redisData;
}
Object dbData = queryDatabase(key);
if (dbData != null) {
// Redis 也设置随机过期时间
int redisExpire = 3600 + random.nextInt(600); // 1小时 ± 5分钟
redisCache.put(key, dbData);
int localExpire = 300 + random.nextInt(60);
localCache.put(key, dbData);
}
return dbData;
}
}
private Object queryDatabase(String key) {
// 模拟数据库查询
return "data_" + key;
}
}
4.7 总结
缓存雪崩的解决方案,核心就两句话:
- 分散失效时间:用随机过期时间,别让缓存同时过期
- 增加防护层:用多级缓存,让本地缓存扛住第一波冲击
实际项目中,我一般会组合使用这两种方案。随机过期时间成本最低,先加上。如果业务对可用性要求高,再上多级缓存。至于加锁和限流,看具体情况,别过度设计。
嗯,最后提醒一句:缓存雪崩的预防比事后补救重要得多。等数据库被打爆了再想方案,那就晚了。