什么是缓存击穿、缓存穿透、缓存雪崩?
结论:三个词描述的是三种不同的"缓存挡不住请求"的失效模式。穿透是查一个根本不存在的数据,缓存永远不命中;击穿是一个热点 key 突然过期,瞬间请求全压到 DB;雪崩是大量 key 同时失效或 Redis 整体不可用。区分它们的关键是问一句:失效的是"数据不存在"、"单个热点"还是"一大片/整个缓存层"。
一、三种模式的对照
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 触发对象 | 不存在的 key | 单个热点 key | 大批 key 或整个 Redis |
| 触发原因 | 恶意/错误参数查不存在的数据 | 热点 key 到期或被删 | 同时过期、实例宕机、网络分区 |
| 请求特征 | 大量不同 key,全部 miss | 大量相同 key,全部 miss | 大量不同 key,同时 miss |
| DB 压力 | 持续高位,可能被打挂 | 瞬间尖峰 | 持续高位甚至全站不可用 |
| 主要对策 | 空值缓存、布隆过滤器、参数校验 | 互斥重建、逻辑过期、热点永不过期 | TTL 抖动、多级缓存、限流降级、高可用 |
| 兜底 | 限流 + 黑名单 | 本地缓存 + 单飞 | 熔断 + 降级返回默认值 |
二、穿透:让"不存在"也被缓存住
请求 id=-1 → 缓存 miss → 查 DB(无此记录)→ 不写缓存 → 下次再来
↑ 循环放大,DB 被无效查询打满
方案 A 空值缓存:查不到也写一个短 TTL 的空标记(如 60s)
方案 B 布隆过滤器:写入时把存在的 id 映射到 bitmap,查询先过过滤器
├─ 判定"不存在" → 直接返回(100% 准确)
└─ 判定"可能存在" → 放行去查缓存/DB(有小概率误判)
// 空值缓存 + 布隆过滤器双保险
public Item query(long id) {
if (!bloom.mightContain(id)) { // 一定不存在,直接短路
return null;
}
String key = "item:" + id;
String cached = redis.get(key);
if (cached != null) {
return EMPTY.equals(cached) ? null : JSON.parseObject(cached, Item.class);
}
Item item = itemMapper.selectById(id);
if (item == null) {
redis.set(key, EMPTY, Duration.ofSeconds(60)); // 空值也缓存,TTL 要短
return null;
}
redis.set(key, JSON.toJSONString(item), Duration.ofMinutes(30));
return item;
}
注意空值缓存的两个坑:TTL 过长会掩盖"后来真的创建了数据",要在写路径主动删掉空标记;海量随机 key 会撑爆内存,必须配合布隆过滤器或限流。
三、击穿:热点 key 的重建只能有一个线程
热点 key 过期瞬间:
1000 个请求同时 miss
├─ 无保护:1000 个线程同时查 DB → DB 尖峰
└─ 互斥重建:只有 1 个线程拿到锁去查 DB,其余等待/返回旧值
public Item queryHot(long id) {
String key = "hot:item:" + id;
Item cached = localCache.getIfPresent(key); // 第一层:本地缓存兜底
if (cached != null) { return cached; }
String lockKey = "lock:rebuild:" + id;
if (redis.setIfAbsent(lockKey, "1", Duration.ofSeconds(5))) {
try {
return loadAndCache(key, id); // 只有赢家查库并回填
} finally {
redis.delete(lockKey);
}
}
sleep(50);
return queryHot(id); // 落败者短暂等待后重试
}
另一种思路是逻辑过期:value 里带 expireAt,物理上永不过期,读到"逻辑过期"的数据时先返回旧值,再异步触发一个线程重建。它牺牲了短暂的一致性,换来零等待,适合对响应时间敏感的场景。
四、雪崩:把"同时"和"全挂"都拆掉
- TTL 加抖动:基础 30 分钟 + 0~5 分钟随机,避免同一秒集体过期;
- 预热 + 分批加载:大促前把热点数据提前灌入,重启后分批回源,别让应用启动瞬间全量查库;
- 多级缓存:Caffeine 挡一层,Redis 挂了仍有一段时间的服务能力;
- 限流 + 熔断:DB 侧设并发上限,缓存层不可用时快速失败并返回默认值;
- 高可用:主从 + 哨兵自动切换,Cluster 多分片,避免"整个 Redis 不可用"这种最坏情况。
# Resilience4j:给 DB 回源加并发闸门,防止雪崩时把库打穿
resilience4j.bulkhead.instances.dbLoad.maxConcurrentCalls: 50
resilience4j.circuitbreaker.instances.dbLoad.failureRateThreshold: 50
resilience4j.circuitbreaker.instances.dbLoad.waitDurationInOpenState: 10s
五、常见追问
- 问:布隆过滤器能删除元素吗?答:标准布隆不能删(会影响其他元素的位),需要删除就用计数布隆、Cuckoo Filter 或定期重建。
- 问:空值缓存和布隆过滤器怎么选?答:key 空间可枚举(如商品 ID)用布隆;key 空间无限(如用户输入的查询词)用空值缓存 + 参数校验。
- 问:逻辑过期会不会读到脏数据?答:会,读到的是上一次重建前的值,最长脏窗口 = 重建耗时。适合"能容忍短暂陈旧"的场景,如商品详情、榜单。
- 问:雪崩时最不该做什么?答:无限重试和全量回源。应该快速失败、限流、返回兜底数据,先保住数据库。
- 问:三者有没有共同的根因?答:有——缓存与 DB 之间的"流量放大"。所有方案本质都是三件事:减少无效回源、合并重复回源、给回源加闸门。
- 问:怎么证明方案有效?答:做故障演练:随机删除热点 key、批量让 key 过期、直接杀掉 Redis 实例,观察 DB QPS 与接口 P99 是否在可控范围。
小结
- 穿透看"数据是否存在",击穿看"是否单个热点",雪崩看"是否成片失效"。
- 对策分别落在:布隆/空值、互斥/逻辑过期、抖动/多级/限流。
- 三者最终都指向同一件事:DB 的回源流量必须可被限流与合并。