缓存击穿、穿透、雪崩:三种失效场景与解法
这三个词经常被混着用,但它们失效的对象、触发的条件和该上的解法完全不同。这篇用一组 200 并发的实测数据把三者拆开,给出各自的方案对比表和可落地的代码。
"缓存击穿、穿透、雪崩"几乎是缓存话题里出现频率最高的三个词,也是最容易被含糊带过的三个词。常见的翻车方式是:不管问哪一个,都答"加随机过期时间 + 布隆过滤器"。
但给击穿加随机过期时间是没有意义的——击穿只涉及一个 key,随机化一个 key 的 TTL 并不能防止它在某一瞬间被并发打穿。三种问题的失效对象、触发条件、解法都不重叠,分清楚比背方案重要得多。
先看一组实测数据。某分类树缓存(整棵树序列化成一个 key,几十个节点),读路径是标准的 Cache Aside:
| 耗时 | 数据库访问 | |
|---|---|---|
| 第一次(未命中) | 60 ms | 1 次 |
| 第二次(命中) | 0.00 ms | 0 次 |
命中与未命中之间差了两个数量级。缓存的全部价值都建立在这条落差上,而三种失效场景要防的就是这条落差被同时兑现很多次。
一、先把三者拆开
三个词描述的是三件事:
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 数据是否存在 | 不存在 | 存在 | 存在 |
| 失效的对象 | 一个查不到的 key | 一个热点 key 的过期瞬间 | 一大批 key 同时失效,或缓存实例整体故障 |
| 请求特征 | 同一批无效参数被反复查询 | 高并发恰好撞上重建窗口 | 全站请求同时涌向数据库 |
| 数据库承受的 | 大量无效查询 | 单个 key 引发的并发回源 | 全量查询 |
| 典型触发原因 | 爬虫、恶意构造的 id、脏数据 | 大 key 过期、热点商品 | 批量预热时设了同一个 TTL、实例宕机 |
一个便于记忆的类比:
- 穿透是"查一个根本不存在的东西",每次都要去数据库确认一遍
- 击穿是"一个很热的东西过期了",所有并发同时去重建
- 雪崩是"很多东西同时过期了"或者"整个缓存服务没了"
下面逐个展开。
二、击穿:单个热点 key 的重建瞬间
问题有多大
分类树这类数据"读极多、写极少",平时很轻松,问题全在 key 过期那一瞬间。实测 200 个并发请求恰好撞上 key 过期:
| 方案 | 回源数据库次数 | 结果分布 |
|---|---|---|
| 无锁裸奔 | 200 次 | 200 个请求全部去查库、各自组装一遍树 |
| 加互斥锁 | 1 次 | 1 个请求重建,其余 199 个等待后命中 |
区别不是"快一点",而是回源次数从 200 变成 1。如果这个 key 对应的是更昂贵的查询(多表 join、聚合统计),200 次并发回源足以把数据库连接池打满,进而拖垮整个服务。
三种解法
| 方案 | 做法 | 代价 | 适合什么数据 |
|---|---|---|---|
| 永不过期 + 主动更新 | key 不设 TTL;数据变更时由服务端主动重建覆盖 | 需要保证"所有写入口都会刷新缓存" | ✅ 数据量小、变更低频(如分类树、配置表) |
| 互斥锁重建 | SET NX 抢锁,抢到的回源重建,其余短暂等待后重读 |
等待方有延迟;要处理锁超时 | ✅ 通用方案,任何热点 key |
| 逻辑过期 | value 里带过期时间戳,key 永不真正过期,读到过期数据时异步刷新 | 会短暂返回旧值;实现更复杂 | 热点商品详情、榜单这类可以接受旧值的数据 |
对于分类树这类数据,推荐的做法是第一种:一年也改不了几次,那就干脆不设 TTL,改的时候主动刷新——击穿问题从根上就没有发生的机会。这不是"绕过去",而是识别出"过期"这个机制在这里根本不必要。
互斥锁的完整写法如下。加锁用 SET NX EX,释放锁用 Lua 保证只删自己的锁:
-- rebuild_lock.lua:尝试获取重建锁
-- KEYS[1] = 锁 key,ARGV[1] = 持有者标识,ARGV[2] = 锁 TTL(秒)
return redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) and 1 or 0
-- unlock.lua:只有持有者才能删
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
public String getCategoryTree() {
String cached = redis.get(TREE_KEY);
if (cached != null) {
return cached; // 命中,直接返回
}
String token = UUID.randomUUID().toString();
boolean locked = tryLock(TREE_KEY + ":lock", token, 10);
if (locked) {
try {
// 拿到锁后二次检查:可能已经被前一个持锁者重建好了
cached = redis.get(TREE_KEY);
if (cached != null) {
return cached;
}
String tree = buildTreeFromMysql(); // 只有一个线程会走到这里
redis.set(TREE_KEY, tree); // 分类树不设 TTL
return tree;
} finally {
unlock(TREE_KEY + ":lock", token);
}
}
// 没抢到锁:短暂等待后重试,避免所有等待者同时打库
sleep(50);
cached = redis.get(TREE_KEY);
return cached != null ? cached : buildTreeFromMysql(); // 兜底:锁等待超时,自己查
}
有两个细节值得注意。
第一,拿到锁之后要二次检查。 在抢锁成功到执行业务之间,上一个持锁者可能已经把缓存重建完了,这时直接返回即可,不必再查一次库。
第二,兜底分支不能少。 如果锁被占用、等待后缓存依然为空(比如重建失败了),必须允许自己回源,否则请求会一直失败。互斥锁的目的是"削峰",不是"拒绝服务"——宁可多回源几次,也不能让请求卡死。
逻辑过期的写法则是把过期信息放进 value 本身:
// value 结构:{"data": {...}, "expireAt": 1735000000000}
public String getWithLogicalExpire(String key) {
CacheValue cv = redis.get(key);
if (cv == null) {
return null; // 真·未命中,通常配合预热
}
if (cv.expireAt > System.currentTimeMillis()) {
return cv.data; // 逻辑上没过期
}
// 逻辑上已过期:先返回旧值,再异步刷新(只让一个线程去刷)
if (tryLock(key + ":refresh", token, 10)) {
executor.submit(() -> {
try {
String fresh = loadFromDb(key);
redis.set(key, new CacheValue(fresh, System.currentTimeMillis() + 600_000));
} finally {
unlock(key + ":refresh", token);
}
});
}
return cv.data; // 用户拿到的是旧值,但没有等待
}
它和互斥锁的关键差别是:互斥锁让请求"等",逻辑过期让请求"先拿到旧值"。前者保证一致性、牺牲延迟;后者保证延迟、牺牲一致性。数据能容忍短暂陈旧时,后者体验更好。
三、穿透:查一个不存在的东西
穿透的触发条件是查询的数据在数据库里也不存在。这样缓存永远不会被写入,每一次请求都要去数据库确认一遍"真的没有",缓存完全失效。恶意请求只要构造大量随机 id,就能把数据库的查询压力放大到与攻击流量等量。
方案一:缓存空值
最简单直接——查不到也缓存起来,值设为空串或特殊标记:
# 查库返回 null 时,写入一个短 TTL 的空值占位
SET category:detail:999999 "__NULL__" EX 60
读取端判断即可:
String val = redis.get(key);
if ("__NULL__".equals(val)) {
throw new BizException("数据不存在"); // 挡住后续所有请求
}
if (val == null) {
String db = queryFromDb(key);
redis.set(key, db == null ? "__NULL__" : db, db == null ? 60 : 3600);
...
}
要点是 TTL 必须短:空值缓存占用内存却没有业务价值,而且一旦数据后来被创建,短 TTL 能让它尽快恢复正常。经验值是几十秒到几分钟。同时,如果攻击者用海量不同的随机 key,空值缓存本身会变成内存炸弹,所以它通常要和参数合法性校验 + 限流一起用。
方案二:布隆过滤器
布隆过滤器是一个位数组加 k 个哈希函数。写入元素时,算出 k 个位置,全部置 1;查询时同样算出 k 个位置,只要有一个是 0,就说明这个元素一定不存在;全是 1 则"可能存在"。
它的特性正好卡在穿透场景上:
| 特性 | 说明 |
|---|---|
| 说不存在 → 一定不存在 | ✅ 这正是拦截穿透所需要的能力 |
| 说存在 → 可能误判 | 误判率由 位数组长度 m、元素数 n、哈希个数 k 决定,公式为 (1 - e^(-kn/m))^k |
| 占用空间 | 每个元素约 10 bit 就能把误判率压到 1% 以下,远小于存全量 key |
| 能否删除 | 不能。删一个元素会影响其他元素的位;需要删除时用计数布隆过滤器 |
| 能否列出元素 | 不能,只有"在不在" |
标准用法是在查询链路最前面加一道闸:
// 启动时把全量合法 id 灌进布隆过滤器(Redisson 提供 RBloomFilter)
RBloomFilter<Long> bloom = redisson.getBloomFilter("category:ids");
bloom.tryInit(1_000_000L, 0.01); // 预计 100 万条,误判率 1%
// 新增数据时同步写入
bloom.add(newId);
// 查询时先过闸
if (!bloom.contains(id)) {
return null; // 一定不存在,连 Redis 都不用查
}
// 可能存在 → 走正常的缓存 + 回源流程
需要注意的是它需要预热:启动时必须把数据库里已有的合法 id 全部灌一遍,否则真实存在的数据会被误判为不存在,导致漏查。另外数据删除后布隆过滤器不会同步,这时只能靠上层的空值缓存或短 TTL 兜底。
方案三:入口校验
最便宜的一层:如果 id 是自增主键,那么 id <= 0 或者超过当前最大值的请求,直接在参数校验阶段拒掉,连缓存都不用碰。很多穿透问题的第一道防线其实是接口参数规范,而不是任何中间件。
四、雪崩:大面积同时失效
雪崩有两种形态,解法不同。
形态一:大批 key 同一时刻过期
典型来源是批量预热:系统启动或大促前,把几万个 key 一次性写进缓存,还都设了同一个 30 分钟 TTL。那么 30 分钟后,它们会在同一秒集体消失。
解法是给过期时间加随机扰动:
// ❌ 批量预热时,T 秒后集体失效
redis.set(key, value, 1800, TimeUnit.SECONDS);
// ✅ 在基础 TTL 上叠加随机量,把失效时间摊开
int base = 1800; // 基础 30 分钟
int jitter = ThreadLocalRandom.current().nextInt(300); // 0~300 秒随机
redis.set(key, value, base + jitter, TimeUnit.SECONDS);
也可以让随机比例与 TTL 挂钩(比如 TTL 的 10%),这样长 TTL 的 key 摊得更开。原则是让失效时间分散到一个足够长的窗口里,让回源压力被时间稀释。
形态二:缓存实例整体不可用
这种情况下"打散 TTL"毫无意义——不是 key 没了,是缓存服务没了。要靠另一组手段:
| 手段 | 作用 |
|---|---|
| 多级缓存(本地 Caffeine + Redis) | Redis 挂了,本地缓存仍能挡住一部分读;本地缓存容量小,只放最热的少量 key |
| 熔断降级(Sentinel 等) | 数据库压力超过阈值时,对非核心接口直接返回兜底数据,保住数据库 |
| 限流 | 在网关层限制进入的流量,宁可拒绝一部分请求,也不能让数据库被打死 |
| 集群 + 哨兵/Cluster | 消除单点,故障时自动切换 |
| 持久化 | 重启后能快速恢复数据,减少冷启动窗口 |
这里有个反直觉但重要的取舍:雪崩时真正要保的不是"所有请求都成功",而是"数据库活着"。 数据库一旦被打死,恢复时间是分钟级的,期间所有请求都失败;而主动拒绝 30% 的流量,至少还有 70% 的用户能用。
补充:热点 key 打散
还有一种介于击穿和雪崩之间的情况:某个 key 的 QPS 高到单节点 Redis 都扛不住(比如头部主播的直播间、爆款商品的库存)。它不是"失效"问题,而是单分片的吞吐天花板。
常见做法是给热点 key 加随机后缀做分片:
// 把一份热点数据复制成 N 份,读请求随机选一份
int shard = ThreadLocalRandom.current().nextInt(10);
String key = "hot:item:" + itemId + ":" + shard; // 读:随机分片
写的时候要同步更新所有分片,所以它只适合读远多于写的热点数据。代价是内存放大 N 倍、写逻辑变复杂。另外要提醒自己:大 key 的删除也是阻塞源,一个几 MB 的 key 被 DEL 时可能卡住主线程,让所有请求一起超时——这种"卡顿"引发的回源风暴,表现和雪崩一样。大 key 应该用 UNLINK 异步删除,并尽量在业务上拆小。
五、三种场景的方案对照
把前文的方案汇总成一张选型表:
| 场景 | 首选 | 次选 | 兜底 |
|---|---|---|---|
| 穿透 | 入口参数校验 | 空值缓存(短 TTL) | 布隆过滤器 |
| 击穿 | 永不过期 + 主动更新(低频变更数据) | 互斥锁重建 | 逻辑过期异步刷新 |
| 雪崩 | TTL 加随机打散 | 多级缓存 | 熔断降级 + 限流 |
| 热点 key | 随机后缀分片 | 本地缓存 | 读写分离 / 独立集群 |
还有一条最容易被忽略的:缓存不可用时的降级路径必须提前写好。分类树这类数据量小,Redis 挂了可以直接查 MySQL 全表重建,性能降级但可用;而像热搜榜这种非核心展示数据,缓存挂了干脆不展示,也不该让数据库承担这份流量。"缓存挂了怎么办"应该和"缓存怎么用"一起设计,而不是等出事再想。
小结
- 三者失效的对象不同:穿透是数据不存在,击穿是单个热点 key 的过期瞬间,雪崩是大量 key 同时失效或实例故障,方案不通用
- 击穿的核心指标是回源次数:200 并发无锁会打 200 次库,互斥锁后压到 1 次
- 击穿的三种解法各有取舍:永不过期+主动更新最彻底(适合低频变更),互斥锁最通用(请求等待),逻辑过期体验最好(返回旧值)
- 互斥锁要二次检查 + 兜底回源,目的是削峰而不是拒绝服务
- 穿透用参数校验 → 空值缓存 → 布隆过滤器三层,布隆过滤器只能保证"说不存在一定不存在",且不支持删除、必须预热
- 雪崩要区分"批量过期"(TTL 加随机)和"实例故障"(多级缓存、熔断降级、限流、集群),后者的目标是保住数据库而不是保住所有请求
- 热点 key 单分片扛不住时用随机后缀分片,但只适用于读远多于写的场景
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。