☰
秒杀系统实战:从限流到库存扣减的高并发方案
2026/10/13 1:15:54 网站建设 项目流程

要说电商后端哪个话题最能检验团队的综合功底,我第一个想到的就是秒杀系统。普通接口碰到高流量可以靠加机器、加缓存硬扛,但秒杀会把最极端的条件同时压到你面前:流量在几秒内冲上来,所有请求都盯着同一个商品,库存又是必须绝对准确的数据。能不能做好,不是看用了多高级的框架,而是看流量真正进来之前你想清楚了哪些问题。

这篇文章不展开讲花哨的概念,只说我自己做过、验证过、也翻过车的方案。适合正在做电商后端、或者刚接手高并发系统的朋友,特别是每次大促活动一上线就心惊胆战的团队。我会从业务场景拆起,讲接入层限流、库存扣减、消息队列解耦,最后落到压测和上线时最容易翻车的地方。

1. 一场秒杀到底发生了什么:先把问题看清楚

1.1 秒杀流量和普通高峰流量的本质区别

很多人第一次接触秒杀,下意识以为它就是"流量高一点的普通接口"。这个认知会直接导致后续所有设计跑偏。要知道普通高峰流量和秒杀流量最大的区别不是量级,而是分布。

普通的大促流量,比如某电商平台的全场满减活动,请求是分散在成千上万个商品上的。用户逛到哪个商品点了哪个商品,热点不集中,系统负载虽然高,但负载是均匀摊开的,常规的水平扩容、缓存、读写分离都能解决问题。

秒杀则完全相反。一个活动可能只做三五个SKU,甚至就一个爆款商品。所有用户的请求在同一秒涌进来,全部指向同一个ID。如果你在数据库里用一张库存表,那这张表的这一行就是整个系统最烫手的地方。任何一个环节的设计都得围绕"单点热点"来展开。

另外一个容易被忽视的差别是时间窗口的极端性。普通高峰流量是持续几个小时甚至几天的,系统负载曲线是缓慢爬坡再慢慢回落,扩缩容有时间余量。秒杀往往只有几分钟,从开始到结束可能就一分钟,流量呈断崖式上涨,根本没有留给你"看到监控告警再扩容"的时间。所以秒杀系统的所有准备必须前置,所有容量预估必须提前做。

1.2 一条秒杀请求要穿过哪些环节

从用户点下"立即抢购"到看到"恭喜您抢到了",这一条请求链路上涉及的东西远比想象中多。搞清楚这条链路,才能知道每个环节各自的瓶颈在哪里。

客户端上报抢购按钮开始,请求先经过接入层,包括DNS、CDN、API网关;然后到达业务层,一般会先查一次商品信息,再校验活动时间、用户资格;接着进入库存服务做扣减;扣减成功后再去创建订单、锁定优惠券;最后是异步通知用户支付。这中间还没算上登录态校验、风控、防刷等等前置逻辑。

每个环节都可能成为瓶颈。接入层的瓶颈在带宽和连接数;网关的瓶颈在转发能力和限流策略;商品查询环节的瓶颈在缓存命中率;库存扣减的瓶颈在数据库热点行;订单创建的瓶颈在事务提交和消息堆积。做秒杀系统,本质上就是在每一个环节都用"最省资源"的方式挡住无意义请求,最后只放极少量真正能成功下单的请求到最贵的数据库事务里。

我经常用一个漏斗来理解秒杀系统:100万用户点了按钮,经过去重、限流、风控之后可能只剩十万个请求走到库存扣减;十万个扣减里库存只有一千个,所以最后成功创建订单的只有一千人。系统设计得好不好,就看每一层漏斗往下漏的时候,有没有把该挡的请求挡在便宜的那一层。

2. 接入层的第一道防线:把无意义的请求挡在外面

2.1 详情页静态化与倒计时设计

