秒杀接口大面积超时:Redis 缓存雪崩的定位与修复

大促秒杀开始缓存集中过期,Redis 被打满后大量请求穿透到数据库,接口 P99 从 50ms 飙到 8s。

一、问题背景

我们的电商系统在大促期间会开放限量秒杀活动,秒杀商品几十款,单款库存几千到几万不等。秒杀接口 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:

1
2
3
span: gateway -> seckill-svc -> redis (GET stock:sku_123)  3ms
span: gateway -> seckill-svc -> mysql (SELECT stock ...)    7200ms
span: gateway -> seckill-svc -> mysql (INSERT order)        timeout

火焰图清楚显示耗时全部卡在 MySQL 的 SELECT stock 上,而本该走 Redis 的 GET stock:sku_xxx 几乎不见了。第一反应是 Redis 挂了或者连接池耗尽。

第二步,登录 Redis 节点验证。连接正常,但发现一个反常现象:

1
2
3
4
5
6
$ redis-cli info stats | grep -E "instantaneous_ops_per_sec|evicted_keys|keyspace_hits|keyspace_misses"
instantaneous_ops_per_sec:1850
evicted_keys:0
keyspace_hits:201
keyspace_misses:1640
expired_keys:98231

keyspace_misses 远大于 keyspace_hits,说明大量 key 查不到,请求全部穿透到 MySQL。再看 expired_keys,在故障窗口内一秒过期近 10 万个 key——典型的集中过期

第三步,确认过期时间分布。抽样几个秒杀 key:

1
2
3
4
5
6
7
8
$ redis-cli TTL stock:sku_1001
(integer) -2
$ redis-cli TTL stock:sku_1002
(integer) -2
$ redis-cli --scan --pattern stock:* | head -5
stock:sku_1021
$ redis-cli TTL stock:sku_1021
(integer) 7

绝大多数热点 key 都已失效或即将在几秒内失效,印证了"批量导入时统一 TTL"导致的集中过期。这就是缓存雪崩:同一时刻大量 key 失效,所有请求同时回源数据库。

第四步,确认数据库承压。SHOW PROCESSLIST 里堆满了 SELECT stock FROM seckill_stock WHERE sku=?,几乎都是 Sending data 状态。MySQL 本身能力不足以承接 3 万 QPS 的纯读,连接池被瞬间占满,进而拖垮整个订单链路。

四、解决方案

应急止血(先恢复,再根治):

  1. 临时对秒杀接口做请求合并 + 本地缓存降级。在应用层对库存查询加一层 Caffeine 本地缓存(TTL 2s),即使 Redis 没值,也先返回上一次的非空结果,避免所有线程同时打库。

  2. 给热点读加互斥锁(singleflight),同一 sku 的回源只允许一个线程查库并写回 Redis,其余线程等待结果:

1
2
3
4
5
6
7
8
9
// 伪代码:singleflight 回源
String val = redis.get(key);
if (val == null) {
    val = singleflight.exec(key, () -> {
        String dbVal = db.queryStock(sku);
        redis.setex(key, baseTtl + random(0,300), dbVal); // 加随机抖动
        return dbVal;
    });
}
  1. 紧急扩容 MySQL 只读从库,将库存读流量导向从库,主库只承接下单写。

  2. 网关侧对 /api/seckill 开启令牌桶限流(20000 QPS),超出直接快速失败返回"活动太火爆",保护后端不被彻底压垮。

5 分钟后 P99 回落到 600ms,10 分钟后恢复到 120ms,订单成功率回到 92%。

五、根因分析

根因是缓存 key 集中过期引发的缓存雪崩,叠加了两个设计缺陷:

  • TTL 高度一致:运营批量导入时统一设 30 分钟 TTL,导致活动开场瞬间海量 key 同时失效,请求在极短时间内全部击穿到数据库。
  • 缺乏防穿透/防雪崩机制:缓存未命中时没有任何合并、降级或随机抖动策略,数据库在没有任何缓冲的情况下直面 3 万 QPS 的回源洪峰。

Redis 没宕机但 CPU 打满,是因为雪崩期间大量 key 同时过期触发主动/被动淘汰扫描,加上连接风暴,使单节点处理效率骤降;而真正的瓶颈在下游 MySQL——它本就不是为承载全部读流量设计的。

六、预防措施

  1. TTL 加随机抖动:导入缓存时 TTL = 基础值 + 随机(0, 300s),打散过期时间,避免集体失效。
1
2
int ttl = 1800 + ThreadLocalRandom.current().nextInt(0, 300);
redis.setex(key, ttl, value);
  1. 热点 key 永不过期 + 后台异步刷新:对秒杀库存这类极热点数据,设为逻辑不过期,由定时任务在后台主动刷新,彻底规避过期瞬间空窗。

  2. 回源合并:全站接入 singleflight / 分布式锁,保证同一 key 同一时刻只有一个回源。

  3. 多级缓存:Redis + 应用本地缓存(Caffeine)双层,Redis 失效时本地缓存仍能兜底。

  4. 数据库保护:库存读走从库,写主库;并配置合理的连接池上限与熔断(Sentinel/Resilience4j),避免被回源打挂。

  5. 压测与演练:大促前对"缓存全失效"场景做专项混沌演练,验证降级链路可用。

七、总结

这次故障本质不是 Redis 性能问题,而是缓存生命周期设计缺陷引发的雪崩。集中 TTL 是很容易被忽视的"定时炸弹",平时相安无事,一到流量洪峰就炸。排查上,关键抓手是 Redis 的 keyspace_misses / expired_keys 两个指标——它们能一眼定性"请求是否穿透"。记住:缓存层永远要假设自己会失效,回源必须有合并、必须有抖动、必须有降级。把数据库当成最后一道防线,而不是第一道承受洪峰的堤坝。

使用 Hugo 构建
主题 StackJimmy 设计