三个词,三种不同的坏情况
缓存穿透、缓存击穿、缓存雪崩,名字很像,出问题时现象也差不多——数据库突然被打满。但它们的成因完全不同,用错方案不仅解决不了问题,还可能引入新问题。
| 问题 | 触发条件 | 典型现象 |
|---|---|---|
| 穿透 | 查询的数据缓存和数据库都不存在 | 大量请求绕过缓存直打数据库 |
| 击穿 | 某个热点 key 刚好过期 | 同一行数据被并发查爆 |
| 雪崩 | 大量 key 同时过期,或 Redis 整体不可用 | 数据库短时间承受全量流量 |
先分清是哪一种,再选方案。
穿透:查不存在的数据
攻击者用一个不存在的 ID 疯狂请求,缓存里没有,数据库里也查不到,每次请求都落到数据库。正常的缓存流程"查不到就查库"反而成了放大器。
两个常用办法:
缓存空值。 查不到也写一个占位值,过期时间设短一些,比如 60 秒。后面同样的请求直接命中占位值,不会打到数据库:
public UserVO getUser(Long id) {
String key = "user:info:" + id;
String cached = stringRedisTemplate.opsForValue().get(key);
if (cached != null) {
return "NULL".equals(cached) ? null : JSON.parseObject(cached, UserVO.class);
}
UserVO user = userMapper.selectById(id);
if (user == null) {
stringRedisTemplate.opsForValue().set(key, "NULL", Duration.ofSeconds(60));
return null;
}
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30));
return user;
}
布隆过滤器。 把所有合法 ID 放进布隆过滤器,请求先过一遍:过滤器说"不存在",一定不存在,直接返回;说"可能存在",再查缓存和数据库。布隆过滤器有误判率,但不会漏判,适合数据量大的场景。
空值缓存实现简单,缺点是占内存,而且数据新增后要记得清掉对应的空值,否则用户会在一分钟内一直看到"不存在"。
击穿:热点 key 失效
一个爆款商品的缓存刚好过期,几千个请求同时发现缓存没了,全部去查数据库,数据库瞬间被打爆。
方案一:互斥锁。 只让一个线程去查库,其他线程等待:
String lockKey = "lock:user:" + id;
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.FALSE.equals(locked)) {
Thread.sleep(50);
return getUser(id); // 简化写法,实际要限制重试次数,避免无限递归
}
try {
// 拿到锁后再查一次缓存,防止重复查库
return loadFromDbAndCache(id);
} finally {
stringRedisTemplate.delete(lockKey);
}
注意锁的过期时间要小于接口超时,finally 一定要释放,递归重试要设上限,否则缓存一直没恢复时线程会被打满。
方案二:逻辑过期。 缓存不设 TTL,value 里带一个 expireAt 字段。读到过期数据时,先返回旧值,再异步起一个线程去刷新。这样请求永远不会打到数据库的同步链路上,代价是短时间内会读到旧数据。适合商品详情、文章内容这类能容忍短暂不一致的场景。
方案三:热点数据永不过期。 后台定时任务主动刷新,或者由数据变更事件触发更新。实现简单,但要注意刷新失败时的兜底。
雪崩:大量 key 同时失效
凌晨批量预热缓存,所有 key 都设了 30 分钟过期,结果同一时刻全部失效,数据库被瞬时流量打满。另一类雪崩是 Redis 整体故障,所有请求直接穿透到数据库。
应对思路有三层:
- 过期时间加随机值。基础时间加一个随机偏移,比如 30 分钟加 0 到 5 分钟,把失效时间打散;
- 多级缓存。本地缓存(Caffeine)挡一层,Redis 挡一层,即使 Redis 挂了,本地缓存还能顶一阵;
- 限流和降级。数据库前面加限流,超过阈值直接返回兜底数据;Redis 故障时启用熔断,不要让请求全部压到数据库。
此外,Redis 本身要做高可用(哨兵或集群),预热时避免同一秒写入大量 key,重要接口准备降级开关。
常见坑
1. 空值缓存没有清理
用户注册后查详情,缓存里还留着之前的 NULL,60 秒内一直显示不存在。新增或更新数据时,顺手删掉对应的空值 key。
2. 互斥锁没释放或超时太短
锁没释放,后续请求全部等待;锁超时太短,业务还没查完锁就没了,等于没锁。锁的 TTL 要略大于一次查库加回填的耗时。
3. 递归重试没有上限
缓存击穿时用递归等待,缓存长时间不恢复,线程池很快被占满。重试次数控制在两三次,超过就返回兜底值。
4. 逻辑过期返回的旧数据不可接受
库存、余额这类数据不能返回旧值,逻辑过期方案不适用。选方案前先确认业务对一致性的要求。
5. 随机 TTL 的偏移太小
所有 key 还是集中在同一分钟内失效,等于没打散。偏移量要和过期时间同一量级,比如 30 分钟过期配 5 分钟偏移。
6. 只加缓存不限流
缓存能挡住 99% 的请求,剩下 1% 在热点事件里也可能是每秒几万次。没有限流和降级,数据库照样扛不住。
小结
穿透是"查不存在",用空值或布隆过滤器;击穿是"单个热点失效",用互斥锁或逻辑过期;雪崩是"大面积失效",用随机 TTL、多级缓存和限流。方案没有绝对的好坏,关键是先判断业务能不能容忍旧数据、能不能接受短暂不可用,再决定用哪一种。
评论