很多人设计秒杀时把百分之百的精力都放在库存扣减上,结果活动开始的第一秒,商品详情页先被打挂了。用户还没看到"立即抢购"按钮,页面就白屏了。这是最基础但也最容易忽略的问题。

详情页静态化是标准做法。活动期间的详情页基本没有动态数据,商品图片、标题、描述这些内容直接渲染成静态页面扔到CDN上,用户读取的时候根本不经过你的后端。能做到这一点,详情页层面的流量再大也只是CDN的带宽问题,和你的应用服务器无关。

静态化解决了"看"的问题,但"点"的问题还需要处理。倒计时逻辑要注意,前端展示的倒计时必须从服务器时间计算,不能依赖客户端本地时间。否则用户把系统时间改快十分钟,倒计时提前结束,按钮提前点亮,就会出现一批"抢跑"的请求。我见过真实案例:活动本来还有一个小时才开始,有人通过改时间提前进入抢购页面,把按钮状态调成可点击,虽然后端校验活动时间能拦住真正的下单,但那一瞬间涌入大量无效请求,等于提前做了一次小规模攻击。

一个务实的做法是把按钮置灰的逻辑做两层:第一层在前端做,纯粹是用户体验层面的展示;第二层在接入层做,用一个活动状态接口返回服务器时间、活动是否开始、用户是否有资格。前端拿到这个结果再决定按钮亮不亮,这样即使用户改了本地时间,活动状态接口也会把他拦下来。

2.2 限流方案选型:单机限流与分布式限流

有了页面层的拦截,依然会有大量请求涌到后端。这时候就要靠限流。限流的本质不是把合法用户拦掉,而是让系统在承受范围内的请求正常处理,超出的请求快速失败,避免系统雪崩。

单机限流最简单,用Guava RateLimiter或者Semaphore就能做,每个服务实例独立计数。比如每台机器限制每秒接收1000个请求,20台机器就是2万QPS。优点是没有额外依赖,性能极好;缺点也很明显:如果负载不均衡,某些机器的请求量超过阈值,某些机器还没跑满,实际总吞吐会低于预期。而且单机限流没办法做全局维度控制,比如你想针对某个用户ID每秒最多放行1个请求,单机限流就做不到。

分布式限流需要统一计数,最常用的方案是Redis配合Lua脚本。同一个用户或者同一个活动的限流计数放在Redis里,所有机器共享。我贴一段我实际用过的计数限流脚本:

local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = tonumber(redis.call('get', key) or '0') if current + 1 > limit then return 0 else redis.call('incr', key) redis.call('expire', key, window) return 1 end

这段脚本的意思是对某个key(比如活动ID或者用户ID)在指定时间窗口内计数,超过阈值就直接拒绝。用Lua脚本是因为判断和自增必须是一个原子操作,不能先get再incr,否则并发下会出现计数不准确。我刚做秒杀系统时在这个地方吃过亏:先Java里get,再incr,结果高并发下同一个用户的请求同时读到同一个current值,两个都放行了,限流形同虚设。

选单机还是分布式,要看你的部署规模和精度要求。机器少、对全局限流要求不高的场景,单机限流完全够用;但如果一个活动入口背后有几十台机器,你希望所有机器合起来的流量不超过某个值,那就得上分布式限流。分布式限流的副作用是每次请求多了一次Redis往返,好在Redis本身很快,影响可以忽略。

2.3 验证码与答题:不是拦人,是提高机器攻击成本

秒杀活动的特点是确认性强、用户意愿极高,所以在秒杀里用验证码的核心目的不是阻止真人用户,而是提高脚本和机器人的成本。真人用户多花两秒钟输个验证码,对体验的影响是有限的,但这两秒钟能让同样的机器带宽和CPU成本翻好几倍,绝大部分脚本党就不跟你玩了。

实际项目中常用的是滑块验证或者点选验证。很多团队会忽略的是,验证码服务本身也必须高可用。我经历过一次活动,由于验证码服务QPS压满,导致大量正常用户卡在验证环节,后台看到限流拦截率飙升,但实际是验证码服务拖慢了整个入口。后来我们把验证码服务独立部署,活动前单独压测,这种问题才避免。

