现象:凌晨的「第一波用户」全部超时
事故发生在一次大促预热日的零点刚过。监控上最先报警的是网关层 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 在同一时刻过期,回源请求同时打到数据库。但往下挖一层,真正的问题有三个:
- 热点 key 集中在同一批:大促商品的价格、库存、活动文案都存在同一个缓存前缀下,TTL 又设成了相同的 30 分钟。过期时间是从上线那一刻开始算的,所有 key「同生同死」。
- 回源没有「单飞」保护:缓存 miss 后所有请求直接穿透到数据库,没有加分布式锁做单飞,也没有本地短暂兜底。
- 数据库侧没有限流缓冲:慢查询堆积反过来拖垮了正常请求,形成了「缓存 miss → DB 排队 → 更慢 → 更多请求堆积」的正反馈。
一句话根因:缓存过期时间设计没有考虑「同时失效」的放大效应,回源路径缺少层层保护的兜底。
止血动作(按顺序)
- 先恢复,再定位:打开缓存穿透保护(null 值短暂缓存 + 单飞锁),收紧网关限流,让流量降下来。
- 手动刷缓存:对热点 key 提前预热,用「过期时间 + 随机抖动」重新写入。
- DB 侧限流:对回源 SQL 加队列与超时保护,防止慢查询继续堆积。
15 分钟内业务恢复。恢复后没有立刻散会,而是把时间线、命令、变更记录全部留档。
后续架构改进
短期(一周内)
- 所有缓存 TTL 改为「基础值 + 随机抖动(±20%)」,从源头避免批量同时过期。
- 热点 key 识别:对访问频率 top 的 key 不设过期(逻辑过期 + 异步刷新)。
- 回源路径统一封装:单飞锁(同 key 只允许一个请求回源)+ null 值短缓存。
中期(一个迭代内)
- 为「回源」增加独立的限流与超时保护,DB 侧按服务设置连接配额。
- 新增缓存命中率、回源 QPS、单 key 回源并发三张监控图,命中率跌破阈值自动告警。
长期(架构层面)
- 把「缓存体系」升级为多级:进程内本地缓存(Caffeine)→ 分布式缓存 → DB,本地缓存为热点 key 提供 50ms 级兜底。
- 大促前做故障演练:直接模拟「缓存全部失效 5 分钟」,验证降级链路真的能扛住。
如果重来一次,我会改什么
事故当天其实有 10 分钟窗口是可以避免全量雪崩的:预热脚本只刷了「价格」没刷「库存前缀」,而告警在命中率刚跌破 80% 时其实就响了——当时值班判断是「预热还没生效」,没有立刻执行降级预案。
教训:告警响了就是预案启动条件,不要用「再等等看」赌它自己恢复。这个判断准则我后来写进了团队的故障手册第一条。
这起事故过去挺久了,但直到现在,每次大促前我还会把这份复盘翻出来过一遍——不是记性差,是因为同类事故往往不是「不知道」,而是「上线那晚恰好没人多想一秒」。