秒杀系统设计这道题,在系统设计面试里的出现频率高得离谱,但同时也是翻车重灾区。我带过的候选人也好,身边的朋友去面大厂也好,十个里头能有七八个在这道题上答得稀碎。最典型的反应就是:面试官刚问完,立刻条件反射式地吐出“Redis 抗量 + MQ 削峰 + 限流”三板斧,再追问一句“库存怎么扣?扣完了订单怎么落?超卖怎么防?”,就开始支支吾吾。说白了,大部分人把这道题当成了背诵中间件名词的填空题,但面试官真正想考察的,是你在极端流量场景下做架构取舍、保护数据一致性、设计降级预案的完整能力。
这篇文章就围绕这道题,从面试官视角出发,把秒杀系统的核心难点、库存扣减的正确姿势、一条可落地链路的设计过程,以及我在实战中踩过的一些坑,完整拆开来讲。适合准备后端/架构方向面试的工程师,也适合真正要设计秒杀业务的同学参考。
1. 面试官到底在考什么:先把题读懂
1.1 这道题不是背诵题,是取舍题
很多候选人把秒杀系统当八股文背,一上来就铺开一整套微服务架构、注册中心、配置中心、全链路监控、分布式链路追踪……结果面试官问到的第一个问题就卡住了:“你觉得这个系统大概是什么量级?QPS 多少?库存多少?参加人数多少?”
为什么这个问题重要?因为秒杀架构里的每一个选择,都和量级强相关。你做一个 1000 人抢 500 件商品的小活动,和做一个 100 万人抢 1 万件商品的大促,设计完全是两套方案。前者单机 MySQL 乐观锁就能扛住,后者才需要上 Redis、MQ、分库分表、风控那一套。一上来不澄清需求就直接堆方案,等于告诉面试官你只会背模板,没有真正设计过系统。
我在面试里最常听到的答法就是:Redis 预减库存、MQ 异步下单、数据库最终扣减。这套方向本身没错,但几乎所有人都会漏掉三个关键点:第一,Redis 扣减失败了怎么补偿?第二,MQ 积压了用户迟迟拿不到订单结果怎么办?第三,同一个用户短时间反复点击、用脚本刷接口怎么防?面试官真正想听的,是你有没有想过这些边界问题,而不是听你报菜名。
1.2 需求澄清应该问什么
拿到这道题,第一反应不应该是画架构图,而是先问清楚几个业务参数。哪怕面试官没有给你任何数字,你也可以主动设定一套合理的业务假设,然后再基于假设去做设计。这样表达出来的思考过程,比直接给结论要高级得多。
至少要澄清这几个维度:
- 同时参与秒杀的人数规模,决定了接入层和网关需要抗多大的流量。
- 商品库存量,决定了数据库最终扣减的写压力。
- 预估峰值 QPS,决定了 Redis、MQ、DB 的容量规划。
- 对库存一致性的容忍度,秒杀场景一般要求零超卖,超卖一单都要出事故。
- 是否允许一个用户多次购买,如果限制一人一单,就需要额外的幂等和去重机制。
- 下单后的支付时效,这决定了未支付订单的库存回补策略。
举个例子,我通常会这样设定假设:1 万件商品,10 万人参与,活动开始后前 10 秒是请求高峰,预估峰值 QPS 5 万。基于这个量级,你再去画架构,每一步都有数据支撑,面试官一听就知道你是有真实经验的。
2. 秒杀系统的核心矛盾:极端流量下的“读”与“写”
2.1 流量漏斗:每一层都要筛掉一批请求
秒杀系统最本质的挑战是瞬间流量尖峰。平时日活几百万的系统,可能平均 QPS 只有几千,但秒杀开始那一瞬间,流量可能飙升到平时的几十倍甚至上百倍。如果所有请求都打到后端服务和数据库,再强的机器也扛不住。所以设计的第一个思路,就是做一个流量漏斗,在每一层都把无效请求筛掉。
漏斗从用户侧就开始了。第一步,商品详情页、活动页面、倒计时页面全部静态化,扔到 CDN 上,同一时刻几万人刷页面,压力全在 CDN,根本不会打到源站。但静态页里的库存数字是会变的,这个动态数据不能也塞进 CDN,否则用户看到库存还剩 10 件,点进去其实已经卖完了,体验极差。我的做法是页面框架走 CDN,库存数据用前端异步接口去拉,轮询间隔拉大到 3 到 5 秒一次,避免用户无脑刷新把接口打爆。
第二步,前端做交互层拦截。抢购按钮点击后立即置灰,同一用户 1 秒内只允许提交一次,防止用户手动狂点或者脚本并发刷请求。再配合一个答题验证码,活动开始瞬间弹出,把自动化脚本挡在门外。这些拦截虽然挡不住所有请求,但能把无效请求量级砍掉一大截。
第三步,接入层限流。用户请求到了 Nginx 或者网关之后,要做两件事:全局 QPS 限流保护后端服务,以及用户维度限流防止单用户刷接口。这里有个很容易踩的坑,就是按 IP 限流。秒杀场景下很多用户都挤在同一个公司、学校或小区的 NAT 出口后面,按 IP 限流会把正常用户误伤,一个办公室几十个人只能进来一两个,这种设计在秒杀场景肯定是错的。正确做法是以 user_id 或 device_id 作为限流维度,再结合一个更粗粒度的全局令牌桶做整体保护。
2.2 读多写少,读写一定要分离
秒杀流量还有一个鲜明特征,叫“读多写少”。用户从看到活动到真正下单,要经历好几次页面刷新、库存查询、倒计时轮询,这些全是读请求,可能占整体流量的 90% 以上。而真正扣减库存、创建订单的写请求,占比其实很小。所以读请求和写请求必须分开处理,不能混在一条链路上。
商品详情、活动规则、库存展示这些读请求,尽量走 CDN 和 Redis 缓存,只有缓存没命中的时候才回源到后端服务。真正涉及库存扣减的写接口,收敛成一个极简的“抢购”入口,路径越短越好。每多一个耗时的 RPC 调用、多一次数据库查询,QPS 能力就大幅下降。
我自己在设计写链路的时候,会给自己定一个耗时预算:从请求进入网关到返回“抢购中”的结果,整个链路不能超过 300 毫秒。倒推下来,抢购入口只做三件事:参数校验、Redis 原子扣减、发送 MQ 消息,所有重活全部异步处理。数据库的写入、订单的创建、库存的最终扣减,都交给 MQ 消费者慢慢消化。这样用户侧体验是秒回,系统侧压力也被削峰填谷了。
3. 库存扣减:为什么只提 Redis 一定答不完整
3.1 三种库存扣减方案横向对比
库存扣减是秒杀系统的核心难点,也是这道面试题里最容易翻车的地方。我见过太多人脱口而出“用 Redis 扣库存就行了”,但再追问一句“Redis 里扣减和数据库怎么保持一致”,就回答不上来了。下面把三种主流方案放在一起对比,每一种我都实际用过,优缺点非常清楚。
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库乐观锁 | update 语句里带 stock > 0 条件 | 实现极简单,不会超卖,绝对可靠 | 并发高时数据库行锁竞争严重,吞吐量有限 | 库存量小、并发量低的场景 |
| Redis 预扣减 + MQ 异步落库 | 先用 Redis DECR 扣减库存,成功后发 MQ 消息,消费者异步写数据库 | Redis 吞吐高,能扛住瞬时高峰,数据库压力被削峰 | Redis 和 DB 存在最终一致窗口,需要补偿机制,流程更复杂 | 并发量大的主流秒杀场景 |
| Redis + Lua 原子脚本 | 用 Lua 脚本把库存检查和扣减封装成一个原子操作 | Redis 单线程执行脚本,天然防超卖,还可以顺便做用户去重 | 依赖 Redis 高可用;库存预热和最终落库仍需单独设计 | 大流量秒杀的主流方案 |
数据库乐观锁的写法很简单,一条 update 就搞定:
UPDATE inventory SET stock = stock - 1 WHERE product_id = #{productId} AND stock > 0;如果影响行数为 0,说明库存不足,抢购失败。这个方案的优势是逻辑直白,数据库层面绝对不会超卖。问题是高并发下所有写操作都串行在行锁上,数据库连接池很快被占满,整体能扛住的 QPS 天花板很低,实测单表乐观锁的写吞吐通常只能到几千 TPS。所以面试时可以说它是兜底方案,但不能作为主方案。
第二种方案,Redis 预扣减。活动开始前把库存预热进 Redis,用户请求进来直接DECR扣减,扣减成功就发 MQ 异步落库。Redis 单机读写轻松到十万级 QPS,扛瞬间高峰没问题。但这里有个隐患:Redis 扣减成功了,MQ 消息消费失败或者数据库写入失败,两边就会不一致。所以必须设计对账和补偿机制,定时扫描 Redis 和 DB 的差异数据并修复。这个方案能用,但你要能讲清楚怎么兜底,否则面试官会追问到漏洞。
第三种方案,Redis + Lua 原子脚本,也是我推荐在面试里重点讲的方案。它的核心是用 Lua 脚本把“检查库存 + 扣减库存 + 记录用户”放到一个原子操作里执行,因为 Redis 是单线程模型,脚本运行期间不会被其他命令插入,所以并发请求不会互相覆盖。这个方案同时解决了超卖和重复抢购两个问题,下面单独展开。
3.2 Redis + Lua:既要防超卖,也要防重复
先看一段实际的 Lua 脚本:
local stockKey = KEYS[1] local userSetKey = KEYS[2] local userId = ARGV[1] local quantity = tonumber(ARGV[2]) local stock = tonumber(redis.call('GET', stockKey)) if not stock or stock < quantity then return 0 end -- 判断用户是否已经抢购过 local isPurchased = redis.call('SISMEMBER', userSetKey, userId) if isPurchased == 1 then return 2 end redis.call('DECRBY', stockKey, quantity) redis.call('SADD', userSetKey, userId) return 1脚本返回三种结果:1 表示扣减成功,0 表示库存不足,2 表示用户重复抢购。这样一次原子操作就把超卖和一人一单都防住了。为什么它能防超卖?因为 Redis 是单线程处理命令,Lua 脚本在DECRBY执行完之前,不会有其他命令插进来修改 stock 的值。所以并发 1 万个请求进来,都会老老实实地排队执行这段脚本,库存扣到 0 之后,后续请求全部返回 0。
活动结束后,Redis 里的库存数据要同步回数据库做最终一致性。这个同步过程我不建议一条条地更新数据库,而是用 MQ 批量消费、批量 update,或者每天跑一次对账任务,把 Redis 的扣减流水和数据库实际扣减结果做比对。同步的时候要注意幂等性,同一个用户、同一个活动,只能同步一次,否则会出现库存多扣的情况。
另外还有一个细节:如果用户下单后没有及时支付,比如 15 分钟超时,订单要关闭,库存要回补。回补库存时也要先幂等判断,不能一个订单被取消两次,把库存加多了。这个逻辑放在支付超时的回调里做,同时加一层定时任务扫描兜底。
4. 从点击到订单:一条能落地的完整链路
4.1 核心链路分步骤拆解
讲完核心方案,我把一条完整的秒杀链路从头到尾走一遍。这套链路我在项目里实际部署过,每一步都有明确的作用,面试时按这个思路讲,逻辑会非常清晰。
第一步,活动预热。活动开始前,把商品库存预热到 Redis,商品详情页静态化并上传 CDN,预热本地的库存缓存,后端服务预先把数据库连接池、线程池调到较大值。这一步的核心目的是让流量进来的时候,所有资源都处于就绪状态。
第二步,用户访问活动页。浏览器请求命中 CDN,返回静态页面,页面里的库存数字由前端每 3 秒轮询一次后端接口。后端接口优先查 Redis 缓存,缓存里没有回源数据库,同时设置较短的缓存过期时间,避免秒杀开始后数据过于陈旧。
第三步,用户点击抢购。前端按钮置灰,同时带上活动 ID、用户 ID 和签名参数,发送到后端抢购接口。注意这个接口的 URL 要动态化,在活动开始前才下发带签名的限时链接,防止脚本提前探测到真实接口。
第四步,后端进入秒杀服务。网关层做用户维度限流和全局令牌桶限流;秒杀服务做参数校验、签名校验,然后执行 Redis + Lua 脚本扣减库存。脚本返回 1,继续走下一步;返回 0 或 2,直接返回“手慢了”或“您已参与过”。
第五步,异步下单。扣减成功后,把 userId、productId、actId、库存扣减记录封装成消息,发送到 MQ。秒杀接口立刻返回“抢购受理中,请稍后查看结果”。这时候用户不会干等着,前端会进入一个轮询等待页面。
第六步,MQ 消费者创建订单。消费者从 MQ 拉取消息,先去 Redis 或数据库做幂等校验,防止同一个用户被消费两次,然后创建订单记录、扣减数据库库存、返回订单号。数据库的 update 依然要带stock > 0条件兜底,防止极端异常下出现超卖。
第七步,结果通知。消费者处理完后,把“成功”或“失败”的状态写入 Redis。用户前端轮询订单状态接口,从 Redis 里拿结果,成功就跳转到支付页,失败则关闭抢购流程。
4.2 容量估算:拿着数字做设计
很多候选人讲链路讲得天花乱坠,一问“你凭什么觉得能扛住”,就答不上来了。面试中如果你能主动给出容量估算,是很加分的。我分享一个常用的简化估算过程。
还是用前面的假设:1 万件商品,10 万人参与,前 10 秒是请求高峰。假设从用户看到页面到点击抢购的转化率是 30%,也就是 10 秒内有 3 万次抢购请求,平均 QPS 3000,峰值翻倍算 6000 QPS。抢购接口只执行 Redis 原子脚本和发 MQ,单次链路耗时按 50 毫秒估算,单机理论吞吐 20000 QPS,所以秒杀服务层部署 2 到 3 台机器就可以扛住 6000 QPS,每台机器负载不高,还有富余。
Redis 方面,6000 QPS 对 Redis 来说非常轻松。即使算上库存查询、幂等校验等所有读写,Redis 单实例也能支撑几万 QPS。瓶颈不在 Redis,而在下游 MQ 消费和数据库写入。MQ 本身吞吐极高,但消费者写数据库的能力有限。1 万件商品最终会产生 1 万条订单和 1 万次库存更新,分散在活动开始后的 1 到 2 分钟内。如果消费者按批量方式处理,每秒能消化 200 到 500 笔,1 万笔订单大约 30 秒到 1 分钟处理完,完全来得及,而且对数据库没有冲击。
所以整体结论是:对于万级库存、十万级参与的秒杀,Redis + MQ + 异步下单这套方案完全够用。如果库存到百万级、参与人数到千万级,那才需要再加分库分表、多地多活、甚至更复杂的云上弹性扩缩容。面试时把量级说清楚,再基于量级给方案,会显得非常专业。
4.3 前端和接入层的防刷细节
秒杀系统的对抗对象不只是正常用户,还有脚本党。我见过不少活动被脚本刷穿库存的案例,所以防刷措施一定要在设计链路里就考虑进去。前端和接入层这部分的处理,往往是被候选人忽略但是面试官很爱追问的细节。
第一,秒杀接口动态化。真正的抢购 URL 使用活动 ID、用户 ID、时间戳和签名生成,活动开始前几分钟通过页面脚本动态下发。签名使用后端密钥做 HMAC,用户无法伪造。这样脚本无法提前爬取固定的接口去刷。很多老系统用固定 URL,被脚本预热后活动一开始就瞬间被机器刷完,这个坑要避开。
第二,验证码延迟弹出。活动开始瞬间先不显示验证码,等用户点击抢购按钮后再弹出,让脚本来不及在流量峰值前预制答案。滑块或者点选验证码都可以,核心目的是拖慢自动化工具的节奏。千万不要用纯数字验证码,现在的识别服务准确率很高,挡不住脚本。
第三,多层限流配合。接入层全局令牌桶限制整个集群的入口流量,应用层用 Redis 滑动窗口做用户维度限流,比如每个用户 1 秒最多 1 次抢购请求。再加上设备指纹风控,同一设备被检测到大量异常请求时直接拉黑。这样一层层下来,大部分无效流量在触达核心的扣库存逻辑之前就被拦截了。
5. 高频追问和容易翻车的细节
5.1 超卖到底怎么防
超卖是秒杀系统里最敏感的问题,面试官几乎必问“你怎么保证不超卖”。完整回答应该包含两层:主方案是 Redis + Lua 原子脚本,在扣减库存这一步就挡住并发超卖;兜底方案是数据库 update 带stock > 0条件,即使异步落库出现异常,数据库层面也不可能扣成负数。两层都上了,超卖才算是真正被堵死。
光答“用 Redis 扣减”是不够的,面试官会继续追问:“Redis 扣减成功了,但 MQ 消费失败,数据库没有扣减,Redis 显示库存为 0,实际数据库还有库存,这时候怎么处理?”正确的思路是:这不是超卖问题,而是数据一致性问题,需要用对账任务定期比对 Redis 扣减流水和数据库实际库存,发现不一致就基于数据库的最终结果做修正。能把这个补偿链路讲出来,面试官就会认为你真的处理过生产环境的问题。
5.2 一人一单怎么限制
很多秒杀活动都要求一个用户只能抢一件,防止黄牛囤货。这个限制不是加个数据库唯一索引就完事了,高并发下要做前置判断。最方便的做法是我前面 Lua 脚本里已经写过的,用 Redis Set 记录每个活动已抢购的用户 ID,扣库存之前先判断 SISMEMBER。如果用户已经抢过,直接返回重复参与,不再扣库存。
但这里有一点需要小心:如果用户把订单取消掉、并且已经回补了库存,这个用户还能不能再抢一次?业务规则不同,实现也不同。有些活动允许取消后重新抢,那 SISMEMBER 判断就不能在取消订单时删除用户记录,要设计新的活动轮次。这个细节面试时主动提出来,会体现出你对业务规则和实现方案之间关系的思考深度。
5.3 接口被脚本狂刷,限流挡不住怎么办
很多系统的限流方案在秒杀开始瞬间就被打穿,原因是限流维度选错了。按 IP 限流会误伤正常用户,按全局 QPS 限流会导致系统在高峰时把所有用户都挡在外面。正解的层次是这样:
第一层,用户维度限流,这是主防线。一个 user_id 在秒杀活动期间只允许一定频率的抢购请求。第二层,设备维度风控,用户在登录时获取设备指纹,异常设备直接风控,别让它进到扣库存环节。第三层,接口维度全局限流,保护系统不被击穿。第四层,数据维度去重,Redis Set 里已经存在 userId,后续请求直接返回。这四层配合下来,脚本即使拿到了 URL,也会被用户维度限流挡在外面,而且由于动态 URL + 签名,脚本无法提前拼接合法请求。
6. 真实项目里踩过的大坑
6.1 Redis 单点故障导致活动直接挂掉
我之前做第一版秒杀系统的时候,图省事只部署了一个 Redis 实例,觉得秒杀时间短,Redis 扛一下就过去了。结果活动当天 Redis 实例因为内存不足触发了 OOM,整个秒杀服务瞬间不可用,所有请求都打到异常链路上,用户端全是报错。这个事故给我的教训非常大:Redis 在秒杀系统里是核心依赖,必须做高可用。哪怕你的预算紧张,也要至少搞一主一从加哨兵,避免单点故障。同时要设计降级方案:如果 Redis 不可用,秒杀接口直接快速失败,返回“活动太火爆”,而不是让请求继续穿透到数据库把后端拖垮。秒杀场景里保护系统比追求成功率更重要。
6.2 MQ 消费积压,用户一直等待结果
另一场活动里,扣库存跑得很顺畅,用户点击后瞬间得到“抢购受理中”,但后续订单一直没创建。排查了半天发现是 MQ 消费者的并发线程数配置太低,而且消费者内部还要调一个缓慢的商品服务接口,一个消息处理耗时到了 1 秒多。秒杀开始后 MQ 瞬间涌入几千条消息,消费者处理不过来,消息积压越来越多。
这个问题的解决方式是:消费者内部不调用远程服务,只做纯数据库写入,远程查询都改成查本地缓存或 Redis;同时把消费者线程数调大,启用批量消费模式,一次拉取和确认一批消息,再加上 MQ 消费 lag 的实时监控报警,积压超过阈值就自动扩容。秒杀系统里,不要把远程调用放在下单消费者的关键路径上,这一点很值得记住。
6.3 限流维度选错,正常用户被误伤
还有一次活动,上线前压测发现网关限流阈值设太高没触发,上线后瞬间流量冲进来,网关按 IP 做了限流,结果一个办公区的人全部被限住。用户反馈“亲戚朋友都成功了,就我们公司的人点不动”。排查后发现被限的 IP 段就是几个大企业出口。后来把限流改成了用户维度加设备维度,网关只做全局 QPS 保护,这个问题才解决。秒杀场景下,限流的目的不是均匀限制所有人,而是把单用户异常流量和全局超阈值流量卡住,正常用户的体验要在设计限流方案时优先考虑。
6.4 面试现场的高质量回答结构
最后说回面试本身。如果你被问到“秒杀系统怎么设计”,我建议按照下面这个结构来回答,既完整又有层次感:
第一步,澄清需求。主动确认并发量、库存量、是否限购、是否允许取消重抢这些业务约束。
第二步,给一个整体设计。用一到两分钟把链路讲清楚:CDN 静态化、网关限流、秒杀服务、Redis 预热、Lua 原子扣减、MQ 削峰、异步下单、数据库兜底。
第三步,重点讲两个核心难点。超卖怎么防,一人一单怎么限制,把 Lua 脚本的逻辑讲出来,再补充 DB 乐观锁兜底和对账补偿。
第四步,讲一下你做过的降级和保护性设计。Redis 挂了怎么办,MQ 积压怎么办,限流维度怎么选,这些才是面试官区分新人和有经验工程师的关键。
第五步,如果面试官追问某个细节,比如“你这套方案支持多大的量级”,要用前面讲的容量估算方式回答,给出具体数字和结论。
我个人这些年做高并发系统的体会是:秒杀这道题在面试里压中率极高,但能答好的人极少,原因就在于大家总是记住了技术名词,忘记了系统的本质是在有限资源内优雅地扛住峰值。每次你准备说“用 Redis”之前,先问问自己:如果 Redis 挂了怎么办?如果消息积压了怎么办?如果数据库被写爆怎么办?能把这三个问题答清楚,这道题的分数基本就稳稳到手了。