
缓存穿透、击穿与雪崩:成因、方案、取舍
关键词标签:缓存穿透 · 缓存击穿 · 缓存雪崩 · 布隆过滤器 · 逻辑过期
一个常被忽略的事实是:缓存层真正的敌人往往不是"缓存失效",而是"缓存失效那一刻,请求并没有消失"。 缓存只是把"读"从慢介质搬到快介质,它并不改变请求量本身。当大量请求在同一时间、针对同一份不存在或刚过期的数据涌向数据库时,缓存的高可用反而成了放大器。
本系列前面讲过线程池与并发工具的背景,这里我们把视角切到缓存这一层,从成因、源码级行为、方案与取舍四个维度拆开讲。下文中的 order-service 仅为讲解示例,非真实系统。
一、三个问题,本质是三种"失配"
很多人把穿透、击穿、雪崩混为一谈,其实它们对应三种不同的失配:
| 问题 | 触发条件 | 失配点 |
|---|---|---|
| 穿透 | 查询根本不存在的数据 | 缓存永远不命中,请求直落 DB |
| 击穿 | 单个热点 key 过期 | 大量并发同时重建同一份缓存 |
| 雪崩 | 大量 key 同时过期 / 缓存整体不可用 | 请求量整体压到 DB |
穿透的根因是"缓存无法表达不存在"——null 默认不写缓存,于是每次请求都穿透。击穿的根因是"重建动作没有互斥"。雪崩的根因是"过期时间同质化"或"缓存层单点"。
理解这三者,关键看请求命中路径。以 Spring 的 Cache 抽象为例,Cache.get(key) 未命中时会走 CacheLoader(Spring 的 @Cacheable 默认是同步加载),而 Redis 场景下通常手写:
public Order getOrder(Long id) {
String key = "order:" + id;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Order.class);
}
// 未命中:查库
Order order = orderMapper.selectById(id);
if (order != null) {
redis.set(key, JSON.toJSONString(order), 300, TimeUnit.SECONDS);
}
return order;
}
这段代码有两个经典缺陷:order == null 时不写缓存(穿透),redis.set 没有互斥(击穿)。
二、穿透:让"不存在"也能被缓存
2.1 空值缓存
最简单的方式是把 null 也缓存起来,但用一个短 TTL:
private static final String NULL_MARK = "__NULL__";
public Order getOrder(Long id) {
String key = "order:" + id;
String cached = redis.get(key);
if (NULL_MARK.equals(cached)) {
return null;
}
if (cached != null) {
return JSON.parseObject(cached, Order.class);
}
Order order = orderMapper.selectById(id);
if (order == null) {
// 空值缓存,TTL 短一些,避免长期占用内存
redis.set(key, NULL_MARK, 60, TimeUnit.SECONDS);
return null;
}
redis.set(key, JSON.toJSONString(order), 300, TimeUnit.SECONDS);
return order;
}
边界情况:如果攻击者用随机 id 持续请求,空值缓存会被大量无意义 key 填满,反而消耗内存。所以空值缓存只适合"可枚举但有上限"的查询,不适合完全开放的 id 空间。
2.2 布隆过滤器
布隆过滤器的价值在于:它能在常数空间内回答"这个 key 一定不存在"。注意措辞——它只能确定"不存在",不能确定"存在"(存在假阳性)。
Redis 从 4.0 开始提供 BF.ADD / BF.EXISTS 指令(RedisBloom 模块),Java 侧可用 Redisson 的 RBloomFilter:
RBloomFilter<Long> bloom = redisson.getBloomFilter("order:bloom");
bloom.tryInit(1_000_000L, 0.01); // 预计元素数、误判率
public Order getOrder(Long id) {
if (!bloom.contains(id)) {
return null; // 一定不存在,直接返回
}
// 走正常缓存逻辑
return loadFromCacheOrDb(id);
}
取舍:布隆过滤器不支持删除(标准实现),数据删除后需要重建或使用计数布隆过滤器(Counting Bloom Filter)。它适合"写入后很少删除"的场景,比如订单 id、用户 id。
三、击穿:给重建动作加互斥
热点 key 过期瞬间,N 个线程同时发现未命中,同时查库。解决方案是只让一个线程重建,其余等待。
3.1 分布式锁 + 双重检查
public Order getOrder(Long id) {
String key = "order:" + id;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Order.class);
}
String lockKey = "lock:order:" + id;
// 尝试加锁,等待 100ms,持有 3s
boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (!locked) {
// 没抢到锁,短暂自旋后重试读缓存
sleepQuietly(50);
return getOrder(id);
}
try {
// 双重检查:可能已被其他线程重建
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Order.class);
}
Order order = orderMapper.selectById(id);
if (order != null) {
redis.set(key, JSON.toJSONString(order), 300, TimeUnit.SECONDS);
}
return order;
} finally {
redis.delete(lockKey);
}
}
注意 setIfAbsent 的原子性:Redis 的 SET key value NX PX 是原子的,不要用 if (!exists) set 这种两步操作。
误区:递归重试没有深度限制,极端情况下可能栈溢出。生产代码里通常用循环 + 最大重试次数。
3.2 逻辑过期:永不物理过期
另一种思路是不给 key 设 TTL,而是把过期时间写进 value:
class CacheEntry {
long expireAt; // 逻辑过期时间戳
Order data;
}
读到时判断 expireAt,若已过期则异步触发重建,当前请求返回旧值。这样所有请求都不会阻塞在锁上,代价是短时间返回脏数据。
public Order getOrder(Long id) {
String key = "order:" + id;
String cached = redis.get(key);
if (cached == null) {
return loadFromCacheOrDb(id); // 首次加载
}
CacheEntry entry = JSON.parseObject(cached, CacheEntry.class);
if (entry.expireAt < System.currentTimeMillis()) {
// 逻辑过期:异步重建,当前返回旧值
asyncRebuild(id);
}
return entry.data;
}
取舍:逻辑过期适合"能容忍短暂不一致"的场景,比如商品详情页;不适合"必须强一致"的场景,比如库存扣减。
四、雪崩:打散过期时间 + 多级缓存
4.1 过期时间加随机扰动
最直接的方式是给 TTL 加一个随机偏移:
long baseTtl = 300;
long jitter = ThreadLocalRandom.current().nextLong(0, 60);
redis.set(key, value, baseTtl + jitter, TimeUnit.SECONDS);
这样即使同一批数据同时写入,过期时间也会分散在一个区间内。
4.2 多级缓存
本地缓存(Caffeine)+ Redis 是常见组合。本地缓存扛住热点,Redis 扛住容量。但要注意本地缓存的一致性问题:多实例部署时,本地缓存更新不同步。
一种常见做法是通过消息队列广播失效事件:
// 更新数据时
redis.set(key, value, ttl, TimeUnit.SECONDS);
mq.send("cache:invalidate", key);
// 各实例监听
@EventListener
public void onInvalidate(String key) {
caffeineCache.invalidate(key);
}
取舍:多级缓存提升性能,但引入了一致性复杂度。如果业务对一致性要求高,宁可只用 Redis。
4.3 缓存高可用
雪崩的极端形式是缓存层整体不可用。Redis 的 Sentinel、Cluster 是基础设施层面的答案,但熔断降级同样重要:当 Redis 不可用时,不能让请求直接压垮 DB。Hystrix(已停止维护)或 Resilience4j 的 CircuitBreaker 可以做这层保护。
五、什么时候别用 / 别踩的坑
- 别对完全开放的 id 空间用空值缓存。攻击者可以用随机 id 填满你的 Redis,反而制造了内存雪崩。
- 布隆过滤器不支持删除。数据删除场景要么重建,要么用计数布隆过滤器,要么干脆不用。
- 分布式锁的 TTL 要大于重建耗时。否则锁提前释放,互斥失效。
- 逻辑过期的"异步重建"要限流。如果重建任务本身把线程池打满,等于换了个地方雪崩。
- 多级缓存的失效广播不是强一致。消息丢失或延迟会导致本地缓存长期脏读,需要兜底 TTL。
- 别把所有 key 的 TTL 设成同一个值。这是雪崩最常见的成因,没有之一。
六、小结与预告
穿透、击穿、雪崩的解决方案本质上都在做同一件事:把"请求量"和"数据库压力"解耦。穿透靠布隆过滤器或空值缓存拦截,击穿靠互斥或逻辑过期,雪崩靠打散 TTL 和多级缓存。每一种方案都有代价,没有银弹。
本系列前面讲过线程池、AQS、CompletableFuture 的并发基础,接下来我们会继续深入缓存与一致性方向,比如分布式锁的 Redlock 争议、缓存与数据库的双写一致性策略。如果你对这类源码级、带取舍分析的硬核内容感兴趣,欢迎关注后续更新。
参考链接:
- Redis 官方文档(Bloom Filter 指令):https://redis.io/docs/latest/commands/bf.add/
- Spring Framework Cache 抽象:https://docs.spring.io/spring-framework/reference/integration/cache.html
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_73201411/article/details/165472484




