Redis 从零到一(03):缓存穿透、击穿、雪崩与一致性
2026-10-12 09:40:00

适合人群:已经拿 Redis 当缓存用,但只在面试时背过”穿透/击穿/雪崩”三个词的同学。
本篇的判断标准一句话:看是”不存在的数据”、”过期的热点”还是”成片失效”。

一、先立个判断框架

三个问题表象都一样:数据库压力瞬间暴涨。区分它们只看一件事——打到数据库的请求,查的是什么:

问题 本质 触发条件
穿透 查的数据根本不存在,缓存永远无法命中 恶意请求、爬虫乱造 ID
击穿 某个热点 key 过期的那一瞬间,并发全部涌向 DB 秒杀商品缓存到期
雪崩 大面积 key 同时失效或 Redis 整个挂掉 批量预热的缓存同一秒过期

图1:缓存穿透、击穿、雪崩的成因对比

二、缓存穿透:查一个压根不存在的 key

现象

用户请求 GET /user/-1,缓存里没有(因为这个用户不存在),于是回源查 MySQL,MySQL 也没有。于是每次请求都完整穿透缓存层,直接打到数据库。攻击者拿脚本批量请求 id=-1、id=99999999,数据库直接被打满。

解法一:缓存空值

查不到也缓存下来,值置为空、TTL 设短(30~60 秒):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public User getUser(Long id) {
String key = "user:" + id;
String cached = redis.get(key);
if (cached != null) {
return "NULL".equals(cached) ? null : parseUser(cached); // 命中空值
}
User user = userMapper.selectById(id);
if (user == null) {
redis.setex(key, 60, "NULL"); // 空值也要缓存,短过期
return null;
}
redis.setex(key, 3600, toJson(user));
return user;
}

优点是简单粗暴;缺点是大量不存在的 key 会浪费内存,且新增该数据后有最长 60 秒的不一致窗口。

解法二:布隆过滤器(Bloom Filter)

在缓存之前加一道”记忆”:它用极小的内存(一个亿的 ID 大约 100 多 MB)记住”哪些 ID 可能存在“。查询时:

  • 布隆说不存在 → 一定不存在,直接返回,连 Redis 都不用查
  • 布隆说存在 → 可能存在,继续走缓存 → DB 流程

它有误判率(说存在时可能骗你),但说不存在时百分之百准确——正好挡住穿透。

1
2
3
4
5
6
BloomFilter<Long> filter = BloomFilter.create(
Funnels.longFunnel(), 1_000_000, 0.01); // 百万级数据、1% 误判

if (!filter.mightContain(id)) {
return null; // 一定不存在,拦截
}

缺点:要维护数据同步(新增用户要 add 进过滤器);不支持删除(可用布谷鸟过滤器替代)。

解法三:参数校验兜底

入口处就拦住明显非法的请求:id <= 0、分页 size > 100 这类,直接返回 400。成本最低,永远要做。

三、缓存击穿:热点 key 过期的那一瞬间

现象

秒杀商品 product:1001 的缓存到期,同一毫秒内几万个并发同时发现缓存 miss,全都去查 DB 并回写缓存——DB 瞬间被打出尖刺。注意它和雪崩的区别:只塌一个点,但那个点特别热。

解法一:互斥锁(最常用)

缓存 miss 后,只放一个请求去查库回写,其他人等一会儿再读缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public String getProduct(Long id) {
String key = "product:" + id;
String val = redis.get(key);
if (val != null) return val;

String lockKey = "lock:" + key;
for (int i = 0; i < 3; i++) {
if ("OK".equals(redis.set(lockKey, "1", "NX", "EX", 10))) {
try {
val = redis.get(key); // 双重检查
if (val != null) return val;
val = loadFromDb(id); // 只有持锁者查库
redis.setex(key, 3600, val);
return val;
} finally {
redis.del(lockKey);
}
}
Thread.sleep(50); // 没抢到锁,稍后重试
}
return loadFromDb(id); // 兜底
}

本质是”牺牲一点延迟,换 DB 的安全“。锁的实现细节(误删、续期)正是下一篇分布式锁的主角。

解法二:逻辑过期

热点数据物理上永不过期,value 里存一个逻辑过期时间;发现逻辑过期后,仍返回旧值,同时异步起线程重建缓存。DB 零压力、零等待,代价是短时间返回旧数据。适合”旧值也能接受”的场景(榜单、首页推荐)。

解法三:热点 key 不过期 + 后台定时刷新

运营活动前主动预热,TTL 拉长甚至去掉,由定时任务负责更新。

四、缓存雪崩:一大片 key 同时失效

两种成因

  1. 批量 key 同一时刻过期:凌晨批量预热的缓存,第二天凌晨同一秒集体阵亡
  2. Redis 实例本身挂了:缓存层整体消失,流量全部砸向 DB

解法

针对同时过期:给 TTL 加随机数,把失效时间打散:

1
2
int ttl = 3600 + ThreadLocalRandom.current().nextInt(0, 600);  // 1h ± 10min
redis.setex(key, ttl, val);

针对实例宕机:

  • 高可用:主从 + 哨兵自动故障转移(第 06 篇展开),单节点挂了分钟级恢复
  • 限流降级:DB 前面加限流(如 Sentinel),宁可拒绝部分请求也不让 DB 被压死;核心接口配降级逻辑(返回默认值/旧缓存快照)
  • 多级缓存:本地缓存 Caffeine(一级)+ Redis(二级),Redis 挂了还有本地缓存的底
  • 缓存预热错峰:重启后不要瞬间回源,用温和的预热节奏

五、缓存和数据库的一致性:Cache Aside

缓存问题的下半场是经典八股:改数据时,先动数据库还是先动缓存?更新还是删除?

图2:Cache Aside 旁路缓存模式与延迟双删

标准答案:Cache Aside Pattern

读:先读缓存,命中返回;miss 则读库,回写缓存(带 TTL)。
写:先更新数据库,再删除缓存。

两个为什么:

为什么删缓存而不是更新缓存?

  1. 更新一个缓存可能要复杂计算(聚合多表),而下次读它可能根本不发生——白发工资
  2. 并发写时,两个”更新缓存”的到达顺序可能颠倒,缓存里留下旧值;删除则天然幂等,谁后到都是删

为什么先更新库再删缓存? 反过来(先删缓存再更新库):删完缓存的一瞬间,另一个读请求 miss 回源,读到还没来得及更新的旧值又写回缓存——脏数据长期驻留。先更新库后删缓存,不一致窗口只有”删缓存前的几毫秒”,且删完后下次读必然回源拿新值。

它也不是完美的:延迟双删

极端并发下(读请求 miss 后、回写缓存前,穿插了一次写请求完成”更新库+删缓存”),旧值仍可能被写回缓存。对付这种小概率脏数据,常用的补丁是延迟双删:

1
2
3
4
5
6
// 1. 删一次缓存
redis.del(key);
// 2. 更新数据库
userMapper.update(user);
// 3. 异步延迟几百毫秒再删一次,清掉"回写旧值"的尾巴
scheduler.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS);

更强的一致性?

要求”缓存与数据库强一致”本质上是分布式事务问题,代价(串行化、全局锁)远超收益。业界的共识是:用 Cache Aside + TTL 兜底 + 接受毫秒级不一致,TTL 保证就算脏了也有自愈上限。真要强一致的数据(账户余额扣减),别走缓存,直接 DB 事务。

总结

  • 穿透 = 数据不存在:缓存空值、布隆过滤器、参数校验
  • 击穿 = 热点过期瞬间:互斥锁、逻辑过期、热点不过期
  • 雪崩 = 成片失效或实例挂掉:TTL 加随机、高可用、限流降级、多级缓存
  • 一致性默认题:先更新数据库,再删除缓存;不放心就延迟双删 + TTL 兜底

下一篇往深处走一层:key 过期了 Redis 到底什么时候删它?内存满了又按什么规则往外扔数据?

上一页
2026-10-12 09:40:00
下一页