秒杀系统设计

秒杀是电商系统中最极端的场景——瞬时流量是平时的数千倍,库存扣减必须绝对正确(超卖是资损事故),用户体验还要好。设计秒杀系统的本质是:如何用最少资源,安全地让最想买的人抢到商品。

一、业务特点与挑战

1
2
3
4
5
        瞬时极高并发
/ \
/ \
/ \
库存不超卖 ─────── 用户体验好(不卡、不白屏、不重复提交)

核心挑战

  • 瞬时流量可能是平时的 100~1000 倍
  • 库存扣减必须在并发下绝对正确
  • 用户体感要流畅(页面快速响应)

二、容量估算

假设:10 万库存的 iPhone,100 万人同时抢购。

1
2
3
4
5
6
峰值 QPS ≈ 100 万(实际集中在前 0.5 秒)

数据库 TPS:单机 MySQL 约 3000~5000 TPS
Redis 单机:10 万 QPS(读),8 万 QPS(写)

结论:流量不能直接打到数据库,必须在前面拦截 99% 以上。

分层拦截漏斗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
100% 请求(100 万 QPS)
│ 前端:静态化 + 按钮置灰 + 防抖 ─→ 拦截 40%

60 万 QPS
│ CDN + 边缘限流 ─→ 拦截 30%

30 万 QPS
│ API 网关限流 + 用户维度排队 ─→ 拦截 80%

6 万 QPS
│ Redis 预减库存 ─→ 拦截 90%(库存只有 10 万)

6,000 QPS
│ MQ 异步排队写入 MySQL

最终到达 DB:~3000 TPS(MySQL 可承受)

三、前端层优化

  • 静态化:秒杀页面做成纯静态 HTML,提前推送到 CDN。唯一动态的是”库存数量”和”按钮状态”,通过异步接口获取
  • 按钮防抖:点击后置灰 3 秒,禁止重复提交
  • 验证码滑块:抢购按钮点击时弹出,人机识别
  • 页面版本控制:秒杀 URL 动态加签,每场活动不同 URL,防止提前暴露

四、网关层

1
2
3
4
5
# Nginx 单 IP 限流:每 IP 每秒最多 10 个请求
limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s;

# URI 级别限流:秒杀接口每秒最多 50000 请求
limit_req_zone $uri zone=seckill_uri:10m rate=50000r/s;

黑白名单:白名单(内部测试/VIP 用户)走独立通道;黑名单(检测到异常行为的 IP/设备)动态加入,实时拦截。

排队机制:用户进入页面后先获取”排队 Token”,服务端用 Redis 有序集合按时间戳排序,批次放行。拿到 Token 才能进入真正的抢购接口。

五、Redis 预减库存——核心逻辑

这是秒杀系统最重要的设计。库存不直接扣减 MySQL,而是先扣 Redis:

1
2
3
4
5
6
7
8
9
10
11
12
1. 活动开始前:将库存总数 SET 到 Redis
SET seckill:stock:12345 100000

2. 用户抢购时:
stock = DECR seckill:stock:12345
if stock < 0:
return "已售罄"
if stock >= 0:
发送异步消息到 Kafka → 异步同步到 MySQL

3. 关键:stock < 0 时回滚
INCR seckill:stock:12345

为什么用 DECR 而不是先 GET 再 SET? Redis 单线程模型下,DECR 是原子操作。先 GET 再 SET 在并发下会产生竞态条件。

防止重复抢购(Lua 脚本原子操作):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 原子操作:判断用户是否抢过 + 扣库存
local uid = KEYS[1]
local stock_key = KEYS[2]
local bought_key = KEYS[3]

-- 检查是否已抢过
if redis.call("SISMEMBER", bought_key, uid) == 1 then
return -1 -- 已抢过
end

-- 扣库存
local stock = redis.call("DECR", stock_key)
if stock < 0 then
redis.call("INCR", stock_key)
return 0 -- 已售罄
end

-- 记录已抢
redis.call("SADD", bought_key, uid)
return 1 -- 抢购成功

六、异步同步到 MySQL

1
2
Redis 扣减成功 → 发送 Kafka 消息 → 消费者批量写入 MySQL
{ user_id, item_id, order_id, timestamp }

消息丢失怎么办? Kafka 持久化 + ACK 确认 + 定时对账任务(对比 Redis 库存 vs MySQL 订单数,自动补单或告警)。

七、熔断降级

熔断:错误率 > 50%(滑动窗口)→ 断路器打开 → 快速返回”系统繁忙” → 冷却 30s → 半开放少量探测 → 正常则恢复。

降级:关闭非核心功能(推荐列表、购物车、积分),返回兜底数据”抢购太火爆,请稍后重试”。

隔离:秒杀服务使用独立机器、独立 Redis、独立数据库。即使秒杀链路崩溃,不影响主站下单/支付/浏览。

八、架构演进路径

1
2
3
4
5
6
7
8
9
10
第一版(初创期):单体应用 + MySQL
└── 问题:秒杀把主库打挂,影响全站

第二版(成长期):Redis 缓存预减库存
└── 问题:Redis 和 MySQL 数据不一致

第三版(成熟期):前端静态化 + 网关限流 + Redis + MQ + 独立部署
└── 问题:热点商品导致 Redis 单分片过热

第四版(规模化):Redis 多副本 + 本地缓存 Caffeine 兜底 + 服务物理隔离

九、小结

秒杀系统设计的核心是一句话:在流量到达数据库之前,用多层漏斗拦截掉 99% 的请求。前端静态化 + 网关限流 + Redis 原子库存预扣 + MQ 异步消峰,这四层构成了标准方案。