还有一个点是,验证码发放和校验一定要和活动入口解耦。验证码服务挂掉的时候,系统要能降级为"直接放行进入下一步限流",而不是让整个抢购入口不可用。降级逻辑要在上线前明确写清楚,否则故障时大家都在猜要不要放开验证码,场面会很混乱。

3. 库存扣减:秒杀系统最不能出错的地方

3.1 数据库直接扣减为什么扛不住

库存扣减是秒杀系统最核心的环节,也是"超卖"和"少卖"问题的发源地。最朴素的做法是数据库里一张库存表,扣减时执行一条语句:

update sku_stock set stock = stock - 1 where sku_id = ? and stock > 0;

stock > 0这个条件能保证不超卖,这也是数据库层面最简单可靠的方案。但问题是,秒杀场景下成千上万的请求同时对同一个sku_id的这一行执行update,数据库会对这一行加行锁,所有请求变成串行执行。数据库单机大概能扛每秒几千次到这个量级就不错了,一旦超过,数据库连接池被打满,整个服务都会雪崩。

另外一个隐藏问题是,即便你扛住了更新语句,这些请求还要和订单表、流水表一起放在一个事务里。事务意味着要写binlog、redo log,要等磁盘落盘,这些操作都会显著降低吞吐。所以数据库直扣方案不是不能用,但只能用在流量可控的场景,比如小规模抽奖、内测活动。

3.2 Redis预扣减和Lua原子脚本

业界最主流的做法是把库存预热到Redis里,用Redis的内存操作扛住高峰流量,再把结果异步回写到数据库。Redis单实例单线程的特性使得DECR这类操作天然原子,不会出现两个人同时扣到同一个库存的情况。

我实际项目里的扣减逻辑长这样:

-- KEYS[1]: 库存key -- KEYS[2]: 已抢用户集合key -- ARGV[1]: 扣减数量(秒杀通常为1) -- ARGV[2]: 用户ID local stock = tonumber(redis.call('get', KEYS[1]) or '0') if stock <= 0 then return -1 end local joined = redis.call('sismember', KEYS[2], ARGV[2]) if joined == 1 then return -2 end redis.call('decrby', KEYS[1], ARGV[1]) redis.call('sadd', KEYS[2], ARGV[2]) return 1

这段脚本把"查库存、查用户是否已抢、扣减、记录用户"这四件事放在一个原子操作里完成。有了这段脚本,并发下不会超卖,同一个用户也不会重复扣减。返回-1表示售罄,-2表示用户已经抢过,业务层根据返回码分别处理。

这里有个重要经验:Redis里扣了库存,数据库里必须能对得上。所以扣减成功后,要发一条消息给订单服务,异步去数据库创建真实订单并把库存状态落库。如果消息丢了或者异步任务失败,Redis里库存和数据库库存就会不一致。我后面会专门讲补偿机制,这里先记住一个原则:Redis做的是流量承担层,数据库做的才是最终数据层,两者之间的同步必须可追踪、可对账。

3.3 三种库存方案怎么选:直接对比

我整理过一张表格,方便对应场景选型:

方案准确度吞吐能力复杂度适用场景
数据库update ... stock > 0绝对准确低,受限于行锁最低小规模活动、内部测试
Redis原子扣减 + 异步回写依赖补偿机制高,可支撑数万QPS中绝大多数正经秒杀活动
资格码/抽签token较准确极高,库存根本不直接暴露高稀缺商品、限量抢购、强管控场景

资格码方案比较有意思。先通过抽签或者排队把"抢购资格"发放出去,用户拿到资格码后在规定时间内凭码购买。这个方案从源头把并发削掉了,库存不再是瞬时争抢的对象。缺点是需要额外管理资格码的生命周期,还要防囤码、防黄牛,而且用户体感上少了"抢"的刺激感。很多平台做限量球鞋、限量手办抽签就是这类思路。

