那是一次让我记忆犹新的线上雪崩。我们的服务前面挡着一层 Redis 缓存,绝大多数读请求都打在缓存上,数据库平时压力很小、悠闲得很。某天上午十点整,监控突然炸了:数据库 CPU 瞬间飙到 100%,响应时间从几毫秒蹦到几秒,大量请求超时,整个服务几乎瘫痪。诡异的是,流量并没有暴涨多少,就是平日的水平,怎么数据库突然就扛不住了?
我盯着监控曲线复盘,才发现一个关键巧合:故障爆发的十点整,正是我们前一天凌晨批量预热缓存的那批数据集体到期的时刻。那批缓存是头天同一时间一次性灌进 Redis 的,我给它们设了统一的 24 小时过期——于是它们也就在 24 小时后的同一秒,齐刷刷地全部失效了。缓存一旦集体失效,那一瞬间所有本该命中缓存的请求,全部扑了个空,像决堤的洪水一样直接涌向数据库。数据库哪见过这阵仗,平时被缓存挡着只尝到涓涓细流,这下突然被泼了一整缸水,当场就被冲垮了。
这就是缓存架构里最凶险的故障之一:缓存雪崩——大量缓存在同一时间集体失效,导致请求瞬间穿透到数据库,把数据库压垮。它和它的两个"亲戚"——缓存击穿、缓存穿透——构成了用缓存时必须严防死守的三大经典陷阱。这篇文章,就从这次"缓存集体过期引发雪崩"的事故出发,把这三个坑的成因和防护,一次讲透。
先摆几个关于缓存的想当然
动手复盘前,先把我自己曾经深信、后来被雪崩教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "加了缓存,数据库就安全了" | 缓存一旦集体失效, 全部流量瞬间砸向数据库, 比没缓存更危险 |
| "过期时间统一设 24 小时,简单清爽" | 统一过期 = 集体到期 = 缓存雪崩的直接导火索 |
| "查不到的数据,缓存也帮不上忙" | 不缓存空结果, 恶意/无效查询会反复穿透缓存打到库 |
| "热点 key 过期了重新查一下就好" | 热点 key 失效瞬间, 海量请求会同时去查库重建, 即击穿 |
| "缓存就是放数据库前面挡一挡, 没啥讲究" | 过期策略、空值、互斥重建、降级, 处处是学问 |
这些念头的共同病根,是把缓存简单理解成"放在数据库前面的一个加速器",却没意识到缓存和数据库之间是一种需要精心设计的协作关系——缓存失效的时机、方式、以及失效后的重建策略,任何一个没设计好,都可能让这个"加速器"在某个瞬间变成"放大器",把压力成倍地放大给数据库。要看清这次事故,得先把"雪崩、击穿、穿透"这三兄弟分清楚。
第一件事:分清雪崩、击穿、穿透三兄弟
这三个名字很像、又常被混淆,但它们是三种不同的故障,得先精确区分,才能对症下药。
缓存雪崩(Avalanche):大量不同的 key 在同一时间集体失效(或 Redis 整体宕机),导致海量请求同时落到数据库。关键词是"大面积、同时"。我这次就是它。
缓存击穿(Breakdown):某个热点 key(单个、但访问量极大)在失效的那一瞬间,大量并发请求同时发现缓存没了,于是一起冲去数据库查这同一个 key、重建缓存。关键词是"单个热点、瞬间并发"。
缓存穿透(Penetration):查询一个根本不存在的数据,缓存里没有(因为数据本就不存在)、数据库里也没有,于是每次请求都白白穿过缓存打到数据库。关键词是"查不存在的数据,缓存永远挡不住"。下面这张图,把三者的区别画出来:
看懂这张图,三者的差异就清楚了:雪崩是"一大片缓存同时没了",击穿是"一个热点缓存没了引发并发重建",穿透是"压根没有可缓存的数据"。它们的共同后果都是"请求打到数据库",但成因不同,防护手段也各不相同。精确区分故障类型,是对症下药的前提——把击穿当雪崩治,或把穿透当击穿治,都会药不对症。接下来,我们就一个个把它们的解法说清楚。
第二件事:治雪崩——过期时间加随机,别让它们同时死
我那次雪崩的直接导火索,是给一批缓存设了完全相同的过期时间,导致它们同生共死。最简单、最有效的解法,就是给过期时间加上一个随机的扰动,把"集体到期"打散成"在一个时间段内陆续到期",这样任意一个瞬间失效的 key 都只是一小部分,数据库就不会被瞬间冲垮。
// 反例:统一过期时间, 一批缓存同生共死, 到期瞬间全部失效
redis.setex(key, 24 * 3600, value); // 全都是 24 小时, 必然集体到期
// 正解:基础时间 + 随机扰动, 把过期时刻打散开
int base = 24 * 3600;
int jitter = ThreadLocalRandom.current().nextInt(0, 3600); // 0~1小时随机
redis.setex(key, base + jitter, value);
// 这样这批缓存会在 24~25 小时之间陆续过期, 而不是同一秒全死
// 任一瞬间失效的只是一小撮, 数据库压力被摊平
除了打散过期时间,治雪崩还有几层纵深防护。其一,缓存服务本身要高可用:雪崩的另一种成因是 Redis 整个挂了,所以 Redis 要用集群/主从,避免单点。其二,给数据库加一道保护:用限流、熔断,在数据库快被压垮时主动拒绝/排队一部分请求,保住数据库不彻底崩——宁可让一部分请求失败,也好过数据库整个挂掉拖垮所有人。
// 纵深防护:即便缓存失效, 也用限流/熔断保护数据库不被冲垮
public Data getData(String key) {
Data v = cache.get(key);
if (v != null) return v;
// 缓存没命中, 要查库前先过一道限流闸门
if (!rateLimiter.tryAcquire()) { // 拿不到令牌, 说明库压力已大
return degradeResult(); // 优雅降级: 返回兜底数据/提示稍后
}
return loadFromDbAndCache(key);
}
// 限流是数据库的"安全阀": 缓存失效时, 它确保打到库的流量在可承受范围内
所以治雪崩是"三管齐下":过期时间加随机(防集体到期)+ 缓存高可用(防整体宕机)+ 数据库限流熔断(兜最坏情况)。第一条治本、最关键;后两条是纵深防御,确保即便缓存出了意外,数据库也有一道保命的闸门。我那次事故后,正是把这三层都补上,才再没出现过雪崩。
第三件事:治击穿——用互斥锁,只让一个请求去重建
缓存击穿针对的是单个热点 key 失效瞬间的并发问题:成千上万个请求同时发现这个热 key 没了,于是一起冲去数据库查同一份数据、各自重建缓存——数据库被这同一个 key 的重复查询瞬间打爆。解法的核心思路是:当缓存失效需要重建时,只允许一个请求去查数据库重建,其它请求等待这一个重建完成、然后直接用新缓存。这靠互斥锁(分布式锁)来实现。
// 治击穿:用分布式锁, 保证同一个热点 key 只有一个请求去重建
public Data getHotData(String key) {
Data v = cache.get(key);
if (v != null) return v; // 命中直接返回
String lockKey = "lock:" + key;
// 只有抢到锁的那个请求, 才去查库重建
if (redis.setnx(lockKey, "1", 10)) { // setnx + 过期时间, 防死锁
try {
v = cache.get(key); // 双重检查: 可能别人已重建好
if (v != null) return v;
v = loadFromDb(key); // 我来查库
cache.setex(key, ttlWithJitter(), v); // 重建缓存(顺便加随机过期)
return v;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 没抢到锁的请求: 短暂等待后重试读缓存(此时多半已被重建好)
sleep(50);
return getHotData(key);
}
}
这段代码的精髓,是把"无数请求同时查库"变成"一个请求查库、其余请求等结果"。注意几个细节:锁要设过期时间(防止持锁者崩溃导致死锁);拿到锁后要双重检查缓存(可能在你抢锁的间隙别人已经重建好了);重建时顺手给新缓存也加上随机过期(顺便防雪崩)。互斥重建的本质,是用一点点"等待"的代价,换取"数据库只被查一次"的巨大收益。对于真正的超热点 key,还可以考虑"逻辑过期"(缓存永不物理过期,靠后台异步更新),进一步避免重建时的等待。
第四件事:治穿透——缓存空值,或上布隆过滤器
缓存穿透针对的是"查不存在的数据":有人(可能是恶意攻击,也可能是程序 bug)不断查询一个数据库里根本没有的 ID,缓存因为查不到、永远不会命中,每次请求都直直地穿过缓存打到数据库。它的危险在于,缓存对这类请求完全失效。有两种主流解法。
第一种简单:把"空结果"也缓存起来。查库发现这个 ID 不存在,就往缓存里写一个特殊的"空值"标记,并设一个较短的过期时间。这样下次再查这个不存在的 ID,缓存就能命中那个空值标记、直接返回,不再打到数据库。
// 治穿透方案一:缓存空值, 让"不存在"也能被缓存挡住
public Data getData(String key) {
Object v = cache.get(key);
if (v != null) {
return v == NULL_HOLDER ? null : (Data) v; // 命中空值标记, 返回 null
}
Data data = loadFromDb(key);
if (data == null) {
// 数据库也没有: 缓存一个空值标记, 过期时间设短一点
cache.setex(key, 60, NULL_HOLDER); // 防止它长期占着缓存
} else {
cache.setex(key, ttlWithJitter(), data);
}
return data;
}
第二种更强,适合 key 空间很大、恶意攻击的场景:布隆过滤器(Bloom Filter)。它是一个极省内存的概率型数据结构,能快速判断"一个元素一定不存在或可能存在"。把所有真实存在的 key 预先放进布隆过滤器,请求先过它一道:如果布隆过滤器说"一定不存在",直接拒绝、连缓存和数据库都不碰;只有"可能存在"的才放行。这样大批无效查询在最前面就被挡掉了。
// 治穿透方案二:布隆过滤器, 把"一定不存在"的请求挡在最前面
// 初始化时, 把所有有效的 key 灌进布隆过滤器
bloomFilter.putAll(allValidKeysFromDb());
public Data getData(String key) {
// 第一道闸:布隆过滤器说"一定没有", 直接拒绝, 不碰缓存和库
if (!bloomFilter.mightContain(key)) {
return null; // 极省资源地挡掉海量无效/恶意查询
}
// 通过布隆过滤器的, 才走正常的缓存→数据库流程
return getFromCacheOrDb(key);
}
// 注意:布隆过滤器有极小的"误判为存在"概率, 但绝不会"误判为不存在"
// 所以它能 100% 挡住真正不存在的 key, 这正是我们要的
两种方案各有适用:缓存空值实现简单,适合穿透不严重、key 相对有限的情况;布隆过滤器更省内存、防护更强,适合 key 空间巨大、有恶意攻击的场景。实践中也常常两者结合,布隆过滤器挡第一道、空值缓存兜第二道。治穿透的核心,是让"不存在的查询"也能被某种机制快速、低成本地拦下,而不是任由它们次次直达数据库。
第五件事:别让缓存和数据库数据不一致
用缓存还绕不开一个经典难题:当数据更新时,缓存和数据库怎么保持一致?如果处理不当,会出现"数据库已经改了、缓存还是旧值"的脏读。业界趟出的相对稳妥的策略是 Cache Aside(旁路缓存)+ 先更库再删缓存:更新数据时,先更新数据库,然后删除(而不是更新)缓存,让下次读请求自然地从数据库重新加载、回填缓存。
// 更新数据:先更新数据库, 再删除缓存(而非更新缓存)
public void updateData(String key, Data newData) {
db.update(key, newData); // 1. 先更新数据库
cache.del(key); // 2. 再删除缓存, 下次读时自然重建
}
// 为什么是"删除"而非"更新"缓存?
// - 删除更简单、更不容易在并发下产生中间脏状态
// - 懒加载: 没人读的数据没必要立刻重建缓存, 省资源
// 为什么"先更库再删缓存"? 比"先删缓存再更库"在并发下更不易出现长期不一致
需要诚实地说:在高并发下,缓存与数据库的绝对强一致几乎做不到,各种策略都只是把"不一致的窗口"缩到尽量小、概率压到尽量低。对一致性要求极高的场景,可以加"延迟双删"(更库前后各删一次)、或借助消息队列订阅数据库 binlog 来异步更新缓存。但对绝大多数业务,"先更库、再删缓存 + 缓存设合理过期时间"已经足够——即便偶有不一致,过期时间一到也会自我修复。关键是认清:缓存换来的性能,是以接受'短暂、最终会修复的不一致'为代价的。(这其实和之前聊分布式事务、聊 DNS 缓存时的最终一致思想一脉相承。)
第六件事:缓存预热与优雅降级
最后两个实用招数。一是缓存预热:在系统上线、或缓存集群重启后,别等用户请求来了才被动地一个个回填缓存(那一刻就是一场小雪崩),而是主动地、提前把热点数据加载进缓存。但要吸取我这次的教训——预热时记得给过期时间加随机,别又埋下"集体到期"的雷。二是优雅降级:当缓存和数据库都顶不住时,要有兜底,返回一个默认值、一份稍旧的快照、或一句"系统繁忙请稍后",而不是让请求堆死、把整个服务拖垮。
// 缓存预热:系统启动时主动加载热点数据(过期时间务必加随机, 防雪崩)
@PostConstruct
public void warmUp() {
for (String key : hotKeysFromConfig()) {
Data data = loadFromDb(key);
cache.setex(key, ttlWithJitter(), data); // 注意随机过期!
}
}
// 优雅降级:最坏情况下也别让请求堆死, 给个兜底
public Data getDataSafe(String key) {
try {
return getData(key);
} catch (Exception e) {
return staleSnapshotOr(defaultData()); // 返回旧快照/默认值, 保住可用性
}
}
到这儿,缓存三兄弟的成因与防护、以及一致性、预热、降级都齐了。我把它们收成一张决策图:
把这套体系建起来,缓存就从"可能在某瞬间反噬数据库的隐患"变成"稳稳挡住流量的可靠屏障"。最后,拧成几条可直接照做的铁律:
- 过期时间一律加随机扰动,杜绝大批缓存同生共死引发雪崩。
- 热点 key 用互斥锁重建,失效瞬间只放一个请求去查库, 其余等结果。
- 缓存空值或上布隆过滤器,让查不存在的数据也能被低成本挡下, 防穿透。
- 缓存要高可用 + 数据库要限流熔断,即便缓存失效, 也给数据库留一道保命闸门。
- 更新走"先更库再删缓存",接受短暂最终一致, 靠过期时间自我修复。
- 上线/重启主动预热热点数据,但过期时间务必加随机, 别预热出一场新雪崩。
- 永远准备好优雅降级,最坏情况返回兜底数据, 保住可用性而非让服务整个崩。
一张缓存三兄弟速查表
把雪崩、击穿、穿透的特征和对策汇成一张表,设计缓存时对照着防。
| 故障 | 成因 | 关键词 | 主要对策 |
|---|---|---|---|
| 缓存雪崩 | 大量 key 同时失效 / Redis 宕机 | 大面积、同时 | 过期加随机 + 缓存高可用 + 库限流 |
| 缓存击穿 | 单个热点 key 失效瞬间高并发 | 单热点、瞬间 | 互斥锁重建 / 逻辑过期 |
| 缓存穿透 | 查询根本不存在的数据 | 不存在、白穿 | 缓存空值 + 布隆过滤器 |
| 数据不一致 | 更新时缓存与库不同步 | 脏读 | 先更库再删缓存 + 合理过期 |
| 冷启动压垮 | 重启后缓存全空被动重建 | 冷启 | 主动预热(过期仍加随机) |
退一步看:缓存的本质,是一场关于"权衡"的设计
把这三个坑都填平后,我对缓存的理解也上了一个台阶。我意识到,缓存从来不是一个"加上去就万事大吉"的开关,而是一整套需要精心权衡的设计。它用"数据可能短暂不一致"换"读取飞快",用"额外的内存和复杂度"换"数据库的轻松"。而雪崩、击穿、穿透这三个坑,本质上都是这套权衡在某个维度上没设计周全时露出的破绽:雪崩是"失效时机"没设计好,击穿是"重建并发"没设计好,穿透是"无效查询"没设计好。
所以用好缓存,考验的不是你会不会调用 Redis 的 API,而是你有没有把这套权衡的方方面面都想周全:数据什么时候过期、怎么错开过期、失效后谁来重建、不存在的数据怎么挡、数据变了怎么同步、最坏情况怎么兜底。这些问题每一个都对应着一个潜在的故障,而把它们逐一想清楚、防到位,才是从"会用缓存"到"用好缓存"的分水岭。缓存的简单是表象,它背后是一连串关于一致性、并发、容错的深刻取舍。
这也再次呼应了这个系列反复出现的一个主题:那些让系统在关键时刻崩塌的,往往不是什么高深的技术难题,而是一些"想当然"地跳过了的设计细节。给过期时间加个随机数,不过是一行代码;可少了它,就可能在某个整点引发一场雪崩。工程的可靠性,正是由无数个这样不起眼、却至关重要的细节累积而成的。
写在最后
这次"缓存集体过期引发雪崩"的事故,给我最深的警醒,是它展示了缓存这把利器反噬时的可怕。平日里,缓存是我们的功臣,默默挡下 99% 的流量,让数据库优哉游哉;可一旦它在某个瞬间集体失效,它非但不再保护数据库,反而会把积压的全部流量,在一瞬间像决堤洪水般倾泻到数据库上——那个曾经被精心保护、因而容量规划得并不宽裕的数据库,根本扛不住这突如其来的、原本被缓存替它挡掉的全部压力。一个系统对某个组件越是依赖,这个组件一旦失效时的反噬就越剧烈——这是缓存教给我的,一条关于"依赖"的深刻教训。
所以设计缓存,乃至设计任何一个"平时帮你扛压力"的中间层时,都要多想一步那个最坏的问题:如果它突然没了,被它挡在身后的那些组件,扛得住吗?这一步"为失效而设计"的思考,正是区分"能用"和"可靠"的关键。缓存的随机过期、互斥重建、布隆过滤、限流降级,这一整套防护,本质上都是在回答同一个问题——如何让缓存即便在出问题时,也不至于把它身后的数据库一起拖下水。这种"不仅设计正常路径,更要设计失效路径"的思维,贯穿了这个系列的每一篇,也是我最想与你共勉的:真正的健壮,不在于让一切永不失效,而在于让每一处失效,都早已被你预料、并妥善地接住。愿你我手中的每一层缓存,都既能在平时飞快地加速,也能在失效时温柔地谢幕,而不是轰然反噬。
—— 别看了 · 2026