缓存穿透、击穿、雪崩的区别与防护
更新时间:2026-09-18。这三个名字像、场景却完全不同,面试和生产事故里都高频出现。区分它们的钥匙是「请求打到的 key 存不存在、是一个还是一批」。本篇配合 Redis 数据结构 与 BigKey/HotKey 诊断 阅读;缓存与库的最终一致性另见 缓存与数据库一致性。
本文要回答的问题
- 穿透、击穿、雪崩到底差在哪?为什么不能用一个方案包打天下?
- 布隆过滤器原理是什么?误判了怎么办?
- 热点 key 失效瞬间,如何只让一个请求去回源?
- 大批量 key 同时过期、Redis 整节点宕机分别怎么扛?
- 空值缓存、逻辑过期、互斥锁各自的代价和适用面?
一、三者一图分清

| 名称 | key 是否存在 | 范围 | 本质 |
|---|---|---|---|
| 穿透 | DB 中也不存在 | 任意多个非法 key | 无效查询绕过缓存 |
| 击穿 | 存在,且是热点 | 单个 key 过期瞬间 | 热点失效并发回源 |
| 雪崩 | 存在 | 大批 key 同时过期 / 缓存宕机 | 缓存层整体失守 |
二、缓存穿透:请求「不存在的数据」
典型:攻击者用大量不存在的 id(如 id=-1 或随机 UUID)刷接口。缓存不命中、查库也是空,于是每次请求都落 DB。
方案 1:缓存空值(简单直接)
查 DB 为空,也把「空标记」写进缓存,设较短 TTL:
GET cache:user:999 -> nil
SELECT ... -> 空
SET cache:user:999 "__NULL__" EX 60 # 短 TTL,防脏
# 后续 60s 内直接命中空标记,不再打 DB- 优点:实现简单、立即见效。
- 缺点:不存在的 key 极多时会写入大量空 key(内存占用);空值 TTL 内真实数据新建会有短暂不一致(新建时主动 DEL 空标记)。
方案 2:布隆过滤器(挡在缓存前)
把所有「可能存在」的 key 哈希进一个位数组。查询先问布隆:
- 布隆说不存在 → 一定不存在,直接拒绝,不查缓存/DB;
- 说存在 → 可能存在(有假阳性,没有假阴性),再走缓存→DB。
写入/新增数据时:bf.add(key)
查询时:
if not bf.exists(key): return 不存在 # 100% 拦
else: 走缓存 -> DB# RedisBloom 模块
BF.RESERVE bf:user 0.01 1000000 # 误判率1%、预期100万元素
BF.ADD bf:user 1001
BF.EXISTS bf:user 999999 # 不存在 key 返回 0| 方案 | 挡住比例 | 代价 |
|---|---|---|
| 空值缓存 | 命中过一次的非法 key | 空 key 占内存、短 TTL 不一致 |
| 布隆过滤器 | 全部「确定不存在」 | 有误判(说有其实没有)、需随数据更新维护 |
| 参数校验/id 合法性 | 非法格式(负数等) | 最廉价,应在最外层先做 |
坑一:布隆过滤器会误判但不会漏判。 它说「可能有」时仍可能查空,所以即便用了布隆,回源空结果仍建议配合短空值缓存;同时新增数据要同步加入布隆,否则合法新 key 被误拦(漏判在工程上来自「忘记 add」,不是算法本身)。
三、缓存击穿:单个热点 key 失效的瞬间
热点 key(爆款商品、首页配置)QPS 极高,TTL 到期那一瞬间,成百上千个并发请求同时 miss 并回源,DB 瞬时被打满。
方案 1:互斥锁(只放一个请求去重建)
GET key miss -> SETNX lock:key 1 EX 10
抢到锁:查 DB -> 回填缓存 -> 释放锁
没抢到:sleep 短时间后重试 GET(别人会填好)// 伪代码:互斥重建,避免惊群
String v = redis.get(k);
if (v == null) {
String lock = "lock:" + k;
if (redis.set(lock, "1", SET_IF_NOT_EXIST, EXPIRE, 10)) {
try {
v = db.query(k); // 只有持锁者回源
redis.setex(k, 3600, v);
} finally { redis.del(lock); }
} else {
Thread.sleep(50);
return getWithMutex(k); // 重试读缓存
}
}
return v;要点:锁必须带过期(防持锁者崩溃死锁)、value 用唯一标识在释放时校验(避免误删别人的锁,或用 Lua 原子比对删除)、设置重试上限防雪崩式递归。
方案 2:逻辑过期(不设物理 TTL,值内嵌过期时间)
缓存本身不设 TTL,value 里存 data + logicalExpireTime。读到发现逻辑过期:
- 先立即返回旧值(保可用);
- 异步起一个线程抢锁去回源刷新;
- 其他请求继续返回旧值。
适合「可容忍短暂旧数据、绝不能阻塞」的热点;不适合要求强一致的字段。
方案 3:热点 key 永不过期 + 主动更新
由后台在数据变更时主动刷新缓存(配合消息/定时任务),热点 key 物理上不设 TTL。结合 缓存一致性 的更新策略。
| 方案 | 一致性 | 可用性/延迟 | 复杂度 |
|---|---|---|---|
| 互斥锁 | 好 | 未持锁者要等待/重试 | 中 |
| 逻辑过期 | 短暂旧数据 | 始终快(返回旧值) | 中 |
| 热点不过期+主动更新 | 依赖更新链路 | 最好 | 高(要保更新不丢) |
坑二:互斥锁要防「锁被别人抢走后误删」。 持锁线程若在 DB 慢查询中超过锁 TTL,锁自动释放并被他人获取,原线程 finally 里一把 DEL 会删掉别人的新锁。规范做法是 value 存唯一 token,释放用 Lua「比较 token 再删」,且回源最好带熔断,避免持锁者把 DB 拖死。
四、缓存雪崩:面状失效
两种成因,处置不同:
成因 A:大批 key 同时过期
# 反例:批量预热时统一 TTL
SET k1 ... EX 3600
SET k2 ... EX 3600 # 3600s 后整批同时失效- TTL 加随机抖动:
ttl = base + random(0, 300s),把失效打散。 - 分类设置不同基础过期时间,避免同批同 TTL。
- 过期前由后台异步预热/续期,而不是等用户请求触发重建。
成因 B:缓存节点整体不可用
- 多级缓存:本地缓存(Caffeine/Guava)+ 分布式 Redis,热点在本地挡一层。
- 高可用:Redis Sentinel/Cluster,故障自动切换(见 主从与哨兵、Cluster)。
- 限流/降级/熔断:DB 前加并发闸(如 Sentinel/RateLimiter),回源失败返回默认值/兜底页,保住 DB 不被打垮。
- 持久化:重启后快速恢复热点(见 RDB/AOF)。
| 防护层 | 手段 |
|---|
五、组合拳:一套落地读写路径

