#故障复盘 #稳定性 #缓存 #架构实战

一次缓存雪崩的完整复盘:从现象到根因到架构改进

一次典型缓存雪崩的完整复盘:现象、时间线、根因、止血与后续架构改进,附可用检查清单。

✍️ diunilaomei 📅 2025-04-15 📝 约 1290 字 ⏱️ 约 3 分钟
📑 章节目录
字号

现象:凌晨的「第一波用户」全部超时

事故发生在一次大促预热日的零点刚过。监控上最先报警的是网关层 5xx 率从 0.1% 突然冲到 23%,随后应用层 P99 延迟从 120ms 飙升到 8 秒,紧接着数据库告警:连接数打满、慢查询堆积。

第一批受影响的用户,恰恰是冲进活动的「最活跃用户」。这类用户越活跃,越会集中命中同一批热点数据——这为后面的根因埋下了伏笔。

时间线(节选)

时间事件
00:00大促预热开始,流量按预期爬升
00:12网关 5xx 突增,P99 开始恶化
00:14缓存命中率从 95% 跌到 31%
00:15数据库连接打满,出现雪崩征兆
00:19决策:全站缓存击穿保护开关打开 + 服务限流收紧
00:26命中率回升,P99 回到 500ms 内
00:35业务恢复,进入复盘

根因分析:为什么是「雪崩」而不是普通的缓存失效?

表面原因是:一批 key 在同一时刻过期,回源请求同时打到数据库。但往下挖一层,真正的问题有三个:

  1. 热点 key 集中在同一批:大促商品的价格、库存、活动文案都存在同一个缓存前缀下,TTL 又设成了相同的 30 分钟。过期时间是从上线那一刻开始算的,所有 key「同生同死」。
  2. 回源没有「单飞」保护:缓存 miss 后所有请求直接穿透到数据库,没有加分布式锁做单飞,也没有本地短暂兜底。
  3. 数据库侧没有限流缓冲:慢查询堆积反过来拖垮了正常请求,形成了「缓存 miss → DB 排队 → 更慢 → 更多请求堆积」的正反馈。

一句话根因:缓存过期时间设计没有考虑「同时失效」的放大效应,回源路径缺少层层保护的兜底

止血动作(按顺序)

  1. 先恢复,再定位:打开缓存穿透保护(null 值短暂缓存 + 单飞锁),收紧网关限流,让流量降下来。
  2. 手动刷缓存:对热点 key 提前预热,用「过期时间 + 随机抖动」重新写入。
  3. DB 侧限流:对回源 SQL 加队列与超时保护,防止慢查询继续堆积。

15 分钟内业务恢复。恢复后没有立刻散会,而是把时间线、命令、变更记录全部留档。

后续架构改进

短期(一周内)

  • 所有缓存 TTL 改为「基础值 + 随机抖动(±20%)」,从源头避免批量同时过期。
  • 热点 key 识别:对访问频率 top 的 key 不设过期(逻辑过期 + 异步刷新)。
  • 回源路径统一封装:单飞锁(同 key 只允许一个请求回源)+ null 值短缓存。

中期(一个迭代内)

  • 为「回源」增加独立的限流与超时保护,DB 侧按服务设置连接配额。
  • 新增缓存命中率、回源 QPS、单 key 回源并发三张监控图,命中率跌破阈值自动告警。

长期(架构层面)

  • 把「缓存体系」升级为多级:进程内本地缓存(Caffeine)→ 分布式缓存 → DB,本地缓存为热点 key 提供 50ms 级兜底。
  • 大促前做故障演练:直接模拟「缓存全部失效 5 分钟」,验证降级链路真的能扛住。

如果重来一次,我会改什么

事故当天其实有 10 分钟窗口是可以避免全量雪崩的:预热脚本只刷了「价格」没刷「库存前缀」,而告警在命中率刚跌破 80% 时其实就响了——当时值班判断是「预热还没生效」,没有立刻执行降级预案。

教训:告警响了就是预案启动条件,不要用「再等等看」赌它自己恢复。这个判断准则我后来写进了团队的故障手册第一条。

这起事故过去挺久了,但直到现在,每次大促前我还会把这份复盘翻出来过一遍——不是记性差,是因为同类事故往往不是「不知道」,而是「上线那晚恰好没人多想一秒」。

如果这篇对你有用,故障复盘模板可以直接拿去用:故障复盘模板。排查时用的工具链,我写在工具栏目里了。

相关阅读

栏目全部 →