3.4 关于分布式锁的一个常见误解

我发现很多人一聊到秒杀就说"要用分布式锁",这个理解需要纠正一下。Redis的DECR本身就是原子操作,它天然解决了超卖问题,这时候再去加一把分布式锁反而是画蛇添足——多了一次获取锁的网络开销,锁服务如果抖动还会引发大量请求阻塞。

分布式锁解决的根本不是"原子性"问题,而是"多个节点互斥执行某段代码"的问题。比如你想保证同一时刻只有一个定时任务在回补库存,这时候用分布式锁是合理的;或者某个复杂操作需要"读数据-判断-写数据"三步,而你没有能力把三步压成一个Redis原子操作的时候,才需要锁。秒杀里的大部分场景,Lua脚本已经能解决原子性问题,不需要上锁。

这个认知很重要,因为锁方案用错了地方,不仅性能受损,还容易在锁过期那一下出现并发问题。锁过期后两个请求同时进来执行了同一段"查库存-扣库存"逻辑,超卖问题又会回来。

4. 扣减成功之后:订单队列、幂等与会话保持

4.1 同步等待和异步排队怎么选

库存扣减成功只是一个起点,后面还要创建订单、锁定优惠券、返回结果给用户。这里面有个设计岔路口:用户是等在那里直到订单创建完成,还是先返回"排队中"?

同步模式的用户体感最好,点击之后马上知道结果。但代价是库存扣减接口的耗时会被后续动作拖长。假设创建订单要100毫秒,在2万QPS的流量下,这100毫秒意味着系统中同时有2000个请求在途,对线程池、数据库连接池的压力是很大的。

异步模式是另一条路:扣减成功之后,接口立刻返回"恭喜您,正在为您锁定订单,请稍后刷新查看",同时把订单创建任务丢进消息队列,由消费端异步处理。好处是接口耗时降到了最低,系统吞吐量大幅提升,瞬间峰值被消息队列削平。坏处是用户不能马上看到订单,如果消费者处理不及时,用户会焦虑地反复刷新。

我现在的建议是:对于秒杀这种强场景,可以接受异步,但必须给用户一个明确的"已排队"状态入口,并且绝不能让用户误以为没抢到。我们是在"已抢到但订单生成中"这种状态卡过用户的,那次体验很糟糕,用户反复刷新看不到订单,客服被问爆。后来在产品状态设计上加了一个"锁定中"的中间态,配合推送通知,问题才缓解。

4.2 消息可靠性与消费幂等

把订单创建任务丢到消息队列之后,要考虑三件事:消息不丢、不重复、不乱序。不丢主要靠MQ的确认机制:生产者发送消息后等待broker确认,没确认就重发;消费者处理完业务逻辑后再确认消费,没确认就重新投递。

但"重新投递"和"重试"就会带来重复消息。比如消费者拿到了消息,创建订单成功,但在提交消费确认时网络闪断,消息被重新投递给另一个消费者,同一个订单就被创建了两次。解决重复消费的标准手段是幂等,不能依赖"MQ正好不投递重复消息",必须业务层自己处理。

最简单可靠的做法是给整个秒杀流程定义一个全局唯一的请求流水号,比如userId + skuId + 活动id加时间的组合,在扣减库存的时候就已经生成,并且写到了Redis的已抢用户集合里。消费者创建订单前先根据流水号查订单表,查到了就直接返回,不再新建。这个幂等逻辑一定要写在"创建订单"动作的前面,而且校验和创建订单要放在同一个数据库事务里,否则还是会有并发重复创建的风险。

4.3 消息堆积导致的雪崩问题

消息队列削峰有个副作用:如果消费者的处理能力跟不上生产速度,消息会越积越多,积压到一定程度,磁盘被占满,MQ本身就开始拒绝写入,反而把压力反传回上游。所以消费者侧一定要有扩容预案,并且最好预先给秒杀专用的一个消费组分配足够的冗余。

