一、问题背景
我们的电商系统在大促期间会开放限量秒杀活动,秒杀商品几十款,单款库存几千到几万不等。秒杀接口 POST /api/seckill 是系统流量入口,正常情况下 QPS 在 2000 左右,大促开场瞬间会冲到 30000+。
为了扛住读压力,商品详情、库存余量、用户限购状态等热点数据全部缓存在 Redis 集群(3 主 3 从,每主 8G maxmemory)。缓存设计上,运营在后台批量导入秒杀商品时,统一把缓存 TTL 设置为 30 分钟,并且由于是同一时刻批量导入,大量 key 的过期时间高度集中在开场后的同一个时间窗口内。
本次大促的秒杀在晚上 20:00 准时开始,商品导入动作发生在 19:25 左右,这意味着绝大部分缓存 key 会在 19:55 ~ 20:00 之间集中失效。
二、故障现象
20:00 活动一开抢,监控告警在 30 秒内全部触发:
- 秒杀接口 P99 延迟从平时的 50ms 飙升至 8s,P999 直接超过 15s;
- 网关层 504 超时大量出现,错误率从 0.1% 涨到 23%;
- Redis 集群 CPU 使用率被打满(各节点 user + sys 接近 100%),但 QPS 反而比平时低——因为大量请求已经穿透;
- 后端 MySQL 主库连接数瞬间打满(max_connections=2000),出现
Too many connections; - 订单创建成功率从 99.5% 掉到 41%,大量用户反馈"点了没反应"“一直在转圈”。
最诡异的一点是:Redis 本身并没有宕机,节点都是 connected 状态,但业务就是全面变慢。这让我们一开始怀疑是网关或应用层的问题,而不是缓存。
三、排查过程
第一步,先确认慢在哪里。在网关机器上抓取秒杀链路的 tracing:
|
|
火焰图清楚显示耗时全部卡在 MySQL 的 SELECT stock 上,而本该走 Redis 的 GET stock:sku_xxx 几乎不见了。第一反应是 Redis 挂了或者连接池耗尽。
第二步,登录 Redis 节点验证。连接正常,但发现一个反常现象:
|
|
keyspace_misses 远大于 keyspace_hits,说明大量 key 查不到,请求全部穿透到 MySQL。再看 expired_keys,在故障窗口内一秒过期近 10 万个 key——典型的集中过期。
第三步,确认过期时间分布。抽样几个秒杀 key:
|
|
绝大多数热点 key 都已失效或即将在几秒内失效,印证了"批量导入时统一 TTL"导致的集中过期。这就是缓存雪崩:同一时刻大量 key 失效,所有请求同时回源数据库。
第四步,确认数据库承压。SHOW PROCESSLIST 里堆满了 SELECT stock FROM seckill_stock WHERE sku=?,几乎都是 Sending data 状态。MySQL 本身能力不足以承接 3 万 QPS 的纯读,连接池被瞬间占满,进而拖垮整个订单链路。
四、解决方案
应急止血(先恢复,再根治):
-
临时对秒杀接口做请求合并 + 本地缓存降级。在应用层对库存查询加一层 Caffeine 本地缓存(TTL 2s),即使 Redis 没值,也先返回上一次的非空结果,避免所有线程同时打库。
-
给热点读加互斥锁(singleflight),同一 sku 的回源只允许一个线程查库并写回 Redis,其余线程等待结果:
|
|
-
紧急扩容 MySQL 只读从库,将库存读流量导向从库,主库只承接下单写。
-
网关侧对
/api/seckill开启令牌桶限流(20000 QPS),超出直接快速失败返回"活动太火爆",保护后端不被彻底压垮。
5 分钟后 P99 回落到 600ms,10 分钟后恢复到 120ms,订单成功率回到 92%。
五、根因分析
根因是缓存 key 集中过期引发的缓存雪崩,叠加了两个设计缺陷:
- TTL 高度一致:运营批量导入时统一设 30 分钟 TTL,导致活动开场瞬间海量 key 同时失效,请求在极短时间内全部击穿到数据库。
- 缺乏防穿透/防雪崩机制:缓存未命中时没有任何合并、降级或随机抖动策略,数据库在没有任何缓冲的情况下直面 3 万 QPS 的回源洪峰。
Redis 没宕机但 CPU 打满,是因为雪崩期间大量 key 同时过期触发主动/被动淘汰扫描,加上连接风暴,使单节点处理效率骤降;而真正的瓶颈在下游 MySQL——它本就不是为承载全部读流量设计的。
六、预防措施
- TTL 加随机抖动:导入缓存时 TTL = 基础值 + 随机(0, 300s),打散过期时间,避免集体失效。
|
|
-
热点 key 永不过期 + 后台异步刷新:对秒杀库存这类极热点数据,设为逻辑不过期,由定时任务在后台主动刷新,彻底规避过期瞬间空窗。
-
回源合并:全站接入 singleflight / 分布式锁,保证同一 key 同一时刻只有一个回源。
-
多级缓存:Redis + 应用本地缓存(Caffeine)双层,Redis 失效时本地缓存仍能兜底。
-
数据库保护:库存读走从库,写主库;并配置合理的连接池上限与熔断(Sentinel/Resilience4j),避免被回源打挂。
-
压测与演练:大促前对"缓存全失效"场景做专项混沌演练,验证降级链路可用。
七、总结
这次故障本质不是 Redis 性能问题,而是缓存生命周期设计缺陷引发的雪崩。集中 TTL 是很容易被忽视的"定时炸弹",平时相安无事,一到流量洪峰就炸。排查上,关键抓手是 Redis 的 keyspace_misses / expired_keys 两个指标——它们能一眼定性"请求是否穿透"。记住:缓存层永远要假设自己会失效,回源必须有合并、必须有抖动、必须有降级。把数据库当成最后一道防线,而不是第一道承受洪峰的堤坝。