缓存穿透、击穿与雪崩
问题
缓存穿透、缓存击穿、缓存雪崩分别是什么?如何解决?
答案
这三个问题是缓存系统最经典的异常场景,面试必问。它们的共同危害是大量请求直接打到数据库,导致数据库压力暴增甚至宕机。
一、缓存穿透
定义:查询的数据在缓存和数据库中都不存在,每次请求都穿透缓存直达数据库。
常见原因
- 恶意攻击:构造大量不存在的 ID(如负数、超大数)
- 业务 Bug:删除数据后没清理关联缓存 Key
- 爬虫:遍历不存在的 URL
解决方案
方案一:缓存空值
public Object getById(Long id) {
String key = "item:" + id;
// 1. 查缓存
Object value = redis.get(key);
if (value != null) {
// 特殊标记表示数据不存在
return "NULL_PLACEHOLDER".equals(value) ? null : value;
}
// 2. 查数据库
Object dbValue = db.findById(id);
if (dbValue == null) {
// 缓存空值,设置较短的 TTL 防止长期占用内存
redis.set(key, "NULL_PLACEHOLDER", 5, TimeUnit.MINUTES);
return null;
}
// 3. 回填缓存
redis.set(key, dbValue, 30, TimeUnit.MINUTES);
return dbValue;
}
- 空值 TTL 要短(2~5 分钟),避免大量空值占用内存
- 如果数据后来被创建,需要及时清除空值缓存
- 恶意攻击如果每次用不同的 Key,缓存空值方案会失效
方案二:布隆过滤器
布隆过滤器可以快速判断一个 Key 一定不存在,从而拦截非法请求。
@Component
public class BloomFilterService {
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 预计 100 万条数据,误判率 0.01%
bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1_000_000, 0.0001);
// 初始化时加载所有合法 ID
List<Long> allIds = db.getAllIds();
allIds.forEach(bloomFilter::put);
}
public Object getWithBloom(Long id) {
// 布隆过滤器拦截:一定不存在则直接返回
if (!bloomFilter.mightContain(id)) {
return null;
}
// 存在(可能是误判):走正常缓存流程
return cacheService.getById(id);
}
// 新增数据时同步更新布隆过滤器
public void addId(Long id) {
bloomFilter.put(id);
}
}
| 对比 | 缓存空值 | 布隆过滤器 |
|---|---|---|
| 适用场景 | Key 空间有限 | Key 空间巨大 |
| 内存占用 | 较高(每个空值占一条缓存) | 极低(位图) |
| 维护成本 | 低 | 需要预加载和维护 |
| 误判 | 无 | 有(但极低) |
| 删除能力 | 支持 | 不支持(需布谷鸟过滤器) |
两种方案可以组合使用:布隆过滤器拦截绝对不存在的请求 + 缓存空值处理漏网的查询。
方案三:请求参数校验
在最外层拦截非法请求:
// 最简单也最有效:参数校验
public Object getItem(@PathVariable Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("无效的 ID");
}
return itemService.getById(id);
}
二、缓存击穿
定义:某个热点 Key 在过期的瞬间,大量并发请求同时打到数据库。
解决方案
方案一:互斥锁(推荐)
只允许一个线程查数据库,其他线程等待。
public Object getHotData(String key) {
// 1. 查缓存
Object value = redis.get(key);
if (value != null) {
return value;
}
// 2. 缓存未命中,尝试获取分布式锁
String lockKey = "lock:" + key;
boolean locked = redis.tryLock(lockKey, 10, TimeUnit.SECONDS);
try {
if (locked) {
// 双重检查:可能其他线程已经加载完了
value = redis.get(key);
if (value != null) {
return value;
}
// 查数据库并回填缓存
value = db.query(key);
redis.set(key, value, 30, TimeUnit.MINUTES);
return value;
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(50);
return getHotData(key); // 递归重试
}
} finally {
if (locked) {
redis.unlock(lockKey);
}
}
}
方案二:逻辑过期
缓存永不过期,但在 value 中存储逻辑过期时间。
@Data
public class CacheData {
private Object data;
private LocalDateTime expireTime; // 逻辑过期时间
}
public Object getWithLogicalExpire(String key) {
CacheData cacheData = redis.get(key);
if (cacheData == null) {
return null; // 数据不存在
}
// 未逻辑过期,直接返回
if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
return cacheData.getData();
}
// 已逻辑过期,尝试异步刷新
String lockKey = "refresh:" + key;
if (redis.tryLock(lockKey, 10, TimeUnit.SECONDS)) {
// 获取锁成功,异步刷新缓存
CompletableFuture.runAsync(() -> {
try {
Object newData = db.query(key);
CacheData newCache = new CacheData();
newCache.setData(newData);
newCache.setExpireTime(LocalDateTime.now().plusMinutes(30));
redis.set(key, newCache); // 永不设置 Redis TTL
} finally {
redis.unlock(lockKey);
}
});
}
// 返回旧数据(不等待刷新完成)
return cacheData.getData();
}
| 对比 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强(等待最新数据) | 弱(可能返回旧数据) |
| 可用性 | 较低(等待锁) | 高(总有数据返回) |
| 实现复杂度 | 中 | 中 |
| 适用场景 | 一致性要求高 | 可用性优先 |
方案三:永不过期 + 后台刷新
热点数据不设 TTL,由后台定时任务定期刷新。适合热点数据量可控的场景。详见热点 Key 处理。
三、缓存雪崩
定义:大量缓存同时过期,或缓存服务宕机,导致请求同时打到数据库。
与击穿的区别
| 对比 | 缓存击穿 | 缓存雪崩 |
|---|---|---|
| 影响范围 | 单个热点 Key | 大量 Key |
| 原因 | 热点 Key 过期 | 大批 Key 同时过期或 Redis 宕机 |
| 严重程度 | 中 | 极高 |
解决方案
方案一:随机 TTL
// 在基础 TTL 上增加随机偏移量,避免同时过期
public void setWithRandomTTL(String key, Object value, long baseTTL) {
// 基础 TTL ± 随机偏移(单位:秒)
long randomOffset = ThreadLocalRandom.current().nextLong(0, baseTTL / 5);
long actualTTL = baseTTL + randomOffset;
redis.set(key, value, actualTTL, TimeUnit.SECONDS);
}
方案二:多级缓存
即使 Redis 全部失效,本地缓存(Caffeine/Guava Cache)仍能提供服务。详见多级缓存架构。
方案三:熔断降级
@HystrixCommand(
fallbackMethod = "getDefaultData",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
}
)
public Object getData(String key) {
return cacheService.getById(key);
}
// 降级方法:返回兜底数据
public Object getDefaultData(String key) {
return defaultDataMap.get(key); // 静态兜底数据
}
方案四:Redis 高可用
- Redis Sentinel:自动故障转移
- Redis Cluster:数据分片 + 副本
- 多机房部署:跨机房容灾
雪崩预防清单
✅ TTL 加随机偏移量
✅ 多级缓存(本地缓存 + Redis)
✅ Redis 哨兵/集群高可用
✅ 熔断降级(Hystrix/Sentinel)
✅ 限流保护数据库
✅ 缓存预热(启动时加载热点数据)
✅ 监控告警(缓存命中率、Redis 内存)
四、三者总结对比
| 问题 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 定义 | 数据在 DB 中也不存在 | 热点 Key 过期 | 大量 Key 同时过期/Redis 宕机 |
| 原因 | 恶意攻击/无效请求 | 热点数据 TTL 到期 | TTL 集中/Redis 故障 |
| 影响 | 单个 Key 级别 | 单个热点 Key | 大面积 Key |
| 核心方案 | 布隆过滤器 + 缓存空值 | 互斥锁 / 逻辑过期 | 随机 TTL + 多级缓存 + 高可用 |
常见面试问题
Q1: 布隆过滤器为什么能防止缓存穿透?
答案:
布隆过滤器是一个概率型数据结构,使用位数组 + 多个哈希函数:
- 判定不存在 → 一定不存在:所有位都为 1 才可能存在
- 判定可能存在 → 不一定存在(有误判率)
将数据库中所有合法 Key 加入布隆过滤器,查询时先过滤。不在布隆过滤器中的请求直接拒绝,不会到达数据库。
误判率可以通过调整位数组大小和哈希函数个数来控制(通常 0.01%~0.1%)。
Q2: 互斥锁解决击穿时,没拿到锁的线程怎么办?
答案:
两种策略:
- 等待重试:
Thread.sleep(50ms)后递归重试查缓存,适合一致性要求高的场景 - 返回兜底数据:直接返回默认值或上一次的旧数据,适合可用性优先的场景
生产环境中,重试需要设置最大重试次数避免无限递归。
Q3: 如何预防缓存雪崩?
答案:
按优先级排列:
- TTL 加随机偏移:最简单有效,避免大批 Key 同时过期
- Redis 高可用:Sentinel 或 Cluster,防止整体宕机
- 多级缓存:本地缓存(Caffeine)兜底
- 熔断降级:数据库扛不住时返回降级数据
- 限流:保护数据库不被打垮
Q4: 如果穿透、击穿、雪崩同时发生怎么办?
答案:
这正是缓存架构设计时要考虑的整体防御:
请求 → 参数校验 → 布隆过滤器 → 本地缓存 → Redis → 互斥锁 → 数据库
↑防穿透 ↑防雪崩 ↑防击穿
配合限流、熔断、降级形成完整的防御体系。核心思想是层层过滤,逐级兜底。
Q5: 缓存空值方案和布隆过滤器如何配合使用?
答案:
public Object get(Long id) {
// 第一层:布隆过滤器拦截(拦截绝对不存在的 ID)
if (!bloomFilter.mightContain(id)) {
return null;
}
// 第二层:查缓存(包括空值缓存)
String key = "item:" + id;
Object cached = redis.get(key);
if ("NULL".equals(cached)) return null; // 空值缓存命中
if (cached != null) return cached;
// 第三层:查数据库
Object value = db.findById(id);
if (value == null) {
redis.set(key, "NULL", 5, TimeUnit.MINUTES); // 缓存空值
} else {
redis.set(key, value, 30, TimeUnit.MINUTES);
}
return value;
}
布隆过滤器拦截「一定不存在」的请求,空值缓存处理「布隆过滤器误判」的请求。两层防御,覆盖率更高。
相关链接
- Redis 缓存问题
- Redis 数据结构 - 布隆过滤器实现
- 缓存读写模式
- 热点 Key 处理
- 多级缓存架构