秒杀系统设计
2019年双十一零点14分,某电商平台的订单系统风控报警突然响起——库存扣减QPS冲到28万,数据库连接池瞬间耗尽,服务开始雪崩。
事后复盘,原因简单到可笑:一个爆款商品(iPhone 11 Pro 256G,限量500台)的秒杀活动,因为没有做库存预热,导致大量请求直接穿透到数据库。
500台iPhone,引来了50万人同时抢购。
28万QPS的库存扣减请求,把MySQL打成了筛子。
这是所有秒杀系统设计的反面教材。
【架构权衡】
秒杀系统的本质是用可控的代价满足有限的资源。不是让你扛住100万人同时抢500台iPhone——那是物理上不可能的事。而是让这100万人在"感觉公平"的前提下,有序地参与抢购,最终只有500人得手。
一、秒杀系统的本质 🔴
1.1 三个核心问题
秒杀系统要解决三个核心问题:
1. 流量控制:如何让50万QPS的请求有序通过,而不是一股脑冲垮系统?
2. 库存扣减:如何在高并发下正确扣减有限库存,不超卖、不少卖?
3. 订单核对:如何保证最终一致性,不丢单、不多单?
这三个问题的难度依次递增。一个"能用"的秒杀系统能解决第一个问题;一个"好用"的秒杀系统能解决前两个;一个"工业级"的秒杀系统必须同时解决三个。
1.2 典型架构演进
架构分层:
- 第一层:CDN + 负载均衡(扛住入口流量)
- 第二层:秒杀网关 + 风控(拦住非法流量)
- 第三层:消息队列(削峰填谷)
- 第四层:库存服务 + Redis(原子扣减)
- 第五层:下单服务 + 数据库(持久化订单)
1.3 面试核心指标
面试官:秒杀系统的核心指标是什么?
候选人:三个:
一是系统可用性:秒杀期间服务不能挂,要保证99.99%的可用性。
二是库存准确性:不能超卖(卖出501台),也不能少卖太多(只卖出450台)。
三是用户体验:抢到的用户在3秒内收到反馈,没抢到的用户要在10秒内收到反馈。
面试官:超卖和少卖,哪个更严重?
候选人:超卖更严重。少卖只是商业损失,超卖会引发客诉、赔偿、平台信任危机。所以我们宁可少卖,不能超卖。
【面试官心理】
秒杀系统是面试中的高频考点,也是最能展示工程思维的题型。面试官问这道题,通常不是想听你背架构图,而是想看你能不能把"高并发"、"数据一致性"、"系统可用性"这三个分布式系统的经典矛盾串起来讲清楚。能说出"宁可少卖不超卖"的候选人,说明他真正思考过业务约束。
二、流量削峰 🔴
2.1 为什么需要削峰
核心思想:用"时间换空间"——把100万人在1秒内的请求,分散到1000人在1000秒内处理。
2.2 削峰四件套
1. 验证码/问答
2. 秒杀令牌
3. 消息队列
4. 预约+分层
2.3 削峰策略对比
生产环境中,通常组合使用多种削峰策略:验证码挡住机器 → 令牌控制并发 → 消息队列平滑处理。这三层削峰下来,100万QPS能降到1万QPS,数据库完全扛得住。
三、库存扣减 🔴
3.1 为什么不能用数据库直接扣减
3.2 Redis原子扣减方案
3.3 超卖问题的根因
3.4 库存扣减的三个坑
坑1:Redis挂了
问题:Redis宕机,所有扣减操作失败
解决方案:
- Redis集群部署(主从 + 哨兵)
- 写成功后立即异步落库
- 库存扣减成功后,给用户一个"预下单成功"
- 如果MQ消费失败,通过对账补单
坑2:Redis和数据库不一致
问题:Redis扣减成功,但MQ消息丢失,导致少卖
解决方案:
- 订单创建时校验Redis库存(乐观锁)
- 每5分钟对账一次:Redis库存 + 已下单未支付数 = 总库存
- 发现差异时,触发补库存流程
坑3:库存回滚
问题:用户下单后15分钟未支付,库存要回滚
解决方案:
- 下单时冻结库存(stock - 1 → frozen_stock + 1)
- 支付成功:frozen_stock → 已售
- 超时未支付:frozen_stock → stock
- 或者:支付成功才扣减库存,下单只做预占
【架构权衡】
库存扣减的核心矛盾是"性能 vs 一致性"。直接用数据库扣减,一致性最好(事务保证),但性能最差;用Redis扣减,性能最好,但需要额外的补偿机制保证一致性。工程上没有银弹,关键是明确你的业务约束:超卖容忍度是多少?少卖容忍度是多少?能接受多长的不一致窗口?
四、订单核对 🟡
4.1 三种订单状态
4.2 对账机制
4.3 幂等性保证
五、生产避坑 🟡
5.1 五大翻车现场
翻车1:库存预热失败
翻车2:热点用户刷单
翻车3:超卖
翻车4:库存回滚不及时
翻车5:CDN缓存过期
5.2 监控指标清单
秒杀系统最怕的不是慢,而是"不可预测"。平时正常,突然某一秒全挂——这种黑天鹅事件往往是因为忽略了某些边界条件。秒杀前一定要做全链路压测,把所有依赖项(Redis、MySQL、MQ、第三方支付)都纳入压测范围。
六、工程代价评估 🟢
七、面试应答模板 🟢
7.1 万能应答结构
7.2 高频追问应答
Q:如何保证不超卖?
用Redis + Lua脚本原子扣减,扣减成功后才发MQ,MQ消费失败时补偿。
关键点:Lua脚本里要有
if (stock <= 0) return -1的判断。
Q:如何处理少卖?
少卖的原因是用户下单后不付款,超时取消后库存回滚。
我们用定时任务扫描超时订单,每5分钟执行一次。
另外,秒杀结束后会对账,发现Redis和DB不一致时补发消息。
Q:Redis挂了怎么办?
方案一:本地缓存兜底。库存预热到每个服务器的JVM内存,用AtomicInteger保证线程安全。适合小库存场景。
方案二:降级为直接查数据库。如果Redis全挂,切换到数据库直接扣减(需要数据库能扛住)。
方案三:熔断。当Redis错误率超过10%,自动熔断,降级到排队购买。
【面试官心理】
秒杀系统的追问,通常会围绕"一致性"和"容灾"展开。面试官想看的不是你背了哪些方案,而是你有没有"兜底意识"——当核心组件挂了,你的系统还能不能降级运行。能说出"本地缓存兜底"或"熔断降级"的候选人,说明他有生产环境的实战经验,不是纸上谈兵。
八、真实面试回放 🟡
面试官:设计一个12306火车票秒杀系统,10万张票,1000万人抢购。
候选人(小张):我先确认几个约束:
- 这10万张票是同一个车次同一时间,还是多个车次?
- 用户一次可以买几张?
- 超卖容忍度是多少?
面试官:多车次多日期,用户一次最多买5张,允许极少量超卖(票到人不到)。
小张:明白。按1000万QPS设计,核心问题有三个:风控拦截、库存扣减、订单创建。
架构上分四层:
第一层:CDN + 验证码,挡住70%流量
第二层:秒杀网关 + 令牌桶,精确控制并发。令牌数量=库存×110%,多出10%是给取消订单回滚预留的。
第三层:Redis库存扣减 + 本地缓存兜底
第四层:MQ + 订单服务
面试官:为什么需要本地缓存兜底?
小张:Redis虽然很快,但机器挂了或者网络抖动时,请求会直接打到数据库。本地缓存是最后一道防线,每个JVM实例缓存100张票,原子扣减,撑30秒足够运维切换Redis主从了。
面试官:火车票有个特殊性是选座,怎么处理并发选座?
小张:好问题。选座需要座位级别的一致性。方案是:
- 每个座位单独一个Redis key:
seat:{train_id}:{carriage}:{row}:{col}- 用户选座时,原子检查+锁定该座位
- Lua脚本:GET + 判断 + SETNX
- 如果座位被锁定,返回"已被选择"
面试官:如果用户选了5个座位,第6个座位没抢到怎么办?
小张:两种方案:
方案一:分布式事务。用户先选5个座位,第六个座位失败时,回滚前5个。但分布式事务性能差。
方案二(推荐):分段锁。座位按车厢分组,每个车厢有自己的锁。用户先选座,提交订单时如果某个座位失败,整个订单失败,用户重新选择。这样可以用乐观锁,不需要分布式事务。
面试官:可以。你来总结一下核心设计。
小张:核心设计三点:
- 多层削峰:CDN + 验证码 + 令牌 + MQ,逐层过滤
- 原子扣减:Redis Lua脚本 + 本地缓存兜底
- 最终一致:MQ异步下单 + 定时对账 + 补偿机制
最大挑战是选座的并发控制,我选择分段锁方案,牺牲部分灵活性换一致性。
面试官:很好。
【面试官手记】
小张这场面试表现出色的地方:
- 确认约束时才问"多车次"和"超卖容忍度"——说明他知道不同场景方案不同
- 令牌数量 = 库存 × 110% —— 说明他考虑到库存回滚
- 本地缓存兜底 —— 说明他考虑到Redis容灾
- 选座问题回答得好:分段锁方案是工程中的常见选择,比分布式事务轻量得多
这场面试属于P7级别的系统设计,小张的回答超出了"秒杀系统"的范畴,进入到"复杂一致性场景"的深度讨论。加分。
秒杀系统是系统设计中的"老网红",但真正能在面试中把每个环节讲清楚的候选人不到20%。
记住三个核心:
- 削峰:把100万QPS变成1万QPS,用令牌、队列、验证码
- 扣减:Redis + Lua原子操作,保证不超卖
- 一致性:MQ + 对账 + 补偿,保证最终一致
当你能在白板上把这三件事讲清楚,秒杀系统这关就算过了。