“秒杀系统怎么设计”这道题,我面试过很多候选人,也在真实项目里被线上流量教育过好几次。很多人一听到秒杀,反应是背一套“Redis + 消息队列 + 限流”的排比句,听起来头头是道,但面试官只要追问一句“如果Redis在秒杀瞬间挂了,你怎么办”,场面就安静了。这篇文章我想从一个实际参与过秒杀类项目的人的角度,把这道高频题拆开揉碎:面试官到底在考什么、一条正确的链路应该怎么推演、哪些方案只是“听起来正确”,以及真正落地上要和面试答案拉开多少差距。
1. 面试官真正想听什么——别急着报Redis和MQ
1.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写热点”,所有请求都集中写同一个商品的库存key,Redis单线程处理模型会让这个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 Redis+Lua原子扣减的正确姿势
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积压量,秒杀后看对账差异。没有压测过就上线秒杀,等于把系统裸奔送到流量高峰面前,出了问题再排查,代价往往很大。
如果你想把这个面试题准备扎实,我的建议是不要背答案,找一张白纸,从浏览器开始一路画到数据库,每个节点都问自己两个问题:这里挂了怎么办?这里慢了怎么办?能把这两个问题回答完整,你的秒杀方案就真正变成你自己的东西了。