跳到主要内容
约 12 分钟5 张卡

秒杀系统设计

秒杀系统要在短时间的大量请求中,保护有限库存,并让每个订单都有可查询、可恢复的结果。限流能减少进入交易服务的请求,库存事务决定谁真正买到商品;把 Redis、消息队列和数据库串起来,还不足以保证不超卖。

讨论这个设计时,先约定业务规则,再解释并发与故障下规则为什么仍然成立。本文以单个活动、单个 SKU、每位用户限购一件为例,使用 MySQL 8.0 InnoDB 保存库存与订单,不展开跨仓调拨和多商品购物车。

1. 面试核心​

先记住这几条,再读正文
  • 秒杀需要同时处理入口容量、库存约束、重复请求和交易恢复。先定义购买成功、排队中与售罄分别意味着什么,再选择同步下单或异步排队。卡 · 综合题
  • 页面缓存与限流负责减少压力,限流器不可用时也要给进入交易服务的请求设上限;无限放行会把故障传给数据库。卡 · Redis 限流不可用时怎么办?
  • 库存扣减必须带上余额条件,并与订单写入处于同一个本地事务。只检查库存不为负,还不能证明有效订单数量没有超过总量。卡 · 先把业务规则写成可检查的约束
  • 请求幂等与每人限购是两个约束:相同请求不能重复扣库存,同一用户换请求号也不能绕过限购。卡 · 重试时找到同一个结果
  • 支付确认和超时关单都要检查订单当前状态,只有成功完成关单状态转换的事务才能释放库存。外部已付款而本地已关闭,需要查询支付结果并退款或进入人工处理。卡 · 综合题
  • 消息重试、服务重启和响应丢失都应有明确后续动作。排队成功不是下单成功,超时也不等于事务失败,恢复时应先查询已有业务状态。

2. 先把业务规则写成可检查的约束​

可售库存是当前还能被新订单占用的数量。用户创建待支付订单时先占用一件,付款后继续占用;订单关闭后释放。假设初始库存为 100 件,期间没有补货、退款回库或人工调整,则应满足:

可售库存 + 待支付订单数 + 已支付订单数 = 100

这个等式把库存与订单联系起来。两个请求都读到剩余一件,各自创建订单,再把库存写成零,库存从未变负,却已经有两个有效订单争夺同一件商品。因此还要检查库存与订单是否一一对应,不能只检查 available >= 0。

本文约定同一用户在同一活动的同一 SKU 下最多创建一个订单,取消后也不能重新参与。这是为了把限购规则写清楚;若业务允许取消后重购,就要额外设计购买资格的释放与再次领取,不能直接沿用这个唯一约束。

下单前由服务端检查活动时间、用户资格、商品与价格。客户端传来的价格和倒计时只供展示,不能作为交易依据。请求号也必须绑定当前认证用户、活动和 SKU,防止一个请求号被拿去查询或操作别人的订单。

3. 入口先保护容量​

静态活动页、图片和商品介绍可以使用 CDN 与缓存,库存展示允许按明确的延迟更新。交易接口仍需重新验证资格和库存,页面显示“还有库存”不承诺一定下单成功。

限流可以分别约束用户重复点击、单个活动和整个交易服务。按用户限流能限制单人请求,但大量不同用户仍可能同时到达,所以还需要总量限制、并发限制以及有界队列。假设经过压测,下单服务在满足延迟目标时每秒能处理 2,000 个请求,而入口每秒到达 20,000 个请求,允许全部请求排队只会让积压持续增长;服务必须拒绝、延后或丢弃超出承载范围的部分。

有界队列是容量和等待时间都有上限的等待区。每秒持续多到达 18,000 个请求,一分钟就增加 108 万个待处理请求。增加消费者只有在数据库和其他下游还有容量时才有效,不能靠扩大队列消除吞吐差额。

达到请求频率限制时,可以返回 429 Too Many Requests,并用 Retry-After 给出建议等待时间;服务暂时无法承接业务时,应按故障含义返回服务不可用结果。客户端应限制重试次数并加入退避,避免所有人固定间隔一起重试。不同维度的限制方法见限流。