我的实际做法是,秒杀相关的消息单独用一套Topic和消费组,不跟普通订单消息混在一起。这样即使秒杀消息海量积压,也只影响秒杀链路,不会污染正常的日常订单。活动结束后,宁可多等几分钟让积压的消息慢慢消化,也不要因为急于清空积压而去并发重放,那样会把下游数据库压垮。

我见过一个团队在秒杀活动结束之后,为了尽快消化积压的订单消息,把消费者的并发线程调到原来的十倍,结果数据库连接池瞬间被占满,反而导致整个订单系统不可用。削峰填谷的核心思想就是"匀速处理",峰值在队列里被削平,消费端必须保持稳定节奏,而不是一起冲。

5. 超时未支付、库存回补与对账:容易忽略的尾巴

5.1 订单超时的处理路径

秒杀的订单通常会设置支付时限,比如十五分钟或半小时。超时未支付的订单必须取消,并且要把库存还回去,让给下一个人。这个过程有两个容易犯错的地方:一个是回补库存的时机,另一个是回补的幂等。

回补时机要特别小心。用户可能已经点了支付按钮,只是在支付回调返回的路上,这时候如果你贸然回补库存,就会出现一个尴尬场景:用户支付成功了,系统却认为他没支付,把库存释放给了别人,最终用户付了钱却拿不到货。这是一个需要把"支付回调状态"考虑进去的状态机问题。

我的状态机设计大致是这样:订单创建时状态为"待支付";用户确认支付或者支付回调成功之后,状态变为"已支付";定时任务扫描那些超过时限仍处于"待支付"状态的订单,先确认支付中心那边没有已支付记录,再执行关单和库存回补。绝对不能只查本地状态就去回补库存,一定要以支付中心的最终状态为准。

5.2 库存回补的幂等与防重复

库存回补这个动作本身必须幂等。也就是说,同一张订单超时了,被定时任务扫到两次,只能回补一次库存。一次回补要记录一条流水,两次回补就是超卖。

做法是在回补前先检查这张订单是否已经回补过,检查的依据最好是唯一的——比如订单号。我倾向于在数据库里建一张"库存回补流水表",订单号设为唯一索引,回补时先insert流水,insert成功才更新库存。利用唯一索引的约束,天然保证了第二个任务重复执行时insert会失败,也就不会二次回补。

另外一个细节是,回补的库存要回到Redis的库存key里,还是直接更新数据库?严格来说两个都要。Redis里库存是给下一次抢购用的,数据库里库存是最终对账数据。所以回补操作要先更新数据库,再同步更新Redis里的计数。顺序不能反,如果先回了Redis再操作数据库,万一数据库失败,Redis库存虚高,等于超卖。

5.3 活动结束后的对账任务

活动结束之后,Redis里的库存和数据库里的库存一定存在差异。差异的来源很多:Redis扣减了但异步消息没消费、用户取消了订单但回补动作失败、支付超时但定时任务没及时扫到。所以对账不能靠"碰运气",要有一套自动对账机制。

我建议在活动结束后,跑一个离线对账任务,把所有参与了活动但状态异常的数据找出来。对账逻辑按两类做:一类是Redis已扣减但数据库没有对应订单的,说明消息丢了,需要补建订单或者回补库存;一类是数据库有订单但Redis没有对应扣减记录的,属于数据不同步,需要人工介入修正。

对账任务最好错峰执行,避开业务高峰期,而且要加上进度汇报和失败重跑机制。对账这件事看起来不起眼,却是保证秒杀不出资金事故的兜底机制。没有对账系统,前面任何一环的小概率故障都会积累成账实不符的大问题。

6. 压测与上线:真刀真枪的注意事项

6.1 压测最容易踩的三个坑

秒杀系统上线前一定要压测,但压测本身也容易翻车。我踩过的最大的坑有三个。

