缓存集体过期压垮数据库:缓存雪崩避坑复盘

那是一次教科书级别的雪崩:某个流量高峰的午间我们的服务突然开始大面积超时,告警短信像下雨一样砸进群里。我冲上去一看监控触目惊心——数据库 CPU 瞬间飙到 100%,慢查询日志疯狂刷屏,大量请求堆积超时,紧接着因为请求都卡在数据库上线程被占满,整个服务也跟着雪崩式瘫痪了。最诡异的是出事前流量并没有暴涨,就是平平常常的午高峰,怎么会在某一个瞬间突然就被打垮?排查结果是一个我们完全没意识到的定时炸弹:我们有一大批缓存数据是在同一段时间写入 Redis 的,而且设置了完全相同的过期时间,于是到了某个时刻这一大批缓存约好了似的在同一秒钟集体过期失效,那一瞬间原本被缓存稳稳挡住的海量请求像决堤的洪水一样全部涌向数据库,而数据库根本扛不住这种瞬间洪峰直接被压垮。这就是缓存领域最经典也最凶险的事故之一:缓存雪崩。这篇文章从这次缓存集体过期压垮数据库的雪崩出发,讲透缓存高可用避坑:给过期时间加随机抖动防雪崩、用互斥锁重建防击穿、用缓存空值和布隆过滤器防穿透、用熔断降级做最后兜底别让数据库陪葬、缓存与数据库一致性怎么做,以及一个根本认知——缓存是把双刃剑,越是被你依赖的东西越要为它的失效提前备好后路。

那是一次教科书级别的"雪崩"。某个流量高峰的午间,我们的服务突然开始大面积超时,告警短信像下雨一样砸进群里。我冲上去一看监控,景象触目惊心:数据库的 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 在库里根本不存在,那是穿透。三种事故的"流量特征"不同,据此就能快速分辨、对症下药。而无论哪种,熔断限流降级都是最后那道通用的兜底防线。

我立下的几条缓存使用铁律

这次缓存雪崩事故后,团队的缓存规范里加了这么几条:

  1. 过期时间必加随机抖动:批量写入的缓存,过期时间一律加随机偏移,严禁清一色相同,防雪崩。
  2. 热点 key 互斥重建:高并发的热点 key,回源重建时加互斥锁或用逻辑过期,只让一个请求查库,防击穿。
  3. 不存在的也要拦:对可能被刷不存在数据的接口,缓存空值或上布隆过滤器,防穿透。
  4. 给回源加保护阀:缓存未命中的回源查库路径,加熔断/限流,过载时降级,绝不让数据库被打死。
  5. 更新用 Cache Aside:更新数据时先更库再删缓存,追求最终一致,别盲目追求强一致。
  6. 缓存命中率纳入监控:监控缓存命中率和数据库 QPS,命中率骤降/库 QPS 骤升能第一时间预警缓存问题。
  7. 压测覆盖缓存失效场景:专门压测"缓存大面积失效时数据库扛不扛得住",把雪崩风险提前暴露。

这几条里,第一条是直接根治这次事故的,而第四条"给回源加保护阀"是我觉得最该补、却最常被忽略的一道防线。因为前三招(防雪崩、击穿、穿透)都是在"努力不让请求打到数据库",但你永远无法保证它们 100% 有效——总有你没想到的边界、没防住的情况;而"熔断降级"是在承认"防线总有被突破的一天"之后,为那一天准备的最后兜底。一个成熟的系统,不会天真地以为"我把缓存做好了就万无一失",而是会清醒地为"万一缓存失效了、数据库扛不住了"准备好降级预案。这正是高可用设计的精髓:不是追求"永不出错",而是确保"出错时,损失可控、核心不死"。

写在最后:缓存是把双刃剑

这次缓存雪崩的经历,让我对"缓存"这个我们天天在用的东西,生出了一种敬畏。在那之前,我对缓存的认知是单纯而美好的:它快、它能扛住高并发、它能保护数据库——它几乎全是优点,是性能优化的银弹。可这次事故让我看到了它的另一面:缓存是一把双刃剑。它在为你抵挡海量流量、岁月静好的时候,你几乎感觉不到它的存在;可一旦它失效(集体过期、热点失效、被穿透),那些它平时默默替你挡下的、远超数据库承受能力的流量,会在那一瞬间反扑回来,而你的数据库,早已习惯了被它保护的"温室生活",根本没有独自面对洪峰的能力。缓存挡得越多,它一旦失效时的反扑就越凶猛——你对它的依赖有多深,它崩塌时的伤害就有多大。

想通这一点,我对待缓存的态度变了:我不再把缓存当成一个"只会带来好处、可以无脑信任"的组件,而是把它当成一个"平时是巨大助力、失效时是巨大风险"的双刃剑,在享受它带来的性能红利的同时,时刻为它的"失效"做好准备。这意味着,我设计缓存时,脑子里始终绷着一根弦:"如果这些缓存在某一刻全没了,会发生什么?我的数据库扛得住吗?我有没有兜底?"——正是这根弦,催生了过期时间打散、互斥重建、熔断降级这一整套"为失效而设计"的防护。这其实是一种更普遍的工程成熟:对任何一个为你"挡在前面、提供保护"的组件(缓存、CDN、限流器、降级开关),都不能只享受它带来的好处,而要同时想清楚"它失效时会怎样",并为那一刻准备好预案。

所以,如果你的系统也重度依赖缓存,我想把这次踩坑最想说的话送给你:请珍惜缓存带给你的性能与从容,但别忘了它是一把双刃剑——在它默默替你挡住惊涛骇浪的同时,请你认真地想一想:万一这道城墙塌了,你拿什么守住身后的数据库?把过期时间打散、给热点 key 加互斥、把不存在的查询拦在外面、给数据库备好熔断降级的最后防线——把这些"为失效而设计"的功课做足,你才能真正驾驭好缓存这把双刃剑,让它只为你所用、而不会反过来伤了你。那次午间的雪崩,正是用一次惊心动魄的服务瘫痪,教会了我这个道理:越是强大、越是被你依赖的东西,越要为它的失效,提前备好后路。愿你我的城墙,都既能挡住洪水,也经得起偶尔的崩塌。

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

RAG 答非所问别急着换模型:检索优化避坑复盘

2026-6-1 13:06:35

技术教程

一次查询被执行五六遍:LINQ 延迟执行避坑复盘

2026-6-1 13:17:01

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