本文目录导读:

Java缓存失效问题(如缓存雪崩、缓存击穿、缓存穿透)是高并发场景下的经典坑点,下面从问题现象、根本原因到规避方案进行系统梳理,并给出可落地的代码示例。
三大经典缓存失效场景
缓存雪崩(大规模同时失效)
现象:大量缓存Key在同一时间过期,请求全部打到数据库,导致DB压力激增甚至宕机。
典型原因:
- 所有缓存设置了相同的过期时间(如统一30分钟)
- 缓存服务器宕机(如Redis集群故障)
规避方案:
✅ 过期时间加随机偏移
// 原代码:set("user:1", user, 3600)
// 改进:在基础过期时间上增加随机值
int base = 3600;
int random = new Random().nextInt(600); // 0~600秒随机
redisTemplate.opsForValue().set("user:1", user, base + random, TimeUnit.SECONDS);
✅ 多级缓存(本地+远程)
- 本地缓存(Caffeine/Guava)设置较短过期时间
- Redis设置较长过期时间
- DB作为最终保底
✅ 服务熔断/降级
- 当DB负载过高时,直接返回默认值或空数据,避免连锁反应
缓存击穿(热点Key突然失效)
现象:一个极其热门的Key(如秒杀商品、热搜)在失效瞬间,大量并发请求同时穿透到DB。
典型原因:
- 热点Key过期,且重建缓存耗时较长(如复杂SQL查询)
规避方案:
✅ 互斥锁(分布式锁)
public String getData(String key) {
String value = redis.get(key);
if (value == null) {
// 尝试获取分布式锁,防止并发重建
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
try {
// 二次检查,防止重复查询(double-check)
value = redis.get(key);
if (value == null) {
value = queryFromDB(key); // 耗时操作
redis.set(key, value, 3600);
}
} finally {
redis.del(lockKey);
}
} else {
// 未获取到锁,等待后重试或直接返回默认值
Thread.sleep(100);
return getData(key);
}
}
return value;
}
✅ 主动续期(热Key永不过期)
- 不设置物理过期时间,改为逻辑过期
- 后台线程定期检查并异步更新缓存
- 查询时发现逻辑过期,返回旧数据同时触发异步更新
缓存穿透(请求不存在的数据)
现象:缓存和DB中都没有的数据(如非法ID查询),每次请求都穿透到DB。
典型原因:
- 恶意攻击或程序bug,不断请求不存在的数据
规避方案:
✅ 缓存空值(布隆过滤器兜底)
public String getData(String key) {
// 方案A:缓存空对象(设置较短过期时间)
String value = redis.get(key);
if (value == null) {
value = queryFromDB(key);
if (value == null) {
// 缓存空值,避免反复穿透
redis.set(key, "null", 60); // 空值过期时间短
return null;
}
redis.set(key, value, 3600);
}
// 注意:空值标记不能直接返回,需特殊处理
return "null".equals(value) ? null : value;
}
✅ 布隆过滤器(预过滤)
// 初始化布隆过滤器(所有合法ID的集合)
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnels(),
expectedInsertions,
falsePositiveRate);
// 查询前先判断
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接返回
}
// 再走缓存-数据库流程
注意:布隆过滤器有误判率(可能判断存在但实际不存在),需配合空值缓存兜底。
综合实战:一个健壮的缓存模板
@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redis;
// 10分钟内,同一个Key最多只能重建一次
private static final long REBUILD_LOCK_TIMEOUT = 10 * 60 * 1000L;
// 缓存基础过期时间:30分钟
private static final long CACHE_BASE_TTL = 30 * 60L;
public <T> T getWithProtection(String key, Class<T> type,
Supplier<T> dbLoader) {
// 1. 从缓存读取
Object value = redis.opsForValue().get(key);
if (value != null) {
return handleNullValue((String) value, type);
}
// 2. 缓存不存在,尝试重建(分布式锁防击穿)
String lockKey = "lock:rebuild:" + key;
boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", REBUILD_LOCK_TIMEOUT, TimeUnit.MILLISECONDS);
if (locked) {
try {
// 3. double check:可能其他线程已经重建完
value = redis.opsForValue().get(key);
if (value != null) {
return handleNullValue((String) value, type);
}
// 4. 从数据库加载
T dbResult = dbLoader.get();
// 5. 策略:空值缓存+随机过期时间,避免雪崩
String cacheValue = (dbResult == null) ? "NULL_MARK" : JsonUtil.toJson(dbResult);
long ttl = CACHE_BASE_TTL + new Random().nextInt(600); // 0~10分钟随机偏移
redis.opsForValue().set(key, cacheValue, ttl, TimeUnit.SECONDS);
return dbResult;
} finally {
redis.delete(lockKey);
}
} else {
// 6. 未获取到锁,等待其他线程完成
try {
Thread.sleep(50);
return getWithProtection(key, type, dbLoader);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return null;
}
private <T> T handleNullValue(String value, Class<T> type) {
if ("NULL_MARK".equals(value)) {
return null;
}
return JsonUtil.fromJson(value, type);
}
}
避坑清单(面试/开发自查)
| 问题类型 | 常见错误做法 | 正确做法 |
|---|---|---|
| 雪崩 | 统一过期时间 | 基础时间 + 随机偏移 / 多级缓存 |
| 击穿 | 不加锁,直接重建 | 分布式锁 + double-check / 逻辑过期+异步更新 |
| 穿透 | 不判断null直接查DB | 布隆过滤器 + 空值缓存(短TTL) |
| 热点Key | 单节点缓存 | 本地缓存(Caffeine)+ Redis + 冗余节点 |
| 大Key | 直接缓存整个列表/对象 | 拆分为多个小Key / 压缩 |
| 缓存与DB一致性 | 先删缓存再更新DB | 先更新DB,再删缓存(延迟双删) |
关键原则:
- 先更新数据库,后删除缓存(避免并发写脏数据)
- 缓存设置过期时间(即使逻辑正确,也要有兜底TTL)
- 监控告警:Redis命中率、DB QPS异常波动要实时感知
如果需要针对具体业务场景(如秒杀、排行榜)的缓存优化策略,可以进一步细化说明。