3.1 Redis 限流不可用时怎么办?​

需要预先决定拒绝请求还是使用保守的本地额度,不能在异常分支里无限放行。若采用本地降级,每个实例的额度乘以可运行实例数,就是需要检查的总量上界;实例扩容、重启重置额度和突发额度都会影响这个上界。无法约束实例数和额度分配时,本地降级就不能声称维持原来的全局限制。

严格的库存正确性仍由后面的事务保护,但这不代表可以让数据库承受无限请求。入口限制失效可能先耗尽连接池、线程和锁等待时间,使查询订单结果与支付回调也无法执行。可以为这些恢复与查询路径保留容量,避免新购买请求挤占全部资源。

4. 用事务同时占库存和建订单​

下面的最小结构用一行表示活动中一个 SKU 的库存。订单的 request_id 标识一次逻辑下单,UNIQUE (activity_id, sku_id, user_id) 表达前面约定的每人一次规则。

CREATE TABLE sale_stock (
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available INT NOT NULL,
PRIMARY KEY (activity_id, sku_id),
CHECK (available >= 0)
) ENGINE = InnoDB;

CREATE TABLE sale_order (
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
state ENUM('PENDING', 'PAID', 'CLOSED') NOT NULL,
UNIQUE KEY one_purchase (activity_id, sku_id, user_id)
) ENGINE = InnoDB;

实际订单还需要保存成交金额、币种、过期时间和支付标识等字段。这里省略它们,只看库存与订单的约束;CHECK 的实际执行以 MySQL 8.0.16 及以后为前提,不能用它代替条件更新。

下单事务先插入 PENDING 订单,再执行下面的扣减语句。应用必须检查受影响行数:等于 1 才提交;等于 0 表示库存不足或库存记录不存在,回滚刚插入的订单。

UPDATE sale_stock
SET available = available - 1
WHERE activity_id = 1 AND sku_id = 101 AND available >= 1;

这里的活动与 SKU 固定为示例值,实际服务应使用绑定参数。订单插入和这条更新必须使用同一数据库连接、同一个显式事务,不能分别自动提交。唯一键冲突、锁超时、死锁或其他错误也要由应用明确结束事务,不能在错误后继续提交剩余操作;数据库对单条语句的回滚不总是等于整个事务回滚。

InnoDB 的更新会对匹配的记录加排他锁。同一库存行上的并发扣减要等待前一个事务结束,随后根据可更新的当前记录判断条件:只剩一件时,最多一个事务能把它扣成零,另一个更新不到符合条件的行。这种条件更新的并发机制见乐观并发控制。

即使有很多商品分布在不同数据库节点,单个爆款 SKU 的这一行仍然可能成为竞争点。先测量锁等待和事务时长,再考虑把总库存明确分配到互不重叠的库存桶;分桶后要解释额度转移、某个桶售罄而其他桶有剩余以及恢复时如何保持总量。增加 Redis 或数据库分片数量,不会自动把同一个库存计数变成可任意并行修改的数据。

4.1 Redis 预扣库存后,数据库失败怎么办?​

如果 Redis 只做快速筛选,数据库事务仍是订单是否成立的最终依据,Redis 的判断可以不精确,但不能绕过数据库的库存条件。Redis 误判售罄会漏掉购买机会,需要修正展示或筛选状态;误判有货会多放一些请求,数据库仍应拒绝超额订单。

若要让 Redis 预扣本身代表库存预留,就必须保存预留 ID、所属请求和状态,并处理预扣成功但消息没发出、消息重复、数据库写入失败以及 Redis 故障恢复。失败时直接执行一次加库存并不够:补偿重复会多加,原下单其实已经成功时补偿又会释放已售出的商品。只有确认预留仍未转成有效订单,并成功改变预留状态的操作才能释放一次。

