缓存穿透、击穿与雪崩
缓存加在数据库前面,正常路径是"先查缓存,未命中查库,回填缓存"。一旦这个路径在某个环节失效,流量就会直接压到数据库上。穿透、击穿、雪崩描述的是三种不同的失效原因,对应的解法也完全不同。本文用一张对照表先把概念分开,再逐条给出可运行的实现。
一、三种问题的区别
| 问题 | 根因 | 特征 | 主要解法 |
| --- | --- | --- | --- |
| 穿透 | 查询不存在的数据,缓存永远不命中 | 请求量平稳但 DB QPS 异常高,key 高度分散 | 空值缓存、布隆过滤器、参数校验 |
| 击穿 | 单个热点 key 过期瞬间,大量并发回源 | 某一个 key 的 QPS 尖刺 | 互斥锁重建、逻辑过期、热点永不过期 |
| 雪崩 | 大量 key 同时失效,或 Redis 整体不可用 | 全站缓存命中率骤降 | TTL 抖动、多级缓存、熔断降级、高可用 |
三者的共同点是"缓存没挡住",但治理手段差别很大:穿透要挡非法 key,击穿要控制并发重建,雪崩要做容量与可用性设计。
二、缓存穿透
攻击者用不存在的 ID 反复请求,比如 /user/999999999。缓存不命中、数据库查不到、也不回填,下一次请求继续打到数据库。
方案一:缓存空值。 查库为空时写入一个短 TTL 的占位值,比如 60 秒。实现最简单,能挡住绝大多数重复请求。
public User getUser(long id) {
String key = "user:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return EMPTY.equals(cached) ? null : JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user == null) {
redis.opsForValue().set(key, EMPTY, Duration.ofSeconds(60));
return null;
}
redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30));
return user;
}
方案二:布隆过滤器。 把所有合法 ID 预先放进布隆过滤器,查询前先判断。它用位数组加多个哈希函数,空间效率极高,但有 1% 左右的误判率(只会误判存在,不会误判不存在),且不支持删除。
# Redis 4.0+ 的布隆过滤器模块(RedisBloom)
BF.RESERVE user:bloom 0.001 1000000
BF.ADD user:bloom 1001
BF.EXISTS user:bloom 1001
BF.EXISTS user:bloom 999999
参数 0.001 是期望误判率,1000000 是预估元素数。误判率越低,位数组越大。
注意:布隆过滤器需要与数据库同步维护。新增数据要
BF.ADD,删除数据无法直接移除,只能重建或改用 Counting Bloom Filter。
方案三:入口校验。 ID 必须为正整数、必须符合格式、必须来自已登录用户,能在网关挡掉一部分爬虫式请求。
三、缓存击穿
一个热点 key 在过期瞬间,比如秒杀商品详情,几千个线程同时发现未命中,一起查库并回填。数据库瞬间被打满,回填的又是同一份数据。
方案一:互斥锁重建。 只允许一个线程回源,其余线程短暂等待后重试读缓存。
public Product getProduct(long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
String lockKey = "lock:product:" + id;
String token = UUID.randomUUID().toString();
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
Product product = productMapper.selectById(id);
redis.opsForValue().set(key, JSON.toJSONString(product),
Duration.ofMinutes(30));
return product;
} finally {
unlock(lockKey, token);
}
}
// 未抢到锁:短暂睡眠后重试,避免全部线程同时打库
sleepQuietly(50);
cached = redis.opsForValue().get(key);
return cached == null ? productMapper.selectById(id)
: JSON.parseObject(cached, Product.class);
}
释放锁必须校验 token,避免误删别人的锁,详见分布式锁一文。这种方案的缺点是未抢到锁的线程会等待,吞吐下降,适合能接受百毫秒级延迟的场景。
方案二:逻辑过期。 key 本身不设 TTL,值里带一个 expireAt 字段。读到逻辑过期时,异步触发重建,当前请求仍返回旧值。这样所有请求都能立刻返回,代价是短时间内可能读到旧数据。
public Product getProductWithLogicalExpire(long id) {
String key = "product:logical:" + id;
String cached = redis.opsForValue().get(key);
if (cached == null) {
return loadAndCache(id, key);
}
CacheWrapper<Product> wrapper = JSON.parseObject(cached,
new TypeReference<CacheWrapper<Product>>() {});
if (wrapper.getExpireAt() > System.currentTimeMillis()) {
return wrapper.getData();
}
// 已逻辑过期:尝试拿锁异步重建,拿不到就直接返回旧值
String lockKey = "lock:rebuild:" + id;
if (Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10)))) {
executor.execute(() -> {
try {
loadAndCache(id, key);
} finally {
redis.delete(lockKey);
}
});
}
return wrapper.getData();
}
热点商品、首页配置、榜单这类"允许短暂陈旧"的数据适合逻辑过期;账户余额、库存这类强一致数据不适合。
四、缓存雪崩
雪崩有两个来源:大量 key 同时过期,或 Redis 集群整体故障。
TTL 抖动。 在基础 TTL 上叠加随机偏移,把过期时间打散:
long ttl = 1800 + ThreadLocalRandom.current().nextLong(300);
redis.opsForValue().set(key, value, Duration.ofSeconds(ttl));
多级缓存。 本地缓存(Caffeine)挡一层,Redis 挡第二层,数据库兜底。Redis 故障时本地缓存仍能撑一段时间,但要注意本地缓存的一致性问题。
熔断与降级。 当数据库 QPS 超过阈值时,对非核心接口直接返回默认值或错误页,保住核心链路。Sentinel 或 Resilience4j 都可以配置。
高可用。 主从 + 哨兵或 Cluster,避免单点故障;同时给 Redis 客户端配置合理的超时与重试,超时时间不宜过长,否则故障时线程池会被打满。
spring:
data:
redis:
timeout: 500ms
lettuce:
pool:
max-active: 50
max-wait: 200ms
缓存预热。 系统重启或 Redis 重启后,缓存是空的,第一批请求会全部回源。上线前用离线任务把热点数据提前写入,或者用「分批放量」的方式逐步切流,避免冷启动瞬间压垮数据库。
降级兜底值。 非核心字段可以返回静态默认值,比如商品列表的推荐位、评分、销量,缓存不可用时返回空列表而不是抛异常。这类降级要在网关或服务层实现,并且可配置开关,方便故障时快速生效。
五、组合使用
真实系统通常三者叠加:
- 入口做参数校验和限流,挡掉明显非法的请求;
- 用布隆过滤器拦不存在的 ID,空值缓存兜住漏网之鱼;
- 热点 key 用逻辑过期或互斥锁重建;
- 所有 TTL 加随机抖动,核心数据做多级缓存;
- 全链路配监控:缓存命中率、DB QPS、Redis 延迟,任何一项异常都能定位到具体环节。
常见坑 / 最佳实践
- 空值缓存的 TTL 不能太长。 如果数据随后被创建,过长的空值缓存会持续返回空,建议 30~60 秒,并在写入时主动删除对应 key。
- 互斥锁一定要设过期时间,并在 finally 中释放。 否则持锁线程崩溃会导致死锁,热点 key 永远无法重建。
- 未抢到锁的线程不要直接查库。 那样等于没加锁,应短暂等待后重读缓存,或直接返回兜底值。
- 逻辑过期要评估数据陈旧的影响。 异步重建失败时旧值会一直返回,需要加重建失败告警和最大陈旧时间限制。
- 布隆过滤器要与数据写入放在同一事务链路。 新增数据后忘了
BF.ADD,会出现"库里明明有、请求却被判不存在"的诡异问题。
小结
- 穿透是查不存在的数据,靠空值缓存、布隆过滤器和入口校验解决。
- 击穿是单个热点 key 过期,靠互斥锁重建或逻辑过期解决,前者一致性强、后者吞吐高。
- 雪崩是大量 key 同时失效或 Redis 故障,靠 TTL 抖动、多级缓存、熔断降级和高可用架构解决。
- 三种问题要组合治理,并且必须配合命中率、DB QPS、Redis 延迟的监控才能快速定位。
- 所有方案都有代价,选型依据是业务对一致性、延迟和吞吐的实际要求。
- 点赞
- 收藏
- 关注作者
评论(0)