六、常见误区
- 拿空值缓存去解决雪崩:空值只挡非法 key,挡不住大批合法热点同时过期。
- 布隆过滤器当成 100% 精确:它有假阳性(说有其实没有),回源仍可能为空,要配合空值缓存。
- 加了互斥锁却忘了 TTL/唯一 token:持锁者宕机导致死锁,或超时误删他人锁,反而把单 key 问题放大。
- 所有 key 统一 TTL 做预热:必然在同一时刻整批过期,是雪崩制造机。
- 只防 DB、不限流:缓存整体故障时回源洪峰没有闸门,DB 仍会被冲垮。
七、相关与延伸
数据结构与热点见 Redis 数据结构、BigKey/HotKey 诊断;缓存高可用见 主从与哨兵、Cluster;更新一致性见 缓存与数据库一致性。
总结
缓存穿透/击穿/雪崩:穿透是查 DB 中也不存在的数据、请求恒落库,用参数校验 + 布隆过滤器(说不存在必不存在、有假阳性)+ 短 TTL 空值缓存;击穿是单个热点 key 过期瞬间并发回源,用带 TTL 和唯一 token 的互斥锁只放一个请求重建,或用逻辑过期返回旧值异步刷新、热点 key 不设物理 TTL 主动更新;雪崩是大批 key 同时过期(TTL 加随机抖动、分类 TTL、后台预热)或缓存整体宕机(多级缓存、主从/集群高可用、持久化、限流熔断降级保 DB);三者 key 是否存在、单点还是面状不同,不能用一个方案混用,互斥锁务必防死锁与误删。
| 过期打散 | TTL + 随机值、分类 TTL、后台续期 | | 架构 | 多级缓存、主从/集群高可用、持久化 | | 兜底 | 限流、熔断、降级默认值、只读开关 |