因此,预扣方案需要自己的持久化与恢复协议,不能把“Lua 原子执行”当作跨 Redis、消息队列和数据库的事务保证。若当前容量足够,先采用入口限流加数据库短事务,更容易说明并验证完整约束。

5. 重试时找到同一个结果​

下单幂等是同一次逻辑下单被重复执行时,不会生成额外订单或再次占用库存。请求号用于识别重试,用户限购约束用于限制业务资格,两者缺一不可:用户换一个请求号重试,可以绕过单纯的请求号去重。

订单插入遇到唯一键冲突时,先回滚当前事务,再查询已有订单。应用需要区分冲突来自相同 request_id 还是同一用户已经参与,并核对请求参数与订单所属用户。相同请求、相同参数返回原订单;同一请求号对应不同参数应拒绝;另一个请求号触发限购时,返回已经参与的业务结果。不能把所有数据库异常都当成重复成功。

如果事务已经提交,但响应在网络中丢失,客户端看到的超时不能说明库存扣减失败。客户端应使用原请求号查询或重试,服务端返回已经存在的订单。若提交结果暂时无法确认,可以返回处理中,等数据库可查询后再判断;不要换请求号重新扣库存。

请求记录需要保留到业务允许的重试和恢复窗口结束。若活动仍可访问却提前删除了去重记录,旧请求就可能重新取得资格。相关语义见分布式流程恢复。

6. 支付与关单竞争同一个状态​

订单状态机规定订单允许怎样变化。本文的本地状态只允许 PENDING → PAID 或 PENDING → CLOSED,不允许关闭后的订单直接复活;外部退款与对账还需要单独记录处理状态。

操作必须满足的条件在本地事务里完成的事
确认付款支付信息已验证,订单仍为 PENDING把订单改为 PAID,库存不再变化
超时关闭已到过期时间,并完成所选支付关闭或查询协议,订单仍为 PENDING把订单改为 CLOSED,可售库存加一
重复付款通知订单已经是 PAID核对同一支付事实后返回已处理,不重复执行业务效果
关闭后收到付款事实订单已经是 CLOSED保持关闭,记录异常支付,查询、退款或人工处理

本地支付确认使用带状态条件的更新,例如 UPDATE sale_order SET state = 'PAID' WHERE request_id = ? AND state = 'PENDING'。关单也只能从 PENDING 改到 CLOSED,检查实际修改了一行后,才在同一事务里给对应库存加一。两个操作竞争同一订单行时,只能有一个完成这次状态转换;重复关单不会再次释放库存。

状态条件解决的是本地并发,不能让外部支付系统与数据库自动原子提交。用户可能已经付款,但通知还没到,关单任务不能只凭“本地仍待支付”就断言没有收钱。具体做法要结合支付渠道的查单、关单和退款协议:能确认支付订单已关闭再释放库存,或者接受本地先关闭、晚到付款必须退款的业务规则。后者会出现用户已付款却无法买到商品,需要提前明确体验与恢复责任。

支付通知应校验签名,并核对商户、订单、金额、币种和支付状态。通知可能重复或乱序;事件去重与订单状态检查要和业务修改一起可靠记录。确认已接收的通知也需要持久化后续工作,不能先向渠道确认成功再把唯一一份待处理信息留在进程内存。这里不提供支付渠道接入实现,机制可参考 Stripe 官方 Webhook 文档中的重复事件与事件顺序说明。

7. 异步排队与故障恢复​

异步下单把一部分等待从 HTTP 请求转移到队列,但用户收到“已入队”时,还没有获得库存。入口必须明确返回排队标识与查询方式,消费者成功提交订单后才能显示下单成功。队列已满、等待过期和售罄都要有终止状态,避免用户一直看到处理中。

消费者可能在数据库提交后、确认消息前退出。消息重新投递时,应沿用同一个请求号和数据库约束,查询已有结果或处理唯一冲突,不能再次占库存。若订单提交后还要发送支付提醒等消息,可以把待发送事件与订单放进同一数据库事务,再由发送任务重试;接收方仍然要按业务键去重,这不等于消息只会发送一次。

