缓存穿透、击穿、雪崩:三个总被混为一谈的故障
面试里被问「缓存击穿和雪崩有什么区别」,十个人里有六个答不上来——不是因为难,是因为这两个词在国内的流传过程中被混用了。
这篇先把定义钉死,再给解法。
先分清:查询的三种「没拿到」
所有缓存故障,都可以还原成一个问题:一次读请求没拿到想要的数据,它去哪了?
| | 故障 | 数据存不存在 | 缓存里有没有 | 请求打到哪
| 穿透 | 不存在 | 从来没有 | 每次都打到 DB
| 击穿 | 存在 | 恰好过期 / 被删 | 瞬间全部打到 DB
| 雪崩 | 存在 | 大面积失效 | 大量请求打到 DB
一句话记忆:
- 穿透 = 查的东西压根没有,缓存挡不住,因为它不知道你要什么
- 击穿 = 查的东西一直有,缓存刚好失效,那一个瞬间挡不住
- 雪崩 = 查的东西一直有,缓存整体垮了,谁都挡不住
命名上要留意:击穿在英文里是 Cache Breakdown,也常被叫 Hotspot Invalidation;雪崩是 Cache Avalanche。但很多团队的文档里这两个是互换的。面试时先问对方「你们怎么定义」,比背定义有用。
一、缓存穿透:查不存在的数据
现象
攻击者用一个不存在的 ID 列表刷你的接口:/user?id=1、/user?id=2……
每个请求都是:查缓存 → 未命中 → 查 DB → 未找到 → 不写缓存。DB 被打满,而缓存里一个字节都没多。
更隐蔽的版本:某个字段线上是 null,比如 user.phone,大量用户没填。你查了 100 万次 null,缓存永远是空的。
解法
① 缓存空值
查不到也写进去,TTL 设短(30 秒到 5 分钟):
User u = cache.get(key);
if (u == null) {
u = db.query(id);
if (u == null) {
cache.set(key, NULL_PLACEHOLDER, 60); // 关键
} else {
cache.set(key, u, 1800);
}
}
简单有效,但拦不住「攻击者每次换一个不存在的 ID」——空值缓存只对重复查询同一个 key 有效。
② 布隆过滤器
启动时把所有合法 ID 灌进布隆位图。查询时先问它:
if (!bloom.mightContain(id)) {
return NOT_FOUND; // 一定不存在,直接返回,不碰 DB
}
它有假阳性(说「可能在」但其实不存在),但不会有假阴性(说「不在」就是真不在)。所以它适合做第一道拦截,把绝大多数恶意请求挡在 DB 之外。
代价:位图有空间开销,且删除元素困难(需要 Counting Bloom 或定期重建)。
③ 参数校验
最朴素也最容易被忽略:id 是不是正数、是不是合法格式。不合法直接 400,别往下传。
二、缓存击穿:一个热点 key 失效的瞬间
现象
有个爆款商品的 key,QPS 一万。平时缓存命中,风平浪静。
某天这个 key 到了 TTL,或者被人手动删了。接下来 1 秒内,一万个请求同时发现缓存没了,同时去查 DB,同时试图写回。
DB 被打出一个尖峰,然后可能就挂了。
解法
① 互斥锁(single flight)
只让第一个线程去查 DB,其他线程等它查完再读缓存:
Object lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
if (lock.tryLock(0, TimeUnit.MILLISECONDS)) {
try {
// 双重检查:等锁期间可能别人已经填好了
Object v = cache.get(key);
if (v == null) {
v = loadFromDB(key);
cache.set(key, v);
}
return v;
} finally {
lock.unlock();
locks.remove(key); // 必须移除,否则内存泄漏
}
} else {
// 没抢到锁,短暂自旋后重读缓存
return retryReadCache(key);
}
两个坑:锁一定要在 finally 里 remove,否则 key 多了锁表也跟着涨;另外要双重检查,因为你等锁的这段时间里,别人可能已经把值填进缓存了。
② 逻辑过期
不给 key 真的设 TTL,而是在值里塞一个 expireAt 时间戳:
Record {
V value; long expireAt; }
V v = cache.get(key);
if (v != null) {
if (v.expireAt > now()) return v.value;
// 逻辑上过期了,但物理上还在,直接返回旧值
asyncRefresh(key); // 后台异步刷新,不阻塞
return v.value;
}
好处是永远不会出现「全部同时失效」——刷新是异步的,一个一个来。代价是牺牲了短暂的数据新鲜度,换可用性。在电商大促这种场景,这个取舍很划算。
三、缓存雪崩:整体垮掉
现象
两种触发方式:
- 大量 key 同一时刻过期。比如批量预热时 TTL 都设成了 3600 秒,那这一批就在整点同时炸。
- 缓存服务本身挂了。Redis 主节点故障、网络分区、连接池打满。
区别在于:击穿是一个 key,雪崩是一大片。
解法
① 过期时间打散
不要所有 key 用同一个 TTL。加随机:
long ttl = baseTtl + ThreadLocalRandom.current().nextLong(0, 600); // 0~10min 抖动
cache.set(key, value, ttl);
一行代码,把「同时过期」变成「陆续过期」。
② 多级缓存
本地缓存 + Redis。本地缓存在 Redis 挂掉时还能扛一波:
// L1:进程内 Caffeine,TTL 短
V v1 = localCache.get(key);
if (v1 != null) return v1;
// L2:Redis
V v2 = redis.get(key);
if (v2 != null) {
localCache.put(key, v2, 30, SECONDS); return v2; }
// 都 miss,走 DB
V v3 = loadFromDB(key);
localCache.put(key, v3, 30, SECONDS);
redis.set(key, v3);
return v3;
代价是一致性问题被放大:本地缓存里的数据在多台机器上不一致,容忍窗口最长 30 秒。业务上能不能接受这个窗口,是选型的前提。
③ 熔断降级
Redis 挂了不要让请求全部堆到 DB 上。挂个熔断器,DB 的 QPS 超过阈值就快速失败:
if (!circuitBreaker.tryAcquire()) {
return DEGRADED_RESPONSE; // 返回兜底数据,而不是拖死 DB
}
宁可返回「稍后重试」,也不要把 DB 一起拖下水。
④ 随机过期 + 提前预热
Redis 重启后是空的,那一刻所有流量都会打到 DB。所以:重启前预热常用数据;给每个节点的缓存 TTL 加随机偏移,避免所有节点同时冷启动。
三个故障的对照表
| | | 穿透 | 击穿 | 雪崩
| 本质 | 查不存在的 | 单点失效 | 大面积失效
| 触发 | 恶意/异常参数 | 热点 key 过期 | 批量过期/服务挂
| 首选解 | 布隆过滤器 | 互斥锁/逻辑过期 | TTL 打散 + 多级缓存
| 兜底 | 空值缓存 | 双重检查 | 熔断降级
| 监控指标 | 缓存 miss 率 | 单 key 的 DB 回源 QPS | 整体命中率断崖
最后一句
这三个问题的共同点是:请求绕过缓存直接打到 DB,而 DB 是整条链路上最脆弱的一环。
所以设计缓存时不要只想着「命中率」,要想「最坏情况下会发生什么」。缓存系统的设计难度,从来不在正常路径上。