Redis缓存三灾:穿透/击穿/雪崩全解析+Spring Boot实战避坑指南
[!abstract] 本文要点
缓存雪崩、穿透、击穿三大灾难的原理、防护思路与 Spring Boot 代码示例,以及对应的避坑要点。
1. 缓存雪崩
一句话理解:大量缓存同时过期,瞬间流量全部涌向数据库,DB 直接懵圈。
1.1. 防护思路
- TTL 随机化:给过期时间撒点盐,错开高峰。
- 定时预热:把热点数据提前塞进缓存,DB 永远不被冷启动。
1.2. 代码示例
// 1. 设置随机TTL,错开过期时间
redisTemplate.opsForValue()
.set("key", value,
30 + ThreadLocalRandom.current().nextInt(31), // 随机30~60分钟
TimeUnit.MINUTES);
// 2. 定时任务提前预热热点数据
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)
public void preloadHotData() {
List<HotData> list = dbService.getHotData();
list.forEach(data -> {
redisTemplate.opsForValue()
.set("hot:" + data.getId(),
data,
1 + ThreadLocalRandom.current().nextInt(2), // 随机1~2小时
TimeUnit.HOURS);
});
}
1.2.1. 要点说明
ThreadLocalRandom线程安全,性能比new Random()高一倍。- 预热任务加随机 TTL,防止下一次又一起过期。
- 用
@Scheduled自动跑,线上零人工干预。
2. 缓存穿透
一句话理解:缓存没有,数据库也没有,每次请求都穿透到底层。
2.1. 防护思路
- 缓存空对象:把"查不到"的结果也缓存起来,5 分钟后再说。
- 布隆过滤器:提前把所有合法 ID 放到过滤器,非法请求直接挡在门外。
2.2. 代码示例
// 1. 缓存空对象
Object data = redisTemplate.opsForValue().get(key);
if (data == null) {
data = dbService.queryById(id);
if (data == null) {
// 存个"NULL"占位,防止穿透
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(key, data, 30, TimeUnit.MINUTES);
}
}
if ("NULL".equals(data)) return null;
// 2. 布隆过滤器
BloomFilter<Integer> bloomFilter = BloomFilter.create(
Funnels.integerFunnel(), 50_000, 0.01);
@PostConstruct
public void initBloom() {
List<Integer> ids = dbService.getAllIds();
ids.forEach(bloomFilter::put);
}
public Object query(Integer id) {
if (!bloomFilter.mightContain(id)) {
return null; // 非法ID直接返回
}
return redisTemplate.opsForValue().get("user:" + id);
}
2.2.1. 要点说明
- 空对象用固定字符串"NULL",避免与真实数据混淆。
- 布隆过滤器误报率设为 1%,50K 数据只占用约 58KB 内存。
@PostConstruct容器启动时加载,服务一上线就生效。
3. 缓存击穿
一句话理解:某个超热门 Key 刚好过期,千军万马同时去 DB 查同一条数据。
3.1. 防护思路
- 本地锁:单机用
synchronized,简单粗暴。 - 逻辑过期 + 异步刷新:让老数据"续命",后台异步更新,用户无感知。
3.2. 代码示例
// 1. 本地锁版本
Object data = redisTemplate.opsForValue().get(key);
if (data == null) {
synchronized (key.intern()) { // 同一个key只让一个线程查库
data = redisTemplate.opsForValue().get(key);
if (data == null) { // 双重检查
data = dbService.query(id);
redisTemplate.opsForValue().set(key, data, 30, TimeUnit.MINUTES);
}
}
}
// 2. 逻辑过期+异步刷新
@Data
@AllArgsConstructor
public static class CacheData<T> {
private T data;
private long expireAt; // 毫秒时间戳
}
public void setLogicExpire(String key, Object value, long ttl, TimeUnit unit) {
long expireAt = System.currentTimeMillis() + unit.toMillis(ttl);
redisTemplate.opsForValue().set(key, new CacheData<>(value, expireAt));
}
public Object getLogicExpire(String key, Supplier<Object> dbFallback) {
CacheData cache = (CacheData) redisTemplate.opsForValue().get(key);
if (cache == null) return null;
if (System.currentTimeMillis() > cache.getExpireAt()) {
// 异步刷新,老数据先返回
RLock lock = redissonClient.getLock("refresh:" + key);
if (lock.tryLock()) {
executor.submit(() -> {
try {
Object fresh = dbFallback.get();
setLogicExpire(key, fresh, 30, TimeUnit.MINUTES);
} finally {
lock.unlock();
}
});
}
}
return cache.getData();
}
3.2.1. 要点说明
key.intern()保证相同字符串共用一把锁,锁粒度最细。- 逻辑过期把过期时间存在 value 里,Redis 里永不过期,击穿风险为 0。
- Redisson 分布式锁 + 线程池异步刷新,高并发也稳如老狗。
4. 总结
| 场景 | 特征 | 防御策略 |
|---|---|---|
| 雪崩 | 大批 Key 同时失效 | TTL 随机化 + 定时预热 |
| 穿透 | 缓存和 DB 都没有 | 布隆过滤器 + 空对象缓存 |
| 击穿 | 热点 Key 突然失效 | 本地锁/Redisson + 逻辑过期异步刷新 |