秒杀系统设计实战

秒杀是“把一整天的流量压缩到一秒”的极端场景:瞬时并发极高、库存只有个位数,还要防刷与限购。这类系统的设计原则是层层过滤,把绝大多数请求挡在数据库之前,并且明确“宁可少卖也不超卖”。本章给出一套可落地的分层方案。

三个核心挑战

挑战表现应对主线
瞬时高并发开抢瞬间 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 保证库存不被超卖,消息队列把写入削峰,数据库用唯一约束做最后兜底;同时接受少量少卖,靠超时回补与对账修正,并在评审阶段就把这条取舍讲清楚。

笔记加载中…