跳到主要内容

缓存穿透、击穿与雪崩

问题

缓存穿透、缓存击穿、缓存雪崩分别是什么?如何解决?

答案

这三个问题是缓存系统最经典的异常场景,面试必问。它们的共同危害是大量请求直接打到数据库,导致数据库压力暴增甚至宕机

一、缓存穿透

定义:查询的数据在缓存和数据库中都不存在,每次请求都穿透缓存直达数据库。

常见原因

  • 恶意攻击:构造大量不存在的 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: 互斥锁解决击穿时,没拿到锁的线程怎么办?

答案

两种策略:

  1. 等待重试Thread.sleep(50ms) 后递归重试查缓存,适合一致性要求高的场景
  2. 返回兜底数据:直接返回默认值或上一次的旧数据,适合可用性优先的场景

生产环境中,重试需要设置最大重试次数避免无限递归。

Q3: 如何预防缓存雪崩?

答案

按优先级排列:

  1. TTL 加随机偏移:最简单有效,避免大批 Key 同时过期
  2. Redis 高可用:Sentinel 或 Cluster,防止整体宕机
  3. 多级缓存:本地缓存(Caffeine)兜底
  4. 熔断降级:数据库扛不住时返回降级数据
  5. 限流:保护数据库不被打垮

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;
}

布隆过滤器拦截「一定不存在」的请求,空值缓存处理「布隆过滤器误判」的请求。两层防御,覆盖率更高。

相关链接