秒杀系统设计:库存防超卖与流量削峰全链路 — editorial cover photo

秒杀系统设计:库存防超卖与流量削峰全链路

秒杀是系统设计的试金石,它把高并发、防超卖、热点、削峰全部压到一个请求路径上。能设计好秒杀,等于把限流、缓存、消息队列、最终一致性都过了一遍。

2025年4月20日·返回文章列表
文章大纲

秒杀几乎是高并发系统设计的「综合考试」。它把瞬时洪峰流量、严格防超卖、热点账户、异步履约这几个难题,全部压到一条请求路径上。能把秒杀设计好,基本等于把限流、缓存、消息队列、最终一致性都过了一遍。

所以面试爱问秒杀,因为它能考出候选人对高并发的整体理解。但秒杀不只是面试题,大促、抢购、抢券这些真实业务里它天天发生。这篇把秒杀系统的设计从流量入口到落库,每一层的取舍讲清楚。

图:秒杀核心思路——层层漏斗,让流量到不了 DB

核心矛盾,瞬时洪峰对上强一致防超卖

秒杀的本质矛盾有两个。

PlantUML
正在加载图表…
PlantUML diagram

图:秒杀流量经 CDN、网关限流、验证码、Redis 预减、MQ 异步五层漏斗,洪峰稀释成涓流再到数据库

瞬时洪峰。平时可能几十 QPS 的接口,秒杀开始那一秒涌入几万甚至几十万 QPS,是日常流量的千倍万倍。如果没有保护,这股流量会瞬间打垮数据库和下游服务。

强一致防超卖。库存 100 件就是 100 件,一件不能多卖。超卖了是资损(卖给用户的东西没有),少卖了是浪费(库存没卖完但用户买不到)。

这两个矛盾是冲突的。防超卖要求强一致,强一致要求加锁或者走数据库事务,而锁和事务在高并发下是性能毒药。要扛洪峰就得少加锁,要防超卖就得加锁。秒杀系统的所有设计,都是在解这个矛盾。

解矛盾的思路就一句话,让流量根本到不了数据库。用层层漏斗把洪峰稀释成涓流,最终到数据库的写入只有几百 TPS,数据库用传统的事务就能保证一致性。

第一层,CDN 和静态化,挡掉 99% 的流量

秒杀页面、商品详情、活动规则,这些内容是静态的,应该全部推到 CDN。用户进来,99% 的请求根本不到你的服务器,CDN 就地返回了。只有真正点「抢购」按钮的那一下,才走动态接口打到后端。

这一层看似简单但极重要。我见过一个团队秒杀挂了,原因就是活动页没静态化,几百万用户刷新页面,每个请求都打到应用服务器查数据库取活动信息,服务器直接被打爆。把页面静态化推 CDN 后,同样流量轻松扛住。

动态的内容(库存数字、用户状态)单独走异步接口,而且要做节流,不要实时刷新库存数字(用户也不需要看到精确的实时库存),定时刷新一次就够。

第二层,网关限流,把洪峰削成可控流量

过了 CDN 的动态请求,到网关这一层要限流。网关是流量的总闸,不做限流等于裸奔。

限流用令牌桶或者漏桶算法。令牌桶允许一定程度的突发(攒着的令牌可以一次性用掉),漏桶则是严格的匀速输出。秒杀场景通常用令牌桶,因为它更灵活,能应对合理的突发流量。

限流要分维度。全局的总 QPS 限流(保护整个系统),单 IP 的频控(防止单点刷),单用户的频控(防黄牛)。多层限流叠加,把恶意流量和超量流量都挡在业务逻辑之外。

蜂控平台做风控,限流也是核心能力。我们用 Lua 脚本在 Redis 上做原子的计数器扣减,等价于一个分布式限流器。秒杀的限流思路一样,区别是秒杀的限流阈值更激进(因为就是要挡掉绝大部分请求)。

第三层,验证码或者答题,人为制造错峰

这一层有两层意义。一是反外挂机器人,机器抢购比人快太多,加个验证码把机器人挡掉一部分。二是人为制造错峰,用户答题速度不同,原本同一秒涌入的流量被拉平成几十秒的持续流量。

验证码的错峰效果是实打实的。一次大促加了滑块验证码,后端峰值直接降了一半多,不是因为挡掉了多少用户,而是流量被均匀分散了。这个效果对系统稳定特别关键。