恢复任务应能针对具体差异采取动作:

可观察到的情况后续动作
订单提交成功,客户端未收到响应按原请求号查询结果
排队请求被重复消费用请求号和限购约束识别已处理结果
待支付订单长时间未终结查询支付事实,按状态条件支付确认或关单
关单已提交,又收到有效付款通知记录并推进退款或人工处理,不恢复原订单
库存与有效订单数量不符按活动核对明细与调整记录,查明差异后修复

除了请求量、拒绝率和延迟,还应监控库存锁等待、队列最老消息年龄、长时间待支付订单、退款积压与对账差异。一个请求成功返回,并不能说明后续交易已经完成;恢复流程见分布式流程恢复。

8 · 面试题卡片​

本页题目与参考回答

8.1 设计秒杀系统时,需要考虑哪些问题?​

题型:综合题

考察重点:入口容量、库存与订单约束、幂等、支付状态和故障恢复之间的关系。

相关内容:业务约束、库存事务、异步与恢复。

参考回答

先定义限购、库存占用与购买成功的业务规则,再设计流量控制和交易恢复。 页面与商品介绍可缓存,交易入口按用户、活动和服务总容量限流,并约束并发、队列长度和等待时间。

库存条件扣减与订单写入放在同一个事务,库存不足或写入失败就回滚。请求号约束重试,同一用户的唯一约束保证限购。支付与关单从同一个待支付状态竞争,只有成功关单的事务释放库存。消息重复、响应丢失和晚到付款都需要可查询的状态与后续处理;异步排队成功不能直接当作购买成功。

8.2 库存从来没有变成负数,是否足以证明没有超卖?​

考察重点:库存计数与有效订单之间的约束、条件更新及事务范围。

相关内容:业务约束、库存事务。

参考回答

不够,还要验证有效订单占用量与剩余库存之和没有超过可供销售的总量。 两个请求都读到一件,各自建单后把库存写成零,库存不为负,但已经超卖。

应在同一事务中写订单并执行带 available >= 1 条件的扣减,检查更新到一行才提交,否则回滚。关单释放也要与订单关闭在同一事务中完成,并且每个订单最多释放一次。

8.3 有了下单请求号,是否还需要用户限购的唯一约束?​

考察重点:技术重试与业务资格的区别、唯一冲突后的查询与校验。

相关内容:下单幂等。

参考回答

需要,请求号防止同一次请求重复执行,限购约束防止同一用户用不同请求号反复下单。 在本文规则下,可用活动、SKU 和用户组成唯一键。

发生冲突后先回滚事务,再检查已有订单与请求参数。相同请求返回原结果;请求号被用于不同参数应拒绝;限购冲突返回已经参与,不能再次扣库存。订单关闭后是否允许重购需要单独约定。

8.4 支付回调与超时关单同时执行,怎样避免重复释放库存?​

题型:综合题

考察重点:条件状态转换、本地事务边界和外部支付事实。

相关内容:支付与关单、库存事务。

参考回答

两个操作都只允许修改仍为 PENDING 的订单,只有关单更新成功的事务才能把库存加一。 关单与释放库存必须一起提交;已支付、已关闭或更新失败时不释放。

这只保证本地状态竞争正确。外部可能已经付款而通知晚到,需要依据渠道协议查单、关单,或在本地关闭后对晚到付款执行退款与对账,不能把订单直接改回已支付。

8.5 Redis 限流不可用时直接放行,库存事务能否解决所有问题?​

考察重点:正确性与容量保护的区别、降级额度的整体上界。

相关内容:限流失效。

参考回答

不能,库存事务限制成功订单数量,不能限制连接池、锁等待和 CPU 被多少失败请求消耗。 限流依赖故障时可以拒绝新请求,或者使用预先限定的本地额度。

采用本地额度时,应结合实例数量、突发额度和重启行为计算总量上界;无法约束这些条件,就不能继续承诺原来的全局限流。查询结果、支付通知和恢复任务还应保留必要容量。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明