秒杀最难的不是扛住流量,是让同一件商品只卖一次
秒杀系统是在极短时间内把远超库存的请求收进有限名额的系统,核心约束不是吞吐,而是库存这条不变量:不能超卖,也尽量不少卖。很多人把它当成一道高并发性能题,指望加机器、加消息队列解决,结果入口越宽超卖越严重,因为判断与扣减不在同一个原子操作里。正确的做法是一层层做漏斗:静态页与 CDN 挡住读流量,网关限流与防刷挡住无效的人,Redis 用 Lua 做预扣,消息队列把下单削峰,数据库以流水和唯一索引兜底。读完你能说清预扣与记账的分工、超时回补的竞态,以及为什么工程上宁可少卖也不敢超卖。
是什么
秒杀系统是在一个确定时刻开放有限名额,让远超名额的用户同时来抢的系统。一句话直觉:它不是"把每个请求处理得更快",而是"用一串漏斗把请求变少,最后只让恰好等于库存的那部分人拿到资格"。
典型场景包括整点抢购、限量球鞋、演唱会门票、限量优惠券包。它们的形状高度一致:读极多、写极少,但那一小撮写全部压在同一个数字上。商品详情可能被看几十万次,真正被修改的库存字段只有一个。
因此它的核心不变量只有三条:不超卖,卖出的数量不超过库存;尽量不少卖,已经扣掉的名额最终能变成有效订单;同一用户不重复中签。其余所有设计——缓存、队列、分库分表——都是为这三条服务的。
核心原理
先建立漏斗,再谈原子性。
浏览器里的倒计时不可信,开始时间必须由服务端裁定,客户端最多提前拉取一次活动状态。商品页尽量静态化放到 CDN,详情不查库;网关按用户、设备、IP 做限流,用答题或验证码把脚本挡在外面;活动库存提前预热进 Redis。每往下一层,请求量少一个数量级,最后真正碰到库存的,只是少数拿到资格的人。
预扣这一步的关键,是"读、判断、写"必须一次完成。先 GET 再 DECR 是两个网络往返,中间任何请求插进来都会把库存扣成负数;DECR 本身原子,但它先扣后判,返回 -1 时货已经减掉了,只能回补,而回补既制造少卖也可能被二次抢走。Lua 脚本在 Redis 的单线程里一次执行完,判断与扣减之间没有缝隙。
-- KEYS[1]=stock:{1001} KEYS[2]=buyers:{1001}
-- ARGV[1]=uid ARGV[2]=qty
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1 -- 同一用户重复提交
end
local left = tonumber(redis.call('GET', KEYS[1]) or '-1')
if left < tonumber(ARGV[2]) then
return 0 -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], 86400)
return left - tonumber(ARGV[2])
两个 key 都带同一个 hash tag {1001},是为了在 Redis Cluster 里落到同一个槽——跨槽的多 key 脚本会直接报错。脚本本身用 EVALSHA 调用,避免每次都把整段脚本传一遍。
预扣成功只代表"抢到了资格",不代表订单成立。消息带着唯一请求 ID 投进队列,按活动 ID 分区,让同一活动的消息进同一分区、保持先后;消费端先用 (activity_id, user_id) 的唯一索引挡住重复,再做一次带条件的账本更新:UPDATE stock SET sold = sold + 1 WHERE sku_id = ? AND sold + 1 <= total。数据库这一层不是闸门,而是账本,它的职责是让"卖出去的每一件"都有据可查。任何一步失败,就把消息投进重试队列,并把预扣回补。
回补正是少卖的主要来源。Redis 扣了但消息丢了、消费一直失败、用户超时未支付、分桶之后某个桶先空,都会让实际成交少于库存。少卖可以补货、发券、二次开售,超卖则是资损加投诉,所以整套设计默认偏向少卖:宁可最后剩下几件,也不能多卖一件。对账任务定时比较 Redis 剩余、数据库已售和有效订单数,对不上就告警,并按流水修正。
单 key 的 Redis 本身也是热点。一个分片再快也有上限,于是有人按库存分桶:100 件拆成 10 个桶各 10 件,请求按用户哈希到桶上各自原子扣减。总量当然不会超卖,但某个桶先空时,整体还有货却卖不出去,桶间余量还得动态借调。更彻底的办法是改规则:限时报名加随机抽签,把"先到先得"变成"人人有机会",既消除了对时误差,也让刷单失去速度优势。
那为什么不直接在数据库上做 UPDATE ... WHERE stock > 0?这条语句本身是原子的,确实不会超卖。但单行更新会被行锁串行化,并发在这里变成排队,吞吐被锁竞争和持久化成本压住;热点行还会拖慢同库的其它业务。数据库适合当账本,不适合当闸门。
常见考点与误区
- 误区一:加机器、加消息队列就能解决超卖。 队列只改变时序,不提供互斥。库存判断如果留在应用代码里,副本越多,读到同一个旧值的窗口就越多,超卖反而更严重。正确表述是把并发收敛到一个原子点上,而不是把并发摊开。
- 误区二:
DECR扣成负数再补回去就行。 补回不是免费的:补的瞬间库存虚高,可能被别人抢走;补失败就永久少卖;判断与扣减之间还隔着一次往返。面试里答"用 DECR 判断返回值"基本会被追问到脚本或条件更新。 - 误区三:有唯一索引就不会重复扣库存。 唯一索引只能挡住重复的订单行,挡不住重复扣减——Redis 和数据库是两个系统,没有共同事务。幂等键要放在预扣入口,让同一请求 ID 的第二次调用直接返回第一次的结果。
- 误区四:Redis 是可靠的,扣了就算数。 主从异步复制,故障切换会丢掉最近一段写入;内存也不适合当账本。Redis 只是闸门,数据库流水才是真相,重启后要能从流水重建库存。
- 误区五:超时回补就是定时把订单关掉。 支付回调和超时取消会并发:用户在最后一秒付款,定时任务同时关单,两边都改状态就可能重复回补。要用状态机的条件更新,例如
WHERE status = '待支付',让只有一方能成功。 - 考点:为什么用 Lua 不用分布式锁? 锁要加锁、续期、释放,至少三次往返,还要处理锁超时与主从切换导致的脑裂;单 key 内的判断用脚本更短、更稳。锁适合保护跨多个 key 的临界区,不适合替代一次计数。
- 考点:分桶之后还会超卖吗? 每个桶各自原子扣,总量天然不超;难的是不少卖与桶间均衡,所以分桶常配动态余量或按比例放量。
- 考点:开始瞬间的公平怎么保证? 服务端时间加提前下发的入场 token,再留一秒左右的误差窗口;强一致的毫秒级对时做不到,抽签比抢更公平。
实践建议
- 预热用
SETNX,并给库存与买家集合都设过期时间。 活动开始后缓存失效回源会瞬间打穿数据库;活动结束不清理,买家集合会一直长。 - 所有写接口带客户端生成的请求 ID。 预扣、消息消费、数据库插入、回补四处都查同一个幂等键,重试才不会变成重复扣减。
- 预扣与记账分离,账本记流水而不是只记余额。 流水包含请求 ID、SKU、增减量、状态,对账任务按流水重算;一旦出现负库存,立刻熔断该 SKU 并告警。
- 限流阈值按下游容量倒推,不要按入口 QPS 拍脑袋。 给队列堆积、下单失败率、Redis 与数据库的库存差分别设告警,并演练 Redis 宕机与消息积压两种故障。
- 压测必须覆盖热点单 key 和超时回补。 前者决定吞吐上限,后者最容易在凌晨低峰期出事故,因为定时任务和支付回调正好撞在一起。
- 能抽签就别抢。 把"整点开抢"改成"限时报名加随机中签",系统压力和用户公平性会同时改善,这往往是比调参更有效的一刀。
一句话总结:秒杀的工程量不在把流量扛住,而在把库存的判断与扣减压进同一个原子点,再用账本兜住它的谎。