第一个是只关心QPS不关心热点。用均匀分布的压测请求压出来系统能支撑5万QPS,但秒杀场景下所有请求打在同一批Redis key上、同一行数据库记录上,性能表现完全不一样。所以压测必须模拟真实场景,构造一个热点key,观察热点链路的吞吐和延迟。

第二个坑是连接池耗尽而不是CPU打满。有一种常见故障现象:系统CPU消耗只有百分之三四十,数据库连接池和线程池却已经耗尽,所有请求都在排队等待获取连接。这时候的根因往往是某个慢查询或者某个外部依赖阻塞时间太长,把连接都占住不释放。压测时必须盯着"连接池活跃数"和"线程池排队量"看,而不只是盯着平均延迟和吞吐量。

第三个坑是日志把磁盘打满。高并发下如果每个请求都打印info日志,磁盘写入量会高速增长,最终磁盘满导致服务挂掉。我自己经历过一次,压测到一半发现响应时间突然飙到几十秒,查了半天才发现是磁盘满了。秒杀系统的日志必须降噪,成功日志打到debug级别,只有异常才打warn以上,还要配合日志切割和清理策略。

6.2 开关与降级:上线前必须想好的后路

秒杀系统的上线必须要有"一键降级"的能力。你无法保证压测环境模拟出了线上所有的真实情况,所以必须假设活动过程中会出故障,并且提前定义好故障时怎么办。

至少要准备三个级别的开关。一二级是入口开关:活动入口可以瞬间关闭,让用户看到"活动已结束",这时候所有请求不再进入业务逻辑;第二级是库存扣减降级,如果Redis异常,可以降级为数据库直扣,哪怕吞吐低,至少不超卖;第三级是异步链路降级,如果MQ积压严重,可以先不做异步订单补偿,改为直接把用户信息记录下来,活动结束后人工补单。

这些开关要通过配置中心下发,不能靠改代码发版。开关本身要提前演练,确保在活动当天真的能一键生效。我见过配置中心的开关下发有秒级延迟,结果故障发生时,运营在后台点了三次都没生效,白白多扛了两分钟的峰值流量。

6.3 一次失败活动的复盘

最后分享一次真实的失败经历。那是我做的第二轮秒杀改造,我把库存扣减、消息队列、异步订单全都做完了,压测数据也好看,当时志得意满。结果活动上线第一分钟,监控就报警:订单服务响应时间从30毫秒飙升到5秒,随后整个服务开始大量报错。

事后复盘,原因让我觉得很丢脸——我漏掉了接入层限流。活动流量冲进来之后,除了库存扣减之外,所有请求都会去订单服务查询用户的收货地址、默认优惠券、历史订单状态等等,这些查询正常情况下很轻,但流量放大到几十倍之后,数据库连接池被打满,进而把正常购物用户的请求也拖住了。整个秒杀流程倒是没有超卖,但连带影响了全站,这是比秒杀失败更严重的后果。

那次之后我定了一条铁律:任何秒杀活动上线,接入层限流是第一优先级,宁可误杀一部分用户,也要保证系统整体稳定。我们可以通过优化让误杀率降低,但绝对不能以牺牲全站稳定性为代价。这条铁律在后面几轮活动中保护了我们很多次。

做秒杀系统做到现在,我的体会是它其实没有太多玄学,无非是把每一层流量都管控好:页面层挡掉一部分,限流层挡掉一部分,风控挡掉一部分,最后到数据库的真实写请求已经很少了。难的是这些环节要能在同一时间稳定协作,任何一个环节掉链子都会引发连锁反应。

如果你正准备给团队搭一套秒杀系统,我建议先别急着上各种中间件,先把接入层限流和Redis库存扣减这两件最基础的事情做扎实,再逐步叠加消息队列和异步链路。每加一层,都要想清楚这层如果挂了,系统怎么降级。这个思路,比任何架构图都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询