秒杀系统设计:高并发下的库存扣减与流量控制实战
发布时间:2026/10/13 21:29:02 锦皓数字建站

“秒杀系统怎么设计”这道题我面试过很多候选人也在真实项目里被线上流量教育过好几次。很多人一听到秒杀反应是背一套“Redis 消息队列 限流”的排比句听起来头头是道但面试官只要追问一句“如果Redis在秒杀瞬间挂了你怎么办”场面就安静了。这篇文章我想从一个实际参与过秒杀类项目的人的角度把这道高频题拆开揉碎面试官到底在考什么、一条正确的链路应该怎么推演、哪些方案只是“听起来正确”以及真正落地上要和面试答案拉开多少差距。1. 面试官真正想听什么——别急着报Redis和MQ1.1 三种“一看就会、一问就废”的答法我先说三种我听过最多次、也最容易被追问卡住的答法。第一种用Redis扛流量。候选人会说“Redis单实例能到十万级QPS秒杀请求全打到Redis不行就上集群”。这话本身没错但问题是Redis再快也不是无限流量秒杀开启的瞬间可能出现几十万甚至上百万的QPS如果不做入口侧的收敛打过来的请求量会先压垮网关、负载均衡甚至打到组件之前的网络层。第二种用MQ削峰。候选人说“把秒杀请求全丢到消息队列里消费者慢慢处理”。但MQ解决的是“瞬时压力平摊”它不解决“库存扣减”的状态一致性问题。你把请求丢进队列以前库里就100件商品如果不用原子扣减队列里1000个消息全部消费成功照样超卖。第三种提前做缓存和读写分离。候选人说“把商品信息放Redis数据库主从分离减轻压力”。但这是所有读多写少系统的基本操作不是秒杀的核心。秒杀的核心难点集中在“有限库存的原子扣减”和“峰值流量的控制与兜底”上只谈缓存和读写分离等于根本没碰到考点。这三种答法的共同问题是把“选型名词”当成了“系统设计”但面试官想听的是你在每个环节的决策逻辑和边界意识。1.2 秒杀题里藏着的四个验收维度在我看来秒杀系统这道面试题本质上是在考察一个工程师能不能在极端流量下守住四个底线。不超卖。库存是有限资源100件就是100件系统在任意并发情况下都不能卖出第101件。这是数据正确性底线。不拖垮核心链路。秒杀只是整个交易系统里一个子场景不能让它的流量把日常的搜索、下单、支付全部打挂。这是可用性底线。可恢复、可对账。高并发场景下Redis可能丢数据、MQ可能重复消费、数据库可能出现延迟系统必须有能力发现不一致并且能通过补偿任务恢复。这是工程兜底底线。用户反馈可接受。用户抢不到可以但不能让用户无限转圈、卡片死掉、没有任何反馈。这是体验底线。很多人答这道题时只盯着“性能”把并发数字说得越高越好但面试官真正在脑子里打分的是这个人有没有考虑过系统挂了之后怎么办数据错了之后怎么办。你一旦主动说出这些就和只会背名词的候选人拉开了差距。1.3 你要讲的是决策不是名词所以我建议的答题基调是不讲“我用了什么”而讲“我为什么在这里用这个、不用那个”。比如“这里为什么用Redis扣库存因为数据库单行更新的并发能力撑不住秒杀这种集中写热点而Redis的Lua脚本能把‘检查库存’和‘扣减库存’做成原子操作”。这句话里同时包含了性能、一致性、技术选型三个信息量。回答这道题时把“从浏览器到数据库”这条链路上每个节点的角色讲清楚比报出十个中间件名字更重要。接下来我就按照这个思路把链路里的关键节点逐个拆开。2. 流量门卫限流和削峰才是秒杀的灵魂2.1 先算一笔账秒杀的流量到底有多“凶”很多人对“秒杀流量”没有具体概念。我拿常见活动举个例子假设一个平台备货1万件提前预约人数有100万。活动开启的瞬间哪怕只有10%的预约用户同时点击也就是10万人在几秒内发出请求平均算下来QPS也在几万到十万级别。这还只是入口请求如果购物车详情、库存查询、下单接口全都在里面流量会更大。数据库在普通配置下单库几千万把数据量的写能力通常只有每秒几千读可能到几万。Redis单实例的读能力虽然能到十万级但注意一点库存扣减是“单key写热点”所有请求都集中写同一个商品的库存keyRedis单线程处理模型会让这个key的写操作被串行化CPU开销一点也不小。算这笔账的用意是你要让面试官看到你心里有系统的容量水位。你不可能让10万QPS直接打到数据库也不可能让10万QPS全打到同一个Redis key上所以在链路入口必须有“流量门卫”把人挡在外面把量削平。2.2 限流算法选型令牌桶、漏桶还是计数器限流的实现方式很多面试里至少要能说出三种算法的区别以及适用场景。计数器固定窗口最简单比如“1秒内最多放行1000个请求”用一个时间段统计。但它有临界问题假设限制了每秒1000次用户在第1秒的最后100毫秒发起1000次第2秒的前100毫秒又发起1000次这200毫秒内实际上放过了2000个请求违背了限流的初衷。滑动窗口把时间切成更小的粒度能缓解临界问题代码复杂度也不高适合做用户维度的频控比如“同一个用户1分钟内最多点5次抢购按钮”。令牌桶允许一定的突发流量。桶里可以预存若干令牌请求来了先取令牌取到就放行没取到就拒绝或等待。它适合入口层——既能挡住整体流量又能容忍秒杀开始时那一瞬间的集中点击。漏桶是恒定速率输出。请求进来后先进入桶里处理方按固定速率拿走处理流量再大出口也是平滑的。它特别适合保护下游能力固定的系统比如“每秒最多往订单库写入500条”这时候漏桶思路就很合理。我的经验是入口层用令牌桶控总量下游写数据库的链路用MQ或者漏桶思维限速率两种算法是组合不是二选一。2.3 把瞬间流量摊平提前报名、排队与答题限流虽然有效但它本质上是在拒绝用户。一味的拒绝会带来极差的体验所以很多秒杀系统会在限流的前面再加一层“排队”设计。我参与过的案例里提前预约、资格校验、虚拟排队是三件套。秒杀前开放报名或预约用户在活动开始前就提交了基本信息和抢购资格这等于把一部分流量分摊到了活动开始之前。秒杀真正开启时用户点按钮不再是直接抢库存而是先进入一个虚拟队列系统发给他一个排队序号弹窗显示“前方还有多少人”等排到了可以自动创建订单也可以提示用户点击确认购买。这样入口的瞬时TPS会大幅降低因为系统只需要处理队列分配而不是直接处理库存扣减。答题、拼图、滑动验证这类操作一方面可以拦截一部分脚本刷单者但另一方面也会给后端引入新的验证码服务压力。不要为了反脚本把所有请求都引到验证码服务上容量也要提前评估。2.4 容易被忽略的“软限流”按钮置灰与多级放行除了算法层面的硬限流还有一个经常被候选人忽略的部分端上策略。秒杀的详情页、商品图、倒计时、文案全部应该静态化到CDN用户从打开页面到点击按钮之间的所有流量都不要打到业务后端。活动开始前前端按钮置灰到点才变成可点击也能避免一批提前狂刷的请求。真正打到后端动态服务的应该是经过层层校验后的“抢购请求”。这个“多级放行”的思路我建议在面试里主动说出来第一级CDN挡静态流量第二级网关限流挡无效请求第三级资格校验挡没有预约过的用户第四级才是库存扣减。每多挡一级后端承受的压力就少一个数量级这个意识比任何中间件都值钱。3. 库存扣减多数超卖事故都出在“检查再更新”3.1 超卖的真实成因check-then-act聊完流量控制接下来就是秒杀系统最核心的阵地库存扣减。很多超卖事故不是系统宕机导致的而是代码里写了一个经典的“先检查再更新”逻辑。用一段类似SQL的伪代码来表示就是stock select stock from goods where id 1 if stock 0: update goods set stock stock - 1 where id 1两个并发请求同时执行select都读到库存等于5都判断大于0然后都执行update两次update都成功但库存从5变成了4。这就是超卖的本质检查和更新不是一个原子操作。解决思路也简单原则是“把检查到更新压缩成一条不可分割的原子指令”。这个原理看着不难但真正难的是在不同存储上把它实现好并且权衡好性能和一致性的关系。3.2 三种扣减方案对比性能与一致性数据库层面的方案我列过三种特点差异很大。数据库悲观锁也就是对商品行执行select for update然后再update。它确实能保证不超卖因为事务在更新时会锁住这一行其他事务必须等待。问题也明显所有抢购请求都排队在锁上数据库行锁的排队时间是灾难性的秒杀并发一起来数据库很快被打满响应时间飙升。数据库乐观锁或者说条件更新写法是update goods set stock stock - 1 where id 1 and stock 0受影响行数等于1说明扣减成功等于0说明库存不足。这段SQL并发执行时数据库自身会保证同一行更新的串行化所以不超卖。它的成本是每一次扣减都要真实更新数据库一次数据库写盘能力就是整个系统的天花板。秒杀场景几万QPS的写集中到一张表的一行上绝大多数数据库扛不住。Redis原子扣减也就是把库存计数放在Redis里用Lua脚本做原子操作。Redis单机承载能力远高于数据库单行写所以这是目前秒杀场景最主流的思路。它的代价是Redis和数据库之间的库存数据存在短暂不一致必须靠异步同步和补偿来兜底。三种方案我用一个对比总结方案一致性性能适用场景主要风险数据库悲观锁强一致差低并发、对一致性要求极高行锁排队数据库容易扛不住数据库条件更新强一致中可以接受数据库写瓶颈库存量不大热点行写锁吞吐有限Redis原子扣减最终一致高秒杀、抢购等极端集中写场景需要异步同步和补偿机制如果库存数据量不大数据库条件更新其实是个很可靠的兜底方案。我在面试中回答这个问题时会强调一句秒杀的主链路用Redis扛流量但数据库条件更新可以作为Redis故障时的降级方案虽然慢但是不会错。3.3 RedisLua原子扣减的正确姿势Redis做库存扣减网上很多方案写的是先用get查库存判断大于0再decr或者直接用decr命令判断返回值。前者在两个命令之间会被其他请求插队本质上还是check-then-act后者虽然单条命令是原子的但扣成负数才发现不对再回补时已经产生了多次超卖回补操作还会叠加并发问题。正确做法是把检查和扣减写进一段Lua脚本Redis执行Lua脚本时不会被打断保证原子性。核心脚本类似这样-- KEYS[1]库存key -- ARGV[1]本次扣减数量 local stock tonumber(redis.call(GET, KEYS[1])) if not stock then return -2 -- 库存key不存在 end if stock tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 0 -- 扣减成功业务侧根据返回值判断0放行-1返回“已抢光”-2说明活动数据没有初始化。这个方案既是原子操作也不会出现库存扣成负数的情况。落地上有几个细节我要额外提醒。库存key在活动期间不要轻易设置过期时间万一到期被Redis淘汰剩下的库存数据就全丢了如果非要设置过期时间也应该设置成活动结束后自动清理不能在活动进行中“滚动过期”。另外Lua脚本里不要放耗时操作比如遍历大集合、调用随机命令Redis是单线程一个慢脚本会堵住整个实例上的其他请求。3.4 扣完之后的账怎么算异步落库与对账补偿Redis扣减成功不代表整个订单链路结束。扣完库存之后系统还要创建订单、通知支付、同步数据库。但数据库的写能力有限所以“扣库存”和“落订单明细”之间通常走异步。我的建议是设计成两条线Redis负责秒杀瞬间的计数控制数据库负责最终订单和库存流水。Redis每扣减一笔同时发送一条消息给下游消费者消费者把订单插入订单库再把库存快照批量同步到数据库库存表。两边数据在秒杀后的几秒到几十秒内存在差异这是允许的。但“允许短暂不一致”不代表“可以永远不一致”。必须有一个对账任务定时扫描Redis流水、订单表和商品库存表把不一致的数据找出来告警并补偿。比如Redis显示扣了100件数据库订单只有98条那就可能是丢了2个订单需要根据流水补建反过来数据库订单有101条Redis只扣了100件就需要检查是不是库存扣减接口被绕过或者消息重复消费了。4. 订单异步化解耦让订单库别在高峰期裸奔4.1 为什么不能同步建订单秒杀请求如果走到“校验资格、扣库存、创建订单、扣减库存、更新用户账户”这一整条同步链路上任何一个下游慢一点整个请求都会阻塞。更致命的是如果1万人同时抢到了资格1万次同步建订单同时打在订单库上订单库大概率直接崩溃。即使数据库能扛住用户端的响应时间也会变得不可接受。所以秒杀链路里有一个重要原则同步接口只做最轻量级的必要操作把长耗时的操作全部挪到异步。一个典型的流程是前端点击抢购后端在校验资格后先做Redis原子扣库存扣减成功立刻返回用户“抢购成功”同时发送一条消息到MQ然后让用户跳转到等待页或者由前端轮询订单状态。订单的真正创建发生在MQ消费者里它会去查用户是否已经下过单、再插入订单记录、处理后续的物流和支付回调。用户看到的等待时间可能只是一次轮询的延迟但系统承受的压力分布完全变了。4.2 MQ入场的正确姿势与三大副作用消息队列在这里承担了两个作用削峰和解耦。削峰体现在消费者可以按照自己的消费能力去拉消息不会因为瞬间大量订单被数据库打死解耦体现在库存服务和订单服务之间通过消息交互库存服务不需要关心订单系统内部怎么做。但如果面试只答到“用MQ削峰”还是不够。MQ本身会引入三个副作用必须能说出处理方案。消息丢失生产者发消息时可能因为网络异常或者消费者宕机导致消息没有被消费。解决方案是生产者开启消息确认机制消费者完成任务后手动提交ack。更稳的方案是做本地消息表先在同一数据库事务里写入业务数据和一条待发送消息再通过定时任务把消息投递到MQ投递成功才更新消息状态这样即使MQ故障本地消息还在可以补偿发送。消息重复消费者处理完订单后还没来得及提交ack进程挂掉消息会被重新投递同一个订单就可能被消费两次。解决方案是幂等设计比如订单表里对订单号建唯一索引插入时冲突就跳过或者用消费处理表记录消费过的消息ID。消息乱序同一个订单的多个消息如果被并发消费顺序错乱会导致状态异常。解决方案是按订单ID做哈希把同一个订单的消息都路由到同一个队列消费者顺序处理。这三点我在面试里一定会主动讲出来。因为“用MQ”谁都会说能说出MQ带来的问题和自己如何兜底的人才是真正在系统里把MQ用明白的人。4.3 超时未支付延迟关单怎么做秒杀和普通购买很大的不同是用户抢到库存后不一定马上支付甚至不支付。如果不处理这些未支付订单库存被占住后面真正想买的用户就买不到。所以系统必须有一个关单机制通常设定15到30分钟超时未支付订单自动取消同时释放已扣减的库存。实现上常用的有延迟消息和定时扫描两套方案。延迟消息的意思是创建订单成功后发送一条延迟15分钟的检查消息到时间后由消费者检查订单状态如果仍未支付就执行关单。定时任务则是每隔几分钟扫描一次超时未支付订单批量处理。两套方案可以配合延迟消息保证时效性定时扫表作为兜底避免延迟消息丢失导致库存永久占用。这个环节容易被忽略但非常重要。我在面试回答里提这一笔等于告诉面试官我不仅知道怎么把库存扣出去还知道怎么把库存收回来。4.4 隔离与兜底独立集群、独立域名和容量预案秒杀流量还有一个特性它会拖垮无辜接口。一个电商站点平时每秒几千QPS秒杀瞬间变成几十万QPS如果整个业务共用同一套应用集群和数据库秒杀请求会占满连接池和线程池导致普通的下单、查询也被拖死。所以线上秒杀系统通常是一个独立部署的子系统有独立的域名或网关路由有独立的数据库或独立的表空间。这样做的好处是秒杀流量再大最多影响秒杀系统自身。资源池隔离之后还要提前做容量预案秒杀开始前要压测确认网关、Redis、MQ、数据库每个环节的极限水位秒杀中要盯监控面板关注QPS、库存水位、MQ积压数和订单成功率一旦发现异常要有能力执行降级开关比如把秒杀入口直接熔断或者把库存扣减从Redis降级到数据库条件更新。“如果Redis挂了怎么办”这个高频追问我通常的回答是秒杀入口配置了降级策略要么直接把秒杀功能暂时关闭要么降级到数据库条件更新来保证不超卖。秒杀作为一个活动可以接受用户抢不到但不能接受因为秒杀把整站拖挂。这个“系统性取舍”的表述是秒杀题里最加分的回答之一。5. 五分钟答题框架与高频追问拆招5.1 一套可以直接用在面试里的叙述主线技术细节讲完我给一套可以直接组织语言的答题框架每句话都要带上“为什么”不是背流程而是讲决策。开头先点目标秒杀系统的设计目标是在极端峰值流量下做到不超卖、不拖垮核心链路、数据最终一致、用户有反馈。然后从前端到后端推演。前端秒杀页静态化放CDN按钮到点置灰用户端的静态流量不进后端。请求打到网关后网关做总量限流和用户维度频控挡住大多数无效流量。接着校验用户预约资格过滤掉没有权限的请求。再进入库存服务用Redis加Lua脚本做原子扣减扣减失败返回“已抢光”扣减成功发送消息到MQ。MQ消费者异步创建订单用订单号做幂等。最后设置延迟关单超时未支付自动释放库存同时每天跑对账任务修正Redis和数据库之间的一致性问题。讲完这套流程后主动补一句兜底设计Redis故障时降级到数据库条件更新MQ重复消费靠唯一键幂等数据不一致靠对账补偿。这句话一说出来整个答案就从“正确但普通”变成“工程可落地”。5.2 高频追问挂掉怎么办、重复怎么办、没付钱怎么办面试官在这个题上最喜欢做的就是故障注入。我在下面列了最常见的几个追问和推荐的回答要点。追问回答要点Redis挂了怎么办秒杀熔断关闭入口或降级到数据库条件更新宁可买不到不能超卖MQ消息丢失怎么办生产者确认加本地消息表消费者手动ack定时任务兜底补偿MQ重复消费怎么办订单号唯一索引加幂等校验重复消息直接丢弃库存扣了但用户没付钱延迟队列到期关单库存回补数据库对账任务确认怎么防脚本刷单预约资格制、答题验证码、设备指纹、用户维度限流Redis和数据库库存不一致异步落库加每日对账异常数据告警人工处理这些追问没有标准答案面试官想看到的是你遇到故障时的第一反应。不要只说“加监控”要能说出具体怎么做谁触发补偿、补偿动作是什么、失败后怎么告警。能答到这个颗粒度基本就过关了。5.3 少说少错这些词在秒杀题里尽量别主动提最后说一个面试经验有些技术词汇在这个题目里属于“雷区”不提反而更好。分布式事务、两阶段提交这类强一致方案在秒杀这种高并发场景里是不现实的。因为两阶段提交会锁住所有参与方把数据库和消息队列拉进同一个长事务里响应时间会变得不可接受。如果你主动提这两个词面试官多半会认为你只有理论概念没有真实落地经验。“Redis集群多少台”“Kafka吞吐量多高”这种数字也不要一上来就报。没有业务背景的吞吐量没有任何意义面试官更关心你在什么约束下做了哪些取舍。把思路先讲清楚数字作为佐证出现就可以了。6. 写在最后线上秒杀和面试答案之间隔着什么面试题里的秒杀系统逻辑链可以讲得很顺但真实线上和这套标准答案之间还隔着很多只有踩过坑才知道的细节。我见过不少团队把Redis扣库存做上线了却忽略了库存key的容量评估结果秒杀开始后Redis内存被打满GET和EVAL都变慢连带着其他业务一起遭殃。这个问题的根源不是Redis不够快而是没有提前规划库存数据以外的缓存容量也没有对Redis单实例做保护。面试里你可以不谈这个但如果你能主动说一句“我会评估Redis内存和CPU给秒杀key预留资源”这会是加分项。还有一个常被忽视的设计是消费者线程池。MQ削峰听起来挺美但如果消费者拉消息的能力太猛瞬间从MQ里拉出几千条订单创建消息数据库一样被打死。所以线上往往会在消费者和数据库之间再加一层本地队列或者限制并发消费数把写库的速率控制在数据库能承受的范围内。这个“速率控制”的思想和限流算法其实是同源的。监控是另一个重要话题。秒杀前做全链路压测秒杀中盯库存水位和MQ积压量秒杀后看对账差异。没有压测过就上线秒杀等于把系统裸奔送到流量高峰面前出了问题再排查代价往往很大。如果你想把这个面试题准备扎实我的建议是不要背答案找一张白纸从浏览器开始一路画到数据库每个节点都问自己两个问题这里挂了怎么办这里慢了怎么办能把这两个问题回答完整你的秒杀方案就真正变成你自己的东西了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。