那是一次教科书级别的"雪崩"。某个流量高峰的午间,我们的服务突然开始大面积超时,告警短信像下雨一样砸进群里。我冲上去一看监控,景象触目惊心:数据库的 CPU 瞬间飙到了 100%,慢查询日志疯狂刷屏,大量请求堆积、超时,而紧接着,因为请求都卡在数据库上、线程被占满,整个服务也跟着雪崩式地瘫痪了。最诡异的是,出事前流量并没有暴涨,就是平平常常的午高峰,数据库平时也都好好的——怎么会在某一个瞬间,突然就被打垮了?
排查的结果,是一个我们当时完全没意识到的定时炸弹:我们有一大批缓存数据,是在同一段时间(比如某次集中刷新)写入 Redis 的,而且设置了完全相同的过期时间。于是到了某个时刻,这一大批缓存"约好了"似的,在同一秒钟集体过期、集体失效了。那一瞬间,原本被缓存稳稳挡住的海量请求,因为缓存全没了,像决堤的洪水一样,在同一时刻全部涌向了数据库——而数据库根本扛不住这种瞬间的洪峰,CPU 直接拉满、被活活压垮,进而拖垮了整个服务。这就是缓存领域最经典、也最凶险的事故之一:缓存雪崩。这篇文章,就从这次"缓存集体过期压垮数据库"的雪崩讲起,把缓存这道"挡在数据库前面的城墙"上,那几个最容易被忽视、却足以引发灾难的缺口,一个个讲透。
故障现场:一道城墙的瞬间崩塌
先理解缓存的角色,才好理解雪崩的可怕。在我们的架构里,缓存(Redis)扮演的是数据库前面的一道"城墙":绝大多数读请求,都先到缓存里拿数据,缓存有就直接返回(命中),压根不碰数据库;只有缓存里没有的(未命中),才会去查一次数据库,然后把结果写回缓存。正常情况下,这道城墙能把 99% 以上的读请求都挡在外面,数据库只需要应付那一点点漏网的请求,轻松惬意。
正常时: 缓存这道城墙稳稳挡住绝大多数请求
海量请求 → [Redis 缓存] ──命中99%──> 直接返回 (数据库很轻松)
└─未命中1%──> 查数据库
缓存雪崩: 城墙在某一瞬间整段塌掉
某时刻, 一大批缓存同时过期 → 城墙瞬间消失
海量请求 → [缓存全空] → 全部直接砸向数据库 → 数据库被瞬间压垮
问题就在于,这道城墙的"防御力",依赖于缓存里有数据。而我们犯的致命错误,是让一大批缓存数据,拥有了完全相同的过期时间。这就好比一支军队,所有士兵的换防时间被设成了同一秒——到了那一秒,整段城墙上的守军同时离岗,城墙瞬间门户大开。海量请求在这一刻发现缓存全没了,只能一窝蜂地全去查数据库;而数据库的处理能力,是远远小于缓存的(这正是我们用缓存挡在它前面的原因),它哪里扛得住平时被缓存挡掉的那 99% 的流量瞬间全压过来?于是 CPU 拉满、响应变慢、请求堆积,然后是连接被占满、线程被耗尽,最终整个服务雪崩。缓存雪崩的本质,就是"大量缓存在同一时刻集体失效,导致海量请求瞬间穿透到数据库,把数据库压垮"。
第一件事:给过期时间加上"随机抖动"
找到了"过期时间完全相同"这个病根,最直接、最有效的解药也就有了:别让大批缓存在同一时刻过期——给每个缓存的过期时间,加上一个随机的"抖动",把它们的失效时刻打散开。这样,原本"约好了同一秒集体下岗"的守军,就变成了"在一段时间内陆续轮换",城墙永远不会出现整段同时空缺的情况。
// 反面: 所有缓存用完全相同的过期时间 → 会集体过期, 引发雪崩
redis.set(key, value, 3600); // 全都是整整 1 小时
// 正面: 在基础过期时间上, 加一个随机抖动, 把过期时刻打散
int base = 3600; // 基础 1 小时
int jitter = ThreadLocalRandom.current().nextInt(600); // 随机 0~10 分钟
redis.set(key, value, base + jitter); // 实际过期时间 60~70 分钟不等
// 这样大批缓存的过期时刻就被均匀地"抹"在了一个时间段里, 不再扎堆
这个改动小到只有一行,却能从根本上化解"集体过期"型的雪崩。原理很朴素:通过给过期时间引入随机性,把原本集中在一个时间点的失效压力,均匀地分摊到一个时间段里。任何一个时刻,只有一小部分缓存过期、需要回源查库,数据库面对的永远是平缓的涓涓细流,而不是排山倒海的洪峰。这是一个极其通用的"削峰"思想——当你发现某种压力会在某个时间点集中爆发时,给它引入一点随机的抖动,把尖峰"抹平"成一段平缓的曲线,往往就能化解危机。不只缓存过期,定时任务的触发时间、客户端的重试间隔(加随机退避),用的都是同一招。
第二件事:缓存击穿——单个热点 key 的"千军万马"
解决了"集体过期"的雪崩,我又顺藤摸瓜研究了它的"近亲"——缓存击穿。雪崩是"一大批 key 同时过期",而击穿是"某一个被高并发访问的热点 key 过期"。比如一个爆款商品的详情、一条全站都在刷的热门动态,平时每秒有上万次请求,全靠这一个缓存 key 顶着;一旦这个 key 恰好过期的那一瞬间,这上万个请求会同时发现缓存没了,然后同时去查数据库、同时去重建这个缓存——一瞬间上万个相同的查询砸向数据库,同样能把它打垮。
// 击穿的隐患: 热点 key 过期瞬间, 大量请求同时回源重建
public Object getHot(String key) {
Object val = redis.get(key);
if (val == null) { // 高并发下, 上万个请求同时走到这
val = db.query(key); // 上万个请求同时砸向数据库!
redis.set(key, val, 3600);
}
return val;
}
// 正解: 用互斥锁, 只让"一个"请求去重建缓存, 其余的等它建好
public Object getHot(String key) {
Object val = redis.get(key);
if (val == null) {
// 抢一把锁, 只有抢到的那一个才去查库重建
if (redis.setIfAbsent("lock:" + key, "1", 10)) {
try {
val = db.query(key); // 只有一个请求查库
redis.set(key, val, 3600 + jitter());
} finally { redis.del("lock:" + key); }
} else {
Thread.sleep(50); // 没抢到锁的, 稍等再读缓存
return getHot(key); // 重试, 这时缓存多半已被重建好
}
}
return val;
}
击穿的解法核心是"互斥重建":既然问题是"上万个请求同时去重建同一个缓存",那就用一把锁,保证同一时刻只有一个请求能去查库、重建缓存,其余请求要么稍等片刻再读缓存(这时往往已经被那一个请求重建好了)、要么先返回一个旧值。这样,无论多少并发,真正砸到数据库上的,永远只有那一个"幸运儿",而不是千军万马。雪崩靠"打散过期时间"防,击穿靠"互斥重建"防——前者解决"很多 key 同时失效",后者解决"一个热 key 失效时的并发回源"。另外,对极热的 key,还可以考虑"逻辑过期"(不设真过期,由后台异步刷新)等更进阶的方案,彻底避免它在线上"裸奔"地过期。
第三件事:缓存穿透——查一个"根本不存在"的东西
雪崩和击穿的前提,都是"数据本身是存在的,只是缓存暂时没了"。而第三个兄弟——缓存穿透——更阴险:它查的是一个数据库里压根就不存在的数据。比如有人恶意用一堆不存在的 ID(-1、999999999……)疯狂请求:缓存里当然没有(因为数据不存在),于是请求穿过缓存去查数据库;数据库里也没有,返回空;因为结果是空,我们通常不会去缓存这个"空结果"——于是下一次同样的请求,又会重复这个"缓存没有→查库也没有"的过程,缓存彻底失去了拦截作用,每一次这样的请求都直直地打到数据库上。
// 解法1: 把"空结果"也缓存起来(设较短过期), 挡住重复的无效查询
public Object get(String key) {
Object val = redis.get(key);
if (val != null) return val == NULL_HOLDER ? null : val; // 命中(含空值)
val = db.query(key);
if (val == null) {
redis.set(key, NULL_HOLDER, 60); // 空结果也缓存, 但过期时间短
return null;
}
redis.set(key, val, 3600 + jitter());
return val;
}
// 解法2: 布隆过滤器(Bloom Filter) —— 在缓存前加一道"存不存在"的快速判断
// 把所有真实存在的 key 预先放进布隆过滤器;
// 请求先问它"这个 key 可能存在吗?", 它说"一定不存在"就直接拒绝,
// 连缓存和数据库都不用查 —— 高效挡住海量不存在的 key 的攻击。
穿透的两种解法各有侧重:"缓存空值"简单直接——既然查出来是空,那就把这个"空"也缓存起来(设一个较短的过期时间),这样下次同样的无效请求就会在缓存层被挡住,不再打到数据库;"布隆过滤器"更高效——它是一种空间效率极高的数据结构,能快速判断"一个元素一定不存在 / 可能存在",把所有真实存在的 key 灌进去后,放在缓存前面当第一道岗,凡是它判定"一定不存在"的请求,直接拒绝,连缓存都不用查。缓存空值适合"偶发的、无意的"不存在查询;布隆过滤器适合"大量的、可能是恶意攻击的"不存在查询。我把这三兄弟的区别画成一张图:
这张图把缓存的"三大经典事故"放在一起对比,它们虽然现象都是"请求异常地打到了数据库",但成因和对策各不相同:雪崩是"批量 key 同时失效"(打散过期时间)、击穿是"单个热 key 失效时的并发回源"(互斥重建)、穿透是"查询不存在的数据导致缓存失效拦截作用"(缓存空值/布隆过滤器)。分清这三者,是用好缓存、守住数据库这道防线的基本功。
第四件事:最后的兜底——熔断降级,别让数据库"陪葬"
前面三招(打散过期、互斥重建、缓存空值)都是"防患于未然"。但做高可用,还得有一道"万一防线全破了,也别让全盘崩溃"的兜底——这就是熔断与降级。它的思路是:与其让数据库被瞬间洪峰彻底压垮、导致全站雪崩、所有人都用不了,不如在数据库快扛不住时,主动"牺牲"一部分请求(快速失败或返回降级结果),保住数据库不死、保住核心功能可用。
// 兜底: 给"回源查库"加熔断 + 限流, 保护数据库不被打垮
public Object getWithProtection(String key) {
Object val = redis.get(key);
if (val != null) return val;
// 缓存没命中, 要回源查库 —— 但先过一道"保护阀"
if (!circuitBreaker.tryAcquire()) {
// 数据库已经很吃力了, 熔断/限流生效:
return getDefaultOrStale(key); // 返回降级值/旧值, 而非硬查库
}
try {
val = db.query(key); // 受保护地查库
redis.set(key, val, 3600 + jitter());
return val;
} finally { circuitBreaker.release(); }
}
这道兜底防线的价值,在真正的极端情况下才显现:当流量洪峰真的来了、缓存防线被冲破、海量请求要回源查库时,熔断器/限流器会站出来,只放过数据库能承受的那部分请求,其余的快速失败或返回一个降级的结果(比如默认值、稍旧的数据、或一句"系统繁忙请稍后")。这看起来"牺牲"了部分用户体验,但它守住了最关键的底线——数据库不被打死,系统不全盘雪崩。这背后是高可用设计的一个核心哲学:在过载面前,"部分可用"远胜于"全盘崩溃";主动地、有选择地舍弃一部分,是为了保全整体。宁可让 10% 的请求降级,也不要让 100% 的请求都因为数据库被打死而失败。
把缓存的三大事故和对策汇总成一张表,方便对照:
| 事故 | 触发条件 | 核心对策 |
|---|---|---|
| 缓存雪崩 | 大批 key 同一时刻过期 | 过期时间加随机抖动 |
| 缓存击穿 | 单个热点 key 过期, 并发回源 | 互斥锁重建 / 逻辑过期 |
| 缓存穿透 | 查询数据库不存在的数据 | 缓存空值 / 布隆过滤器 |
| (通用兜底) | 防线被突破, 库快扛不住 | 熔断、限流、降级 |
第五件事:顺带说说缓存一致性
讲缓存绕不开另一个高频问题:缓存和数据库的数据一致性——数据更新时,缓存和数据库怎么同步,才不会出现"数据库改了、缓存还是旧的"这种脏数据?这本身能写一篇长文,这里给出最实用的结论。常见的几种更新策略对比如下:
| 策略 | 做法 | 评价 |
|---|---|---|
| 先更库, 再更缓存 | 写库后把新值写进缓存 | 并发下易乱序产生脏数据, 不推荐 |
| 先更库, 再删缓存 | 写库后删掉缓存, 下次读再重建 | 主流推荐(Cache Aside) |
| 先删缓存, 再更库 | 先删缓存再写库 | 并发读会把旧值又载回, 需延迟双删 |
| 延迟双删 | 删缓存→更库→延迟一会再删一次 | 缓解并发回载, 更稳妥 |
主流的最佳实践是 Cache Aside 模式:更新数据时,先更新数据库,再删除(而不是更新)缓存。为什么是"删除"而非"更新"缓存?因为删除更简单、更不容易在并发下出错——下次有人读时缓存没了,自然会去查最新的库、重建一份新缓存,既保证了最终一致,又避免了"并发更新缓存导致写入乱序"的麻烦。但要清醒认识到:在分布式、高并发下,缓存和数据库的"强一致"几乎是做不到、也代价极高的;绝大多数业务,追求的是"最终一致"——允许极短时间的不一致,只要最终能一致即可。想清楚你的业务到底需要多强的一致性,别为了一个其实不需要强一致的场景,去背负强一致的巨大复杂度和性能代价。
一张"缓存出问题怎么排查"的决策图
把这次踩坑沉淀成一张排查图。当你的服务因为缓存相关问题、把数据库打出问题时,照着它快速定位:
这张图的排查主线是看"打到数据库的是什么样的查询":如果是一大批不同的 key 集中回源,查它们是不是过期时间撞一块了(雪崩);如果集中在某个热点 key,那是击穿;如果查的 key 在库里根本不存在,那是穿透。三种事故的"流量特征"不同,据此就能快速分辨、对症下药。而无论哪种,熔断限流降级都是最后那道通用的兜底防线。
我立下的几条缓存使用铁律
这次缓存雪崩事故后,团队的缓存规范里加了这么几条:
- 过期时间必加随机抖动:批量写入的缓存,过期时间一律加随机偏移,严禁清一色相同,防雪崩。
- 热点 key 互斥重建:高并发的热点 key,回源重建时加互斥锁或用逻辑过期,只让一个请求查库,防击穿。
- 不存在的也要拦:对可能被刷不存在数据的接口,缓存空值或上布隆过滤器,防穿透。
- 给回源加保护阀:缓存未命中的回源查库路径,加熔断/限流,过载时降级,绝不让数据库被打死。
- 更新用 Cache Aside:更新数据时先更库再删缓存,追求最终一致,别盲目追求强一致。
- 缓存命中率纳入监控:监控缓存命中率和数据库 QPS,命中率骤降/库 QPS 骤升能第一时间预警缓存问题。
- 压测覆盖缓存失效场景:专门压测"缓存大面积失效时数据库扛不扛得住",把雪崩风险提前暴露。
这几条里,第一条是直接根治这次事故的,而第四条"给回源加保护阀"是我觉得最该补、却最常被忽略的一道防线。因为前三招(防雪崩、击穿、穿透)都是在"努力不让请求打到数据库",但你永远无法保证它们 100% 有效——总有你没想到的边界、没防住的情况;而"熔断降级"是在承认"防线总有被突破的一天"之后,为那一天准备的最后兜底。一个成熟的系统,不会天真地以为"我把缓存做好了就万无一失",而是会清醒地为"万一缓存失效了、数据库扛不住了"准备好降级预案。这正是高可用设计的精髓:不是追求"永不出错",而是确保"出错时,损失可控、核心不死"。
写在最后:缓存是把双刃剑
这次缓存雪崩的经历,让我对"缓存"这个我们天天在用的东西,生出了一种敬畏。在那之前,我对缓存的认知是单纯而美好的:它快、它能扛住高并发、它能保护数据库——它几乎全是优点,是性能优化的银弹。可这次事故让我看到了它的另一面:缓存是一把双刃剑。它在为你抵挡海量流量、岁月静好的时候,你几乎感觉不到它的存在;可一旦它失效(集体过期、热点失效、被穿透),那些它平时默默替你挡下的、远超数据库承受能力的流量,会在那一瞬间反扑回来,而你的数据库,早已习惯了被它保护的"温室生活",根本没有独自面对洪峰的能力。缓存挡得越多,它一旦失效时的反扑就越凶猛——你对它的依赖有多深,它崩塌时的伤害就有多大。
想通这一点,我对待缓存的态度变了:我不再把缓存当成一个"只会带来好处、可以无脑信任"的组件,而是把它当成一个"平时是巨大助力、失效时是巨大风险"的双刃剑,在享受它带来的性能红利的同时,时刻为它的"失效"做好准备。这意味着,我设计缓存时,脑子里始终绷着一根弦:"如果这些缓存在某一刻全没了,会发生什么?我的数据库扛得住吗?我有没有兜底?"——正是这根弦,催生了过期时间打散、互斥重建、熔断降级这一整套"为失效而设计"的防护。这其实是一种更普遍的工程成熟:对任何一个为你"挡在前面、提供保护"的组件(缓存、CDN、限流器、降级开关),都不能只享受它带来的好处,而要同时想清楚"它失效时会怎样",并为那一刻准备好预案。
所以,如果你的系统也重度依赖缓存,我想把这次踩坑最想说的话送给你:请珍惜缓存带给你的性能与从容,但别忘了它是一把双刃剑——在它默默替你挡住惊涛骇浪的同时,请你认真地想一想:万一这道城墙塌了,你拿什么守住身后的数据库?把过期时间打散、给热点 key 加互斥、把不存在的查询拦在外面、给数据库备好熔断降级的最后防线——把这些"为失效而设计"的功课做足,你才能真正驾驭好缓存这把双刃剑,让它只为你所用、而不会反过来伤了你。那次午间的雪崩,正是用一次惊心动魄的服务瘫痪,教会了我这个道理:越是强大、越是被你依赖的东西,越要为它的失效,提前备好后路。愿你我的城墙,都既能挡住洪水,也经得起偶尔的崩塌。
—— 别看了 · 2026