
本指南教你如何系统解决缓存穿透、缓存击穿与缓存雪崩这三个最常见也最棘手的 Redis 高并发问题。很多团队第一次遇到数据库被打挂,根因都不是慢 SQL,而是缓存层在某个瞬间集体失灵:要么用户用一个不存在的 ID 不停查询,缓存挡不住直接打到 MySQL;要么一个超热的商品键刚刚过期,几万个请求同时回源;要么运维图省事把所有键都设成同一个 TTL,结果到点一起失效。这三种场景在英文里分别叫 cache penetration、cache breakdown 和 cache avalanche,国内常合并称为缓存三剑客。下面这份指南帮助你把每一种问题拆开来看,搞清楚它们的触发条件、检测方法、以及生产环境里真正能落地的应对策略,并给出 Redis 命令与代码示例。
三种问题到底有什么区别
很多博客把这三个名词混着用,但它们的触发条件和应对方式其实差别很大。简单梳理一下:
- 缓存穿透:查询一个根本不存在的数据,缓存里没有、数据库里也没有,每次都回源,攻击者最爱用
- 缓存击穿:一个超热的键刚好过期,并发请求同时打到数据库,单点压力突然飙升
- 缓存雪崩:大量键在同一时间集体过期,或者 Redis 实例整体宕机,数据库瞬间被打死
- 三者的共同点是缓存层失去了挡墙作用,差异在于失效的是单点、热点还是全局
如果你只用一种方案对付所有场景,多半会顾此失彼。比如布隆过滤器能挡穿透但救不了击穿,互斥锁能扛击穿但解决不了雪崩。下面分别看。
缓存穿透:用布隆过滤器把不存在的请求挡在门外
缓存穿透的典型场景是恶意刷接口:攻击者构造一堆不存在的用户 ID 或商品 ID,请求 /api/user/-1、/api/user/-2,缓存查不到、数据库也查不到,每次都白白回源一次。
最朴素的方案是把空值也缓存起来。查到 null 时写入一个空对象,TTL 设短一点(比如 60 秒),下次同样的 key 就能命中缓存。这个办法简单粗暴,缺点是 key 空间无限大时内存会被吃掉。
更工程化的做法是用布隆过滤器(Bloom Filter)做前置拦截。Redis 4.0 之后有 RedisBloom 模块,可以直接用:
BF.RESERVE user_filter 0.01 1000000
BF.ADD user_filter 10001
BF.EXISTS user_filter -1
把全部合法 ID 在系统启动时灌进去,每次查询前先 BF.EXISTS,不存在直接返回。误判率 1% 完全可以接受,因为误判只会放一个本来合法的请求进来,不会造成穿透。一百万个 ID 大概只需要 1.2 MB 内存。如果你的 Redis 版本不带 RedisBloom,用 Guava 的 BloomFilter 在应用本地存一份也能用。
缓存击穿:互斥锁与逻辑过期是两条主路
击穿场景里,问题键是真实存在的,只是恰好在某一瞬间过期。比如一个 PV 千万级的商品详情页,缓存过期那 100 毫秒里如果有两万个请求,两万个线程会同时回源查询,数据库连接池瞬间被占满。
第一条主路是互斥锁。回源前用 SET key value NX EX 10 抢锁,抢到的线程去查数据库并写回缓存,没抢到的线程短暂等待后重试或读旧值。Java 里如果用 Redisson,直接调 RLock.tryLock 更稳:
RLock lock = redisson.getLock("lock:product:" + id);
if (lock.tryLock(50, 5000, TimeUnit.MILLISECONDS)) {
try {
Product p = db.queryProduct(id);
cache.set("product:" + id, p, 600);
return p;
} finally {
lock.unlock();
}
}
第二条主路是逻辑过期,也叫永不过期。缓存里存的对象自带一个 expireAt 字段,键本身在 Redis 里不设 TTL。读取时先返回旧值,后台异步检查是否到期,到期再起一个线程去刷新。这种方案的好处是从来不会出现真的未命中,缺点是会读到短时间的旧数据,对一致性要求严的业务不能用。
缓存雪崩:分散过期加多级兜底加限流降级
雪崩是规模最大、危害最深的一种。常见诱因有两个:要么是同一批数据在同一时间被写入缓存且 TTL 相同,到点一起死掉;要么是 Redis 单点宕机或主从切换失败,整个缓存层瞬间不可用。
针对 TTL 同步的问题,最简单的办法是给每个键的过期时间加一个随机扰动:基础 TTL 1 小时,再叠加 0 到 300 秒的随机值。这样几万个键不会在同一秒过期,回源压力被摊平到 5 分钟以内。代码层面只要一行:
ttl = 3600 + random.randint(0, 300)
r.setex(key, ttl, value)
针对 Redis 不可用的场景,单靠扰动救不了。这时候你需要的是多级兜底。本地缓存 Caffeine 留一份热点数据,即使 Redis 全挂还能撑住 1 到 2 分钟;同时在应用层接 Sentinel 或 Resilience4j,对回源接口做限流,超过阈值就降级返回兜底数据或友好错误页。Nginx 层面也可以配合做静态化兜底,把详情页的 HTML 缓存到 CDN,可以参考 Nginx 反向代理与负载均衡实战配置教程 的限流章节。
监控与压测:别等线上炸了才发现
三种问题在监控指标上有明显特征。缓存穿透表现为 Redis 命中率长期偏低但 QPS 正常,数据库查询里大量返回空结果;击穿表现为某个键的 miss 在毫秒级内尖峰,配合数据库慢查询日志可以定位;雪崩则是 Redis QPS 断崖、数据库 QPS 同时飙升。
建议在 Grafana 上盯三个核心面板:Redis 命中率(按 key 前缀分组)、单 key QPS Top 10、过期键到期分布直方图。压测阶段用 wrk 或 k6 模拟同一秒过期场景,故意构造极端值,提前把问题暴露在线下。日志侧建议把所有未命中事件采样上报,配合 ELK 做关键字告警,可以参考 Linux SSH 安全加固与 Fail2Ban 防护实战 里日志聚合的做法。
部署架构上的几个建议
应对三剑客不只是写几行代码,还需要从架构上做好准备:
- Redis 至少做主从加 Sentinel 或 Cluster 模式,避免单点故障演变成雪崩
- 大促前对所有热点键做预热,提前把缓存写满,不要等用户请求触发回源
- 把缓存代理层与应用解耦,建议参考 WordPress W3 Total Cache 性能调优实战 中页面缓存的分层思路
- 选择物理位置离数据库近的机房部署 Redis,跨区延迟会让回源耗时雪上加霜,可以看看 美国 VPS 部署完整教程指南
- CDN 层也参与到挡墙体系里来,详见 Cloudflare CDN 中国大陆加速优化指南
如果业务对延迟敏感,可以考虑把 Redis 从托管型迁到独立服务器实例,Hostease 的高内存 VPS 适合做大容量缓存层。
总结
总结一下,缓存穿透、缓存击穿与缓存雪崩三个问题看起来都叫缓存挂了,但成因和解法完全不同。推荐的组合是:穿透用布隆过滤器加空值缓存挡前置,击穿用互斥锁加逻辑过期保护热点,雪崩用 TTL 随机化加多级缓存与限流降级兜底。如果你需要从零搭建一套生产级的 Redis 缓存层,建议先把监控埋好,再分阶段引入这三类防护;不要一次性全上,否则定位问题的难度会成倍上升。可以考虑结合实际业务的 QPS 与一致性要求做取舍,没有银弹方案,但有可以反复验证的工程实践。