秒杀系统设计实战
秒杀是“把一整天的流量压缩到一秒”的极端场景:瞬时并发极高、库存只有个位数,还要防刷与限购。这类系统的设计原则是层层过滤,把绝大多数请求挡在数据库之前,并且明确“宁可少卖也不超卖”。本章给出一套可落地的分层方案。
三个核心挑战
| 挑战 | 表现 | 应对主线 |
|---|---|---|
| 瞬时高并发 | 开抢瞬间 QPS 达平时的百倍以上 | 分层限流、页面静态化、异步化 |
| 超卖 | 卖出数量大于库存 | Redis 原子扣减 + 数据库唯一约束兜底 |
| 黄牛与刷单 | 脚本抢购、同一用户多单 | 风控、限购标记、验证码、行为校验 |
分层方案总览
浏览器/App → 前端限流:按钮置灰、答题验证、静态资源走 CDN
↓
接入层 → 网关限流:按接口/用户/IP 限流,超限直接快速失败
↓
应用层 → 库存校验、风控、生成订单号、投递消息(不直接写库)
↓
缓存层 → Redis 预热库存,用 Lua 脚本原子扣减
↓
消息队列 → 异步削峰,消费端按数据库能力匀速落库
↓
数据库 → 唯一索引与条件更新兜底,写入最终订单
每一层都在减少进入下一层的请求量,理想情况下能到达数据库的只有真正抢到的那部分请求。
前端与网关
- 页面静态化:商品页与倒计时由静态资源承担,避免开抢瞬间把流量打到应用。
- 按钮防重复点击:点击后置灰并显示倒计时,客户端只发一次请求。
- 答题或验证码:把脚本请求的到达时间打散,同时抬高刷单成本。
- 网关限流:秒杀接口单独设阈值(例如单机 2000 QPS),超限直接返回“活动太火爆”。
库存预热到 Redis
活动开始前把库存写入 Redis,键按维度拆分,避免所有流量打同一个 key:
redis-cli SET seckill:stock:sku1001 100 # 预热库存
redis-cli SET seckill:user:sku1001:u9001 1 NX EX 3600 # 用户限购标记,1 小时有效
Lua 脚本原子扣减
“判断库存、扣减、记录用户”三步必须在 Redis 内一次原子完成,否则并发下依然会超卖:
-- 扣减脚本逻辑(示意,实际以 EVAL 方式交给 Redis 执行)
if redis.call('EXISTS', KEYS[2]) == 1 then return -1 end -- 该用户已抢过
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock <= 0 then return 0 end -- 库存不足
redis.call('DECR', KEYS[1]) -- 原子扣减
redis.call('SET', KEYS[2], '1', 'EX', 3600) -- 记录限购标记
return 1 -- 扣减成功
redis-cli --eval seckill.lua seckill:stock:sku1001 seckill:user:sku1001:u9001
# 输出:1 抢购成功;0 库存不足;-1 重复抢购
返回值语义要固定下来(1 成功、0 售罄、-1 重复下单),应用层据此返回不同提示,避免用户困惑。
MQ 异步下单
扣减成功后不要同步写库,而是投递消息由消费端落单,把写入压力交给可控速率的消费者:
// 以下片段需放入 main 函数中运行(省略库存与消息组件的实现)
code := deductStock(ctx, skuID, userID) // 执行上面的 Lua 脚本
if code != 1 {
return resp.Fail("已售罄或重复下单") // 0 与 -1 都不进入下单流程
}
err := mq.Publish(ctx, "seckill_order", OrderMsg{
RequestID: uuid.NewString(), // 幂等键,消费端据此去重
UserID: userID,
SkuID: skuID,
})
if err != nil {
rollbackStock(ctx, skuID, userID) // 消息发送失败必须回补库存,否则少卖
return resp.Fail("系统繁忙,请重试")
}
return resp.OK("排队中") // 前端轮询查询抢购结果
数据库兜底
即便缓存设计存在漏洞,数据库层也必须保证不超卖:
-- 条件更新扣减:库存足够时才成功,影响行数 0 即表示扣减失败
UPDATE sku_stock SET stock = stock - 1
WHERE sku_id = 1001 AND stock > 0;
-- 同一用户同一商品只允许一单,唯一索引在数据库层兜底
ALTER TABLE seckill_order ADD UNIQUE KEY uk_user_sku (user_id, sku_id);
消费端的幂等依赖 uk_user_sku 或独立的请求去重表:插入冲突即视为已处理,直接确认消息即可。
超卖与少卖的权衡
| 现象 | 成因 | 代价 | 处理倾向 |
|---|---|---|---|
| 超卖 | 校验与扣减非原子、缓存与数据库不一致 | 履约失败、赔付、口碑损失 | 绝不允许,靠原子扣减与唯一约束消除 |
| 少卖 | 消息丢失、回补失败、抢到后未支付 | 库存浪费、收入减少 | 可接受,用超时回补与对账把损失降到最低 |
结论:先保证不超卖,再通过超时未支付自动回补库存、每日对账修正来压缩少卖。这条取舍必须在需求评审阶段就和业务方确认,否则上线后必然争论。
防刷手段简述
- 用户维度限购:Redis 限购标记加数据库唯一索引双重限制。
- 风控前置:设备指纹、IP 聚集度、点击行为轨迹异常的直接拦在网关。
- 接口签名与时间戳:防止脚本直接构造请求绕过前端。
- 异步返回结果:前端轮询而非同步等待,减少连接占用与超时风险。
小结:秒杀的关键在于分层过滤与原子扣减——前端与网关挡掉绝大多数流量,Redis 加 Lua 保证库存不被超卖,消息队列把写入削峰,数据库用唯一约束做最后兜底;同时接受少量少卖,靠超时回补与对账修正,并在评审阶段就把这条取舍讲清楚。