缓存集体过期压垮数据库:雪崩击穿穿透避坑

一次让我记忆犹新的线上雪崩:服务前面挡着一层 Redis 缓存,绝大多数读请求都打在缓存上,数据库平时悠闲得很。某天上午十点整监控突然炸了,数据库 CPU 瞬间飙到 100%、响应从几毫秒蹦到几秒、大量请求超时、整个服务几乎瘫痪,可流量并没暴涨多少,就是平日水平。盯着曲线复盘才发现一个关键巧合:故障爆发的十点整,正是前一天凌晨批量预热缓存的那批数据集体到期的时刻——那批缓存头天同一时间一次性灌进 Redis、我又给它们设了统一的 24 小时过期,于是它们也就在 24 小时后的同一秒齐刷刷全部失效,那一瞬间所有本该命中缓存的请求全部扑空,像决堤洪水直接涌向数据库,平时只尝到涓涓细流的数据库被突然泼了一整缸水当场冲垮。这就是缓存雪崩。这篇文章从这次缓存集体过期引发雪崩的事故出发,讲透缓存三兄弟:分清雪崩击穿穿透、治雪崩用过期加随机扰动加缓存高可用加库限流、治击穿用互斥锁只让一个请求重建、治穿透用缓存空值加布隆过滤器,以及先更库再删缓存的一致性策略、缓存预热与优雅降级。

那是一次让我记忆犹新的线上雪崩。我们的服务前面挡着一层 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());  // 返回旧快照/默认值, 保住可用性
    }
}

到这儿,缓存三兄弟的成因与防护、以及一致性、预热、降级都齐了。我把它们收成一张决策图:

把这套体系建起来,缓存就从"可能在某瞬间反噬数据库的隐患"变成"稳稳挡住流量的可靠屏障"。最后,拧成几条可直接照做的铁律:

  1. 过期时间一律加随机扰动,杜绝大批缓存同生共死引发雪崩。
  2. 热点 key 用互斥锁重建,失效瞬间只放一个请求去查库, 其余等结果。
  3. 缓存空值或上布隆过滤器,让查不存在的数据也能被低成本挡下, 防穿透。
  4. 缓存要高可用 + 数据库要限流熔断,即便缓存失效, 也给数据库留一道保命闸门。
  5. 更新走"先更库再删缓存",接受短暂最终一致, 靠过期时间自我修复。
  6. 上线/重启主动预热热点数据,但过期时间务必加随机, 别预热出一场新雪崩。
  7. 永远准备好优雅降级,最坏情况返回兜底数据, 保住可用性而非让服务整个崩。

一张缓存三兄弟速查表

把雪崩、击穿、穿透的特征和对策汇成一张表,设计缓存时对照着防。

故障 成因 关键词 主要对策
缓存雪崩 大量 key 同时失效 / Redis 宕机 大面积、同时 过期加随机 + 缓存高可用 + 库限流
缓存击穿 单个热点 key 失效瞬间高并发 单热点、瞬间 互斥锁重建 / 逻辑过期
缓存穿透 查询根本不存在的数据 不存在、白穿 缓存空值 + 布隆过滤器
数据不一致 更新时缓存与库不同步 脏读 先更库再删缓存 + 合理过期
冷启动压垮 重启后缓存全空被动重建 冷启 主动预热(过期仍加随机)

退一步看:缓存的本质,是一场关于"权衡"的设计

把这三个坑都填平后,我对缓存的理解也上了一个台阶。我意识到,缓存从来不是一个"加上去就万事大吉"的开关,而是一整套需要精心权衡的设计。它用"数据可能短暂不一致"换"读取飞快",用"额外的内存和复杂度"换"数据库的轻松"。而雪崩、击穿、穿透这三个坑,本质上都是这套权衡在某个维度上没设计周全时露出的破绽:雪崩是"失效时机"没设计好,击穿是"重建并发"没设计好,穿透是"无效查询"没设计好。

所以用好缓存,考验的不是你会不会调用 Redis 的 API,而是你有没有把这套权衡的方方面面都想周全:数据什么时候过期、怎么错开过期、失效后谁来重建、不存在的数据怎么挡、数据变了怎么同步、最坏情况怎么兜底。这些问题每一个都对应着一个潜在的故障,而把它们逐一想清楚、防到位,才是从"会用缓存"到"用好缓存"的分水岭。缓存的简单是表象,它背后是一连串关于一致性、并发、容错的深刻取舍。

这也再次呼应了这个系列反复出现的一个主题:那些让系统在关键时刻崩塌的,往往不是什么高深的技术难题,而是一些"想当然"地跳过了的设计细节。给过期时间加个随机数,不过是一行代码;可少了它,就可能在某个整点引发一场雪崩。工程的可靠性,正是由无数个这样不起眼、却至关重要的细节累积而成的。

写在最后

这次"缓存集体过期引发雪崩"的事故,给我最深的警醒,是它展示了缓存这把利器反噬时的可怕。平日里,缓存是我们的功臣,默默挡下 99% 的流量,让数据库优哉游哉;可一旦它在某个瞬间集体失效,它非但不再保护数据库,反而会把积压的全部流量,在一瞬间像决堤洪水般倾泻到数据库上——那个曾经被精心保护、因而容量规划得并不宽裕的数据库,根本扛不住这突如其来的、原本被缓存替它挡掉的全部压力。一个系统对某个组件越是依赖,这个组件一旦失效时的反噬就越剧烈——这是缓存教给我的,一条关于"依赖"的深刻教训。

所以设计缓存,乃至设计任何一个"平时帮你扛压力"的中间层时,都要多想一步那个最坏的问题:如果它突然没了,被它挡在身后的那些组件,扛得住吗?这一步"为失效而设计"的思考,正是区分"能用"和"可靠"的关键。缓存的随机过期、互斥重建、布隆过滤、限流降级,这一整套防护,本质上都是在回答同一个问题——如何让缓存即便在出问题时,也不至于把它身后的数据库一起拖下水。这种"不仅设计正常路径,更要设计失效路径"的思维,贯穿了这个系列的每一篇,也是我最想与你共勉的:真正的健壮,不在于让一切永不失效,而在于让每一处失效,都早已被你预料、并妥善地接住。愿你我手中的每一层缓存,都既能在平时飞快地加速,也能在失效时温柔地谢幕,而不是轰然反噬。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

让大模型返回 JSON 却偶发崩溃:LLM 结构化输出避坑

2026-5-30 11:57:58

技术教程

一句 LINQ 查了五六遍:C# 延迟执行避坑复盘

2026-5-30 12:07:52

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索