第四层,Redis 预减库存,原子操作防超卖

这一层是防超卖的核心。库存前置到 Redis,扣减用 Lua 脚本保证原子性。

PlantUML
正在加载图表…
PlantUML diagram

图:抢购请求经 Redis Lua 原子扣减,成功发 MQ 异步落库,失败直接返回已抢完,DB 无压力

为什么不用 MySQL 直接扣。MySQL 的 UPDATE stock = stock - 1 WHERE id = X AND stock > 0 逻辑上能防超卖,但高并发下行锁激烈,几十个请求同时抢一行,大部分都在等行锁,TPS 上不去。MySQL 扛秒杀的写入瓶颈在几百到一两千 TPS,而秒杀的抢购请求远超这个数。

Redis 的单线程模型反而成了优势。Lua 脚本在 Redis 里是原子执行的,不会被其他命令打断。扣库存的逻辑(判断大于 0 再减 1)写进 Lua,几万 QPS 的扣减都能串行化处理,而且单次执行极快(微秒级)。

local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
return 1 -- 扣减成功
else
return -1 -- 库存不足
end

扣减成功的请求,发 MQ 异步创建订单。扣减失败的,直接返回「已抢完」。这样真正到数据库的写入,是 MQ 消费端按自己的节奏消费的,几百 TPS 平滑写入,数据库毫无压力。

这就是「削峰」的全部秘密。用层层漏斗(CDN、限流、验证码、Redis 预减、MQ 异步)把洪峰稀释,每一层都大量丢弃请求,最终到数据库的写入只有几百 TPS。数据库用传统事务保证一致性,Redis 用原子操作保证预减的准确性。

Redis 预减和数据库库存的一致性

Redis 预减库存和数据库的真实库存之间,有一个短暂的不一致窗口。Redis 扣了但数据库还没落,这段时间如果 Redis 挂了,数据可能丢。生产实践里要处理这个,Redis 持久化加数据库兜底对账。大促级别的秒杀,还要做容灾预案,Redis 集群挂了怎么降级(比如临时切到数据库直接扣,牺牲性能保正确)。防超卖的可靠性,和限流的可用性,要有独立的容灾设计。

异步下单和最终一致

Redis 预减成功后,请求立刻返回「抢购成功,正在处理」,真正的订单创建走 MQ 异步。用户在前端等几秒看到订单生成。

PlantUML
正在加载图表…
PlantUML diagram

图:异步下单链路用可靠消息防丢失、幂等防重复、失败回补库存,保障最终一致

异步下单要处理几个一致性问题。MQ 消息不能丢,丢了就是用户扣了库存没拿到订单。要用可靠消息(RocketMQ 的事务消息或者本地消息表),保证扣库存和发消息是一个事务。消费端创建订单要幂等,防止消息重投导致重复下单。订单创建失败(比如用户被风控了)要把库存回补。

这一套异步链路是秒杀的履约层,它的可靠性决定了「抢到了但没拿到订单」这类客诉的多少。大促零资损的目标,不光靠防超卖,也靠这套异步链路的可靠兜底。

限流和降级,最后的防线

秒杀必须有限流和降级的预案。万一前面某一层没兜住,流量涌到下游,要有熔断保护核心服务。

库存服务挂了,降级成「暂停抢购」而不是让请求继续打。订单服务超时,熔断后返回「系统繁忙」而不是让用户一直转圈。这些降级开关要提前准备好,关键时候一键切换。

监控是底线。库存余量、Redis 命中率、MQ 堆积、订单创建成功率,这些指标要实时监控。任何一个异常都可能预示着超卖或者系统过载,要能第一时间发现和处理。大促的秒杀,值班和预案是标配,不是可选的。

秒杀系统的设计,精髓不在某一个技巧,在那个「层层削峰」的整体思路。每一层都做一件事,CDN 挡静态、网关限流、验证码错峰、Redis 预减防超卖、MQ 异步落库。每层都大量丢弃请求,最终到数据库的只是涓流。理解了这个漏斗模型,秒杀就不神秘了,它是一组成熟技术(缓存、限流、消息队列、原子操作)的组合运用,考验的是把它们串起来的工程能力。

Continue Reading

关联文档推荐

查看全部
实时在线观看人数统计 — editorial cover photo多级缓存:从浏览器到 DB 的全链路缓存设计 — vibrant cover for technical blog articleRedis 热点 Key 与大 Key 治理 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。