适合人群:已经拿 Redis 当缓存用,但只在面试时背过”穿透/击穿/雪崩”三个词的同学。
本篇的判断标准一句话:看是”不存在的数据”、”过期的热点”还是”成片失效”。
一、先立个判断框架
三个问题表象都一样:数据库压力瞬间暴涨。区分它们只看一件事——打到数据库的请求,查的是什么:
| 问题 | 本质 | 触发条件 |
|---|---|---|
| 穿透 | 查的数据根本不存在,缓存永远无法命中 | 恶意请求、爬虫乱造 ID |
| 击穿 | 某个热点 key 过期的那一瞬间,并发全部涌向 DB | 秒杀商品缓存到期 |
| 雪崩 | 大面积 key 同时失效或 Redis 整个挂掉 | 批量预热的缓存同一秒过期 |
二、缓存穿透:查一个压根不存在的 key
现象
用户请求 GET /user/-1,缓存里没有(因为这个用户不存在),于是回源查 MySQL,MySQL 也没有。于是每次请求都完整穿透缓存层,直接打到数据库。攻击者拿脚本批量请求 id=-1、id=99999999,数据库直接被打满。
解法一:缓存空值
查不到也缓存下来,值置为空、TTL 设短(30~60 秒):
1 | public User getUser(Long id) { |
优点是简单粗暴;缺点是大量不存在的 key 会浪费内存,且新增该数据后有最长 60 秒的不一致窗口。
解法二:布隆过滤器(Bloom Filter)
在缓存之前加一道”记忆”:它用极小的内存(一个亿的 ID 大约 100 多 MB)记住”哪些 ID 可能存在“。查询时:
- 布隆说不存在 → 一定不存在,直接返回,连 Redis 都不用查
- 布隆说存在 → 可能存在,继续走缓存 → DB 流程
它有误判率(说存在时可能骗你),但说不存在时百分之百准确——正好挡住穿透。
1 | BloomFilter<Long> filter = BloomFilter.create( |
缺点:要维护数据同步(新增用户要 add 进过滤器);不支持删除(可用布谷鸟过滤器替代)。
解法三:参数校验兜底
入口处就拦住明显非法的请求:id <= 0、分页 size > 100 这类,直接返回 400。成本最低,永远要做。
三、缓存击穿:热点 key 过期的那一瞬间
现象
秒杀商品 product:1001 的缓存到期,同一毫秒内几万个并发同时发现缓存 miss,全都去查 DB 并回写缓存——DB 瞬间被打出尖刺。注意它和雪崩的区别:只塌一个点,但那个点特别热。
解法一:互斥锁(最常用)
缓存 miss 后,只放一个请求去查库回写,其他人等一会儿再读缓存:
1 | public String getProduct(Long id) { |
本质是”牺牲一点延迟,换 DB 的安全“。锁的实现细节(误删、续期)正是下一篇分布式锁的主角。
解法二:逻辑过期
热点数据物理上永不过期,value 里存一个逻辑过期时间;发现逻辑过期后,仍返回旧值,同时异步起线程重建缓存。DB 零压力、零等待,代价是短时间返回旧数据。适合”旧值也能接受”的场景(榜单、首页推荐)。
解法三:热点 key 不过期 + 后台定时刷新
运营活动前主动预热,TTL 拉长甚至去掉,由定时任务负责更新。
四、缓存雪崩:一大片 key 同时失效
两种成因
- 批量 key 同一时刻过期:凌晨批量预热的缓存,第二天凌晨同一秒集体阵亡
- Redis 实例本身挂了:缓存层整体消失,流量全部砸向 DB
解法
针对同时过期:给 TTL 加随机数,把失效时间打散:
1 | int ttl = 3600 + ThreadLocalRandom.current().nextInt(0, 600); // 1h ± 10min |
针对实例宕机:
- 高可用:主从 + 哨兵自动故障转移(第 06 篇展开),单节点挂了分钟级恢复
- 限流降级:DB 前面加限流(如 Sentinel),宁可拒绝部分请求也不让 DB 被压死;核心接口配降级逻辑(返回默认值/旧缓存快照)
- 多级缓存:本地缓存 Caffeine(一级)+ Redis(二级),Redis 挂了还有本地缓存的底
- 缓存预热错峰:重启后不要瞬间回源,用温和的预热节奏
五、缓存和数据库的一致性:Cache Aside
缓存问题的下半场是经典八股:改数据时,先动数据库还是先动缓存?更新还是删除?
标准答案:Cache Aside Pattern
读:先读缓存,命中返回;miss 则读库,回写缓存(带 TTL)。
写:先更新数据库,再删除缓存。
两个为什么:
为什么删缓存而不是更新缓存?
- 更新一个缓存可能要复杂计算(聚合多表),而下次读它可能根本不发生——白发工资
- 并发写时,两个”更新缓存”的到达顺序可能颠倒,缓存里留下旧值;删除则天然幂等,谁后到都是删
为什么先更新库再删缓存? 反过来(先删缓存再更新库):删完缓存的一瞬间,另一个读请求 miss 回源,读到还没来得及更新的旧值又写回缓存——脏数据长期驻留。先更新库后删缓存,不一致窗口只有”删缓存前的几毫秒”,且删完后下次读必然回源拿新值。
它也不是完美的:延迟双删
极端并发下(读请求 miss 后、回写缓存前,穿插了一次写请求完成”更新库+删缓存”),旧值仍可能被写回缓存。对付这种小概率脏数据,常用的补丁是延迟双删:
1 | // 1. 删一次缓存 |
更强的一致性?
要求”缓存与数据库强一致”本质上是分布式事务问题,代价(串行化、全局锁)远超收益。业界的共识是:用 Cache Aside + TTL 兜底 + 接受毫秒级不一致,TTL 保证就算脏了也有自愈上限。真要强一致的数据(账户余额扣减),别走缓存,直接 DB 事务。
总结
- 穿透 = 数据不存在:缓存空值、布隆过滤器、参数校验
- 击穿 = 热点过期瞬间:互斥锁、逻辑过期、热点不过期
- 雪崩 = 成片失效或实例挂掉:TTL 加随机、高可用、限流降级、多级缓存
- 一致性默认题:先更新数据库,再删除缓存;不放心就延迟双删 + TTL 兜底
下一篇往深处走一层:key 过期了 Redis 到底什么时候删它?内存满了又按什么规则往外扔数据?