高并发秒杀里,最容易被误解的不是“如何把库存扣得更快”,而是“扣减之后,系统能不能证明每一件库存去了哪里”。我在做秒杀压测和线上故障复盘时,见过库存显示为负数、订单支付成功却无法发货、回滚重复增加库存、同一用户拿到两张订单等问题。最后真正帮助我们定位问题的,往往不是某条总库存记录,而是一张按业务事件追加的库存流水表。数据库存的核心,不只是一个数字,而是这个数字每一次变化的原因、请求、订单、状态和责任边界。
数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水
很多入门实现只有一张商品表,里面放着 stock 字段。用户下单时执行一条减库存 SQL,支付失败时再加回去。这个方案在低并发环境下可以工作,但它只保存了“现在还剩多少”,没有保存“为什么变成这个数”。
一旦出现库存异常,工程师只能从订单表、支付表、日志文件和应用监控中拼接过程。日志可能过期,订单可能被补偿,支付回调可能重复,应用日志还可能因为采样而缺失。没有库存流水,就没有一份稳定、可审计、可重放的库存事实。
我更倾向于把库存模型拆成三个层次:库存余额、库存流水、库存状态。库存余额回答“现在有多少”,库存流水回答“发生过什么”,库存状态回答“这笔占用最终是成功、释放还是挂起”。三者缺一不可。
| 层次 | 核心问题 | 典型字段 | 主要用途 |
|---|---|---|---|
| 库存余额 | 当前还能卖多少 | 可用库存、锁定库存、已售库存 | 快速判断和展示 |
| 库存流水 | 库存为什么变化 | 流水号、变更量、业务类型、业务单号 | 审计、对账、追责、重算 |
| 库存状态 | 占用最终走向如何 | 锁定、确认、释放、异常 | 处理超时和补偿 |
因此,秒杀系统的第一原则不是“先上缓存”,而是先定义库存事件。没有事件模型,缓存只是把不清楚的逻辑执行得更快;没有幂等边界,分布式锁只是把重复执行排队;没有流水,任何补偿都无法证明自己没有再次制造问题。

秒杀结束时库存为零,只能说明某个时点的余额为零,不能证明没有超卖。比如初始库存为 100,系统先后产生 100 条成功占用流水、3 条重复占用流水和 3 条错误释放流水,最终余额可能仍然显示为零,但订单和流水已经不一致。
我在复盘库存问题时,会先计算一个基本恒等式:
当前可用库存 = 初始可用库存 + 所有增加类流水之和 – 所有扣减类流水之和
如果还区分锁定和已售,则应该继续计算:
初始库存 = 当前可用库存 + 当前锁定库存 + 当前已售库存 + 已废弃或调整数量
这些公式不是财务报表上的装饰,而是排查库存事故的第一道门。只要余额和流水不能相互推导,就说明系统至少存在漏记、重复记、错记或并发覆盖中的一种问题。
“扣库存”在不同业务里可能代表不同含义。预售商品可能是下单锁定,现货商品可能是出库扣减,团购商品可能是配额占用,优惠券库存则可能是资格发放。若把这些动作都命名为 decrease_stock,后续的支付、取消、退款和仓库出库会混在一起。
我建议先把库存动作命名为业务事件,而不是数据库动作。例如:
INIT:初始化库存。RESERVE:订单创建时临时锁定。CONFIRM:支付成功后确认销售。RELEASE:订单超时、取消或风控拦截后释放。ADJUST_IN:盘点、补货或人工修正增加库存。ADJUST_OUT:损耗、报废或人工修正减少库存。REFUND_RETURN:退款并且商品重新进入可售库存。事件名清楚,状态机才清楚;状态机清楚,幂等和补偿才有边界。这是我认为数据库存入门阶段最值得先掌握的判断。
以一件限量商品为例,活动库存为 1,000 件。用户点击秒杀按钮后,请求并不能直接把库存标记为“已售”。通常需要经过活动校验、用户限购校验、库存预占、订单创建、支付确认几个阶段。
如果系统在订单创建时就永久扣减库存,那么大量未支付订单会把库存长期占住;如果系统等支付成功才扣库存,又会让用户在等待支付期间继续竞争同一份库存,导致支付成功时库存不足。因此,更稳妥的模型是将库存划分为可用、锁定、已售三种状态。
| 阶段 | 可用库存 | 锁定库存 | 已售库存 | 产生流水 |
|---|---|---|---|---|
| 活动开始 | 1,000 | 0 | 0 | INIT +1,000 |
| 创建订单 | 999 | 1 | 0 | RESERVE -1 |
| 支付成功 | 999 | 0 | 1 | CONFIRM 状态转移 |
| 订单超时 | 1,000 | 0 | 0 | RELEASE +1 |
| 仓库出库 | 999 | 0 | 1 | SHIPMENT_CONFIRM,不重复扣减 |
这里有一个容易踩坑的地方:支付成功不是再次扣可用库存,而是把“锁定”转为“已售”。如果确认动作又执行一次库存扣减,系统就会出现双扣。换句话说,库存流水不仅要记录数量变化,还要区分余额变化和状态转移。
一个完整的库存占用通常至少包含三种结果:预占后确认、预占后释放、预占后进入异常待处理。不能只设计成功路径,因为秒杀系统中真正考验稳定性的往往是支付超时、消息重复、订单取消、回调乱序和数据库短暂不可用。
我会给每次预占生成一个独立的占用号,例如 reservation_id。后续支付确认和释放都引用这个占用号,而不是只引用商品编号。这样可以明确回答:“释放的到底是哪一次占用?”
如果只按商品编号执行释放,两个订单同时超时就可能互相覆盖;如果只按用户编号执行释放,用户多次尝试又可能释放错误库存。库存操作的最小幂等单位,通常不是商品,而是业务动作加业务单号。
在库存场景中,九数云更适合放在分析和经营复盘层,而不是放在高并发扣库存的事务主链路里。原因很直接:秒杀扣库存要求毫秒级响应、严格原子性和清晰的事务边界;分析平台更擅长把订单、支付、库存流水、退款和仓储数据汇总后,观察转化与异常。
例如,我会将以下数据同步到分析层:
通过这些数据,可以分析“库存卖完了”背后的真实过程:是有效用户快速转化,还是机器人请求占满资格;是库存真的售罄,还是支付超时释放不及时;是数据库锁竞争,还是消息消费延迟导致订单状态落后。分析工具能帮助我们解释库存,但不能替代库存一致性本身。
余额表的目标是提供快速读写,不负责承载所有历史语义。一个基础设计可以包含商品、规格和活动三个维度,避免同一商品参加不同活动时互相污染。
CREATE TABLE inventory_balance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available_qty INT NOT NULL DEFAULT 0,
locked_qty INT NOT NULL DEFAULT 0,
sold_qty INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_activity_sku (activity_id, sku_id)
);available_qty表示当前可以继续预占的数量,locked_qty表示已经被订单临时占用但尚未确认的数量,sold_qty表示已经确认销售的数量。version可以用于乐观锁,但不能把它误认为万能方案。
如果数据库执行的是“读取版本、应用层判断、再写回”的三步操作,版本号才有意义;如果更新语句没有把版本条件写进去,版本字段只是一个看起来专业的装饰。
流水表是后续对账、重算和事故恢复的核心。我的建议是采用追加写入,尽量避免修改已经完成的流水。若状态需要变化,可以记录状态变更事件,或者在允许的情况下使用独立状态表。
CREATE TABLE inventory_ledger (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ledger_no VARCHAR(64) NOT NULL,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
reservation_id VARCHAR(64) NULL,
biz_type VARCHAR(32) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
user_id BIGINT NULL,
delta_available INT NOT NULL DEFAULT 0,
delta_locked INT NOT NULL DEFAULT 0,
delta_sold INT NOT NULL DEFAULT 0,
before_available INT NULL,
after_available INT NULL,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_ledger_no (ledger_no),
UNIQUE KEY uk_biz_action (biz_type, biz_id),
KEY idx_sku_time (activity_id, sku_id, created_at),
KEY idx_reservation (reservation_id)
);这张表有几个字段非常关键。biz_type + biz_id用于业务幂等,request_id用于链路追踪,reservation_id用于关联一次库存占用,before_available和after_available用于快速审计,三组 delta则用于重算。
需要注意的是,前后余额字段属于“当时观察到的状态”,不能单独作为重算依据。真正用于重算的应是经过确认的增减量。因为并发环境下,不同事务看到的前后余额可能不具备全局顺序,流水号和提交顺序必须有明确策略。
请求编号适合识别一次网络请求,但不一定适合识别一次业务动作。用户点击一次,可能因为超时重试产生多个请求;支付平台回调一次,也可能发送多次通知。若每个请求都拥有不同的请求编号,系统仍然可能重复扣减。
更可靠的幂等键应该按照动作定义:
我会把“唯一约束”和“状态校验”同时使用。唯一约束防止相同动作重复插入,状态校验防止动作顺序错误。例如,已经确认销售的占用不能再次释放;已经释放的占用不能再次确认。
很多团队按“后台列表查询”去设计索引,却忽略了库存系统更常见的查询是对账、重放和异常扫描。至少要覆盖以下几类访问:
| 查询目的 | 建议条件 | 索引思路 | 风险 |
|---|---|---|---|
| 按商品重算 | 活动、规格、时间范围 | 活动编号、规格编号、创建时间 | 时间范围过大导致扫描 |
| 按占用处理超时 | 占用号、状态、创建时间 | 占用号与状态组合 | 只按商品扫描会放大锁竞争 |
| 按订单对账 | 业务类型、业务单号 | 业务动作唯一索引 | 缺失时重复动作难以识别 |
| 按请求追踪 | 请求编号 | 请求编号索引 | 日志和数据库无法关联 |

在单库场景中,最基础也最实用的扣减方式,是把“库存大于零”和“库存减一”放进同一条 SQL。不要先查询库存,再在应用层判断,最后执行更新。因为两个请求可能同时读到相同库存,随后都认为自己可以扣减。
UPDATE inventory_balance SET available_qty = available_qty - 1, locked_qty = locked_qty + 1, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE activity_id = ? AND sku_id = ? AND available_qty >= 1;
执行后检查受影响行数。受影响行数为 1,说明这次预占成功;为 0,说明库存不足或条件不满足。这里的关键不是 SQL 看起来简单,而是数据库把判断和修改作为一个原子操作执行。
如果一次购买数量为 n,条件应该写成 available_qty >= n,而不是循环执行 n 次减一。循环会放大锁竞争,也会让部分成功、部分失败的回滚逻辑变得复杂。
乐观锁通常是先读取版本,再执行带版本条件的更新:
UPDATE inventory_balance SET available_qty = available_qty - ?, locked_qty = locked_qty + ?, version = version + 1 WHERE activity_id = ? AND sku_id = ? AND available_qty >= ? AND version = ?;
如果更新失败,应用可以重新读取并重试。但在秒杀热点商品上,版本冲突率可能非常高。重试并不会创造库存,只会让更多线程继续争抢同一行,增加 CPU、连接池和数据库日志压力。
我的判断是:库存很少、请求集中、成功比例低时,乐观锁的重试收益通常有限。它更适合修改冲突不激烈、业务允许短暂重试、热点分散的场景,而不是把所有秒杀请求都压到一个热点行上。
SELECT ... FOR UPDATE可以保证同一库存行的串行修改,但事务持锁期间如果调用支付、远程风控、消息服务或复杂库存计算,锁的持有时间会显著增加。秒杀高峰期,这种设计很容易让数据库连接堆积。
如果使用行锁,事务中应该只做必要的本地操作:校验关键状态、更新余额、写入流水、写入订单草稿或本地消息。远程调用必须放在事务之外,并通过状态机和可靠消息完成后续推进。
| 组件 | 适合承担的职责 | 不适合承担的职责 |
|---|---|---|
| 数据库 | 最终库存事实、流水、订单状态、幂等约束 | 承接无限突发流量 |
| 缓存 | 快速拦截明显无库存请求、展示预估状态 | 单独作为最终事实来源 |
| 消息队列 | 削峰、异步创建订单、推进确认和释放 | 替代幂等和事务约束 |
| 分析平台 | 趋势分析、分渠道转化、异常监测、复盘报表 | 直接执行核心扣减事务 |
缓存库存与数据库库存不一致并不可怕,可怕的是团队没有定义谁拥有最终解释权。我的原则是:缓存可以短暂错误,数据库和流水不能失去可验证性。缓存命中“有库存”后,仍要经过最终扣减;缓存命中“无库存”时,也要考虑补货、回滚和延迟更新造成的误判。

库存状态机至少需要区分占用状态和订单状态。一个常见的占用状态可以是:INIT、RESERVED、CONFIRMED、RELEASED、EXCEPTION。状态之间不是任意跳转,而是由具体事件触发。
| 当前状态 | 允许事件 | 下一状态 | 库存影响 |
|---|---|---|---|
| INIT | 预占成功 | RESERVED | 可用减、锁定加 |
| RESERVED | 支付确认 | CONFIRMED | 锁定减、已售加 |
| RESERVED | 订单超时 | RELEASED | 锁定减、可用加 |
| CONFIRMED | 重复支付回调 | CONFIRMED | 无变化,仅返回幂等成功 |
| RELEASED | 迟到支付确认 | EXCEPTION | 不直接恢复销售,进入人工或自动补偿 |
| EXCEPTION | 补偿完成 | RELEASED 或 CONFIRMED | 根据业务裁决生成新事件 |
最重要的一条规则是:重复事件可以安全返回,非法状态转移必须被拒绝或挂起。例如,支付回调重复到达不应该再次增加已售库存;订单已经释放后又收到支付成功,也不能简单地把状态改回确认。
重复消息通常可以依靠幂等键拦截,迟到消息则涉及业务时间顺序。例如,订单超时释放已经完成,随后支付服务因网络延迟发送成功回调。此时如果系统只判断“支付成功就确认”,就可能把已经释放的库存再次确认,造成余额和订单事实不一致。
处理迟到消息需要同时判断业务状态和事件版本。可以使用订单版本号、支付单状态版本或事件产生时间,但不能只依赖客户端传入时间,因为客户端时间不可信。
在实际设计中,我会为库存占用保存一个单调递增的状态版本。每次状态推进都要求版本匹配;旧版本事件到达时,系统记录为重复或过期事件,不再改变库存。
释放动作必须验证三件事:这笔占用存在、当前状态确实是锁定、释放动作尚未执行。只有三项都成立时,才允许把锁定库存减少并把可用库存增加。
UPDATE inventory_balance b JOIN inventory_reservation r ON b.activity_id = r.activity_id AND b.sku_id = r.sku_id SET b.available_qty = b.available_qty + r.quantity, b.locked_qty = b.locked_qty - r.quantity, b.version = b.version + 1 WHERE r.reservation_id = ? AND r.status = 'RESERVED' AND b.locked_qty >= r.quantity;
随后还要在同一事务中把占用状态更新为 RELEASED,并写入释放流水。若只更新余额而没有状态变更,任务重试时就会再次释放;若只更新状态没有余额变化,库存则会永久丢失。
很多系统把异常状态设计成一个“暂时放着”的垃圾桶,最终无人处理。真正可用的异常状态应该有进入条件、责任队列、重试次数、最后错误原因和人工处理入口。

下面使用一组情景模拟数据说明排查方法。活动初始库存为 1,000 件,活动结束后余额表显示:可用库存 0、锁定库存 12、已售库存 981。按照余额表计算,库存合计为 993,少了 7 件。
如果只看订单表,系统显示支付成功 981 单、待支付 12 单,看起来非常合理。但流水重算发现,预占总量为 1,012,确认总量为 981,释放总量只有 19,而按超时任务记录,理论上应该释放 26 件。差异正好是 7 件。
| 核对项目 | 理论数量 | 实际数量 | 差异 |
|---|---|---|---|
| 初始库存 | 1,000 | 1,000 | 0 |
| 预占数量 | 1,012 | 1,012 | 0 |
| 支付确认数量 | 981 | 981 | 0 |
| 超时释放数量 | 26 | 19 | -7 |
| 当前锁定数量 | 12 | 12 | 0 |
| 余额可推导库存 | 1,000 | 993 | -7 |
继续按占用号查询后,发现 7 个释放任务在更新订单状态时发生超时。任务认为状态已经处理完成,实际上释放库存的事务没有提交。由于任务表先被标记为成功,后续重试没有继续执行,最终留下了 7 件锁定库存差异。
如果系统只有余额表,排查很可能停留在“数据库为什么少了 7 件”。有了流水和占用状态,则可以定位到“哪些占用没有释放、释放任务在哪一步失败、是否需要重放”。这就是库存流水的实际价值。

余额对账只检查当前三种库存之和是否合理;流水对账检查所有事件的增减是否能够推导余额;订单对账则检查订单状态与库存占用状态是否一致。三种对账的发现能力不同,不能只做其中一种。
建议在活动期间进行轻量实时校验,活动结束后再进行全量重算。实时校验不宜执行复杂聚合,否则会反过来影响主链路;可以只监控负库存、锁定超时、重复幂等冲突和流水写入失败。
可重放的关键不是把所有历史日志保存下来,而是保证流水具备确定的业务顺序、唯一身份和明确数量方向。重放程序必须能够识别已经执行过的事件,不能把整个流水表无条件再执行一遍。
一个安全的重放流程通常是:
我不建议直接把生产表中的 available_qty 改成计算结果。即便结果看起来正确,也可能掩盖未处理订单或迟到消息。正确的修复方式应该留下新的调整流水,让未来的人能看懂这次修复。
缓存可以降低数据库读压力,却不能自动解决最终一致性。缓存扣减成功而订单创建失败,缓存库存已经减少;缓存扣减失败但数据库释放库存,缓存又可能继续显示售罄。若没有回补、校准和最终扣减机制,缓存只是增加了一份需要维护的状态。
合理的做法是明确缓存的角色。它可以用于活动资格预热、无库存快速失败、热点请求削峰,但最终库存变更仍必须落到具有幂等约束的事实存储中。缓存初始化、回补和失效都应该有监控,不应依赖人工刷新。
分布式锁只能约束拿到同一把锁的参与者。如果有一条路径绕过锁直接修改数据库,或者锁租约过期后旧持有者继续执行,依然可能发生并发错误。锁还会带来续期、故障转移、锁粒度和性能成本。
即便使用分布式锁,也要保留数据库层面的条件更新和唯一约束。锁是减少竞争的协调工具,数据库约束才是最后一道事实边界。两者不能互相替代。
订单和库存可能位于不同服务、不同数据库或不同事务边界。订单先成功、库存后失败会产生无库存订单;库存先成功、订单后失败又会产生占用未落单。必须明确哪一步是业务承诺,哪一步是临时状态。
常见选择有两种:先预占库存再创建订单,失败则释放;或者先创建待支付订单,再通过库存预占决定订单是否有效。无论选择哪种,都要把中间状态对用户和运营人员解释清楚,不能把“待处理”伪装成“成功”。
数据库事务可以保证本地余额和本地流水同时提交,但不能自动保证消息发送、订单服务、支付服务和仓储服务都成功。把远程调用塞进数据库事务会延长锁时间,也不能消除网络故障。
更稳妥的组合是本地事务加可靠消息:本地事务同时写余额、库存流水和待发送事件;后台投递事件,消费方按业务键幂等处理;失败进入重试和死信队列;最终通过对账发现长期未闭合的状态。
秒杀压测报告经常只写吞吐量和平均响应时间,但库存系统更应该关注成功请求、库存不足请求、幂等重复请求、数据库冲突请求、消息积压请求和补偿请求的比例。
例如,系统每秒处理 20 万次请求并不代表库存能力足够。如果其中 19.8 万次请求最终都因为资格不符或库存已空而失败,真正需要保护的是那 2,000 次有效占用。把无效流量尽早拦截,往往比单纯扩容数据库更有效。

容量规划不能只看页面访问量。应分别估算页面浏览、资格校验、库存尝试、订单创建、支付回调和释放任务的峰值。库存数据库真正承受的,通常是库存尝试和状态推进,而不是全部访问。
可以采用以下估算方式:
如果预占一次写余额、一次写流水、一次写订单草稿,理论上一个成功请求可能带来三类写操作。再叠加支付确认和释放,不能只按“每次请求一次数据库写入”来估算。
如果活动库存规模有限、有效请求经过网关和资格校验后明显下降、数据库连接池和锁等待可控,那么单库事务加条件更新往往是最值得优先采用的方案。它的优势是模型简单、排查容易、对账直接。
很多团队过早引入多级缓存、分库分表和复杂队列,结果是库存事实分散在多个系统里,故障时没人能回答哪个数字是真实的。架构复杂度应该由有效并发、可接受延迟、业务损失和运维能力共同决定,而不是由技术名词数量决定。
如果用户可以接受“排队中”“结果稍后通知”,队列是很有效的削峰工具。它把瞬时并发变成可控的消费速率,保护数据库和订单服务。
但队列引入了新的问题:消息重复、消费顺序、积压、重试、死信、用户等待体验和库存结果查询。队列不是把一致性问题消失,而是把问题从同步请求搬到了异步状态机。上线前必须设计消费幂等和用户可见状态。
单个热点商品对应一行库存时,所有请求会争抢同一行。库存分桶可以把 10,000 件库存拆成多个桶,让请求随机或按规则落到不同桶中,从而降低单行热点。
但分桶会增加查询和回收复杂度。系统需要解决总库存计算、桶之间的分配倾斜、释放回收、超时扫描和补偿一致性。若商品库存只有几十件,分桶带来的管理成本可能高于收益。
| 方案 | 适合场景 | 主要收益 | 主要代价 |
|---|---|---|---|
| 单库原子更新 | 并发中等、业务刚起步 | 简单、可审计、易排查 | 热点行存在竞争 |
| 缓存预扣加数据库确认 | 读流量大、无库存请求多 | 减少数据库无效请求 | 需要处理缓存回补和漂移 |
| 队列削峰 | 允许异步返回结果 | 平滑写入峰值 | 引入积压和消费状态 |
| 库存分桶 | 单商品极端热点 | 降低单行锁竞争 | 回收、对账和倾斜更复杂 |
| 多库分片 | 库存和流量规模都很大 | 横向扩展写入能力 | 跨库一致性和运维成本高 |

如果现有系统只有一个库存字段,不要马上引入十几个中间件。先盘点现有动作:初始化、下单扣减、支付确认、取消释放、退款回库、人工调整分别在哪里发生,是否有业务单号,是否能重复执行。
建议先做一张事件清单,记录每个动作的前置状态、库存变化、唯一键、失败后的处理方式和是否允许重试。清单完成后,再建立流水表和唯一约束。
| 动作 | 前置条件 | 数量变化 | 唯一键 | 失败处理 |
|---|---|---|---|---|
| 预占 | 可用库存足够、用户有资格 | 可用减、锁定加 | 订单尝试号 | 返回失败或重试 |
| 确认 | 占用状态为锁定、支付有效 | 锁定减、已售加 | 支付单号 | 进入待确认 |
| 释放 | 占用状态为锁定、达到释放条件 | 锁定减、可用加 | 占用号加释放版本 | 定时重试 |
| 调整 | 有审批通过的调整单 | 按调整方向变化 | 调整单号 | 禁止静默重试 |
在单库阶段,预占成功后至少要保证余额变更和预占流水同时提交。若其中任何一步失败,整个事务回滚。流水写入失败不能只打印日志然后返回成功,否则后续无法重算。
BEGIN;
UPDATE inventory_balance
SET available_qty = available_qty – :qty,
locked_qty = locked_qty + :qty,
version = version + 1
WHERE activity_id = :activity_id
AND sku_id = :sku_id
AND available_qty >= :qty;
— 检查受影响行数,必须为 1
INSERT INTO inventory_ledger (
ledger_no, activity_id, sku_id, reservation_id,
biz_type, biz_id, request_id,
delta_available, delta_locked, delta_sold,
status, created_at
) VALUES (
:ledger_no, :activity_id, :sku_id, :reservation_id,
'RESERVE', :order_attempt_no, :request_id,
-:qty, :qty, 0,
'SUCCESS', CURRENT_TIMESTAMP
);
COMMIT;这里省略了订单写入和异常处理,但工程上必须检查受影响行数、唯一键冲突、事务提交结果和超时重试。特别是客户端已经超时而数据库实际提交成功时,重试请求必须依靠业务幂等键返回原结果。
库存锁定之后必须有明确的过期时间。定时任务不应直接扫描整张流水表,而应根据占用状态和过期时间建立待处理集合。任务每次领取一批记录,执行幂等释放,再更新处理结果。
释放任务要允许重复执行,但重复执行不能产生数量变化。可以通过占用状态的条件更新保证只有第一个成功者获得处理权,其他任务读取到已释放状态后直接结束。
对账任务则应分层执行:先检查负库存和异常状态,再检查最近时间窗口,最后在低峰期做全量重算。全量重算不应直接锁住所有库存,而应生成差异报告,经过确认后再生成补偿动作。
只压测全部成功的路径没有太大价值。库存系统最应该模拟的是数据库超时、支付回调重复、消息乱序、订单取消、释放任务重试、缓存故障和进程在提交前后突然退出。

CPU、内存、连接数和磁盘使用率当然要监控,但它们不能直接说明库存是否正确。库存系统至少要建立业务指标:
其中“库存闭合率”特别值得关注。可以把已经完成确认或释放的占用数量,除以全部已创建占用数量。闭合率下降,通常意味着支付回调、超时任务、消息消费或状态推进出现了积压。
“库存异常”这种告警没有足够行动价值。更好的告警应该带上活动编号、规格编号、差异数量、最早异常时间、受影响占用号和建议处理动作。
| 告警 | 触发条件示例 | 第一动作 | 后续处理 |
|---|---|---|---|
| 负库存 | 可用库存小于0 | 立即冻结人工调整 | 检查并发更新和补偿任务 |
| 锁定超时 | 超过阈值仍为锁定 | 检查支付与释放队列 | 按占用号重试或裁决 |
| 流水缺失 | 余额变更无法找到对应流水 | 暂停自动修复 | 检查事务和数据修复记录 |
| 消息积压 | 队列延迟超过业务时限 | 扩容消费者或降级入口 | 评估迟到事件影响 |
活动结束后,可以将库存流水与订单、支付、用户、渠道和仓储数据关联起来,分析每个阶段的转化。九数云在这里可以帮助团队搭建经营分析看板,例如按活动、商品规格、渠道和时间段查看预占成功率、支付转化率、释放率与异常率。
我比较看重的不是一张漂亮的销售额大屏,而是能够下钻到业务单号的异常分析:某个规格是否出现集中释放,某个渠道是否预占多但支付少,某个时间段是否数据库冲突激增,某种支付方式是否确认延迟更长。只有能从汇总指标回到明细流水,分析才真正服务于工程决策。

如果库存数量不大,活动频率不高,用户可以接受几百毫秒到一秒左右的结果延迟,我建议采用单库余额表、库存流水表、订单占用表和超时释放任务。先把事件闭环做完整,再考虑缓存和队列。
这类方案的优势是开发快、故障排查直观、对账成本低。缺点是热点行竞争可能限制峰值,但可以通过活动前资格校验、限流和请求合并缓解。不要因为“秒杀”这个词就直接引入分布式库存。
如果活动入口流量较大,但真正有资格的用户比例不高,可以在入口层使用缓存做活动状态、限购和明显无库存请求拦截。数据库仍然执行最终条件扣减,库存流水仍然是事实凭证。
这套方案的取舍是:响应速度更快,数据库压力更小,但需要维护缓存预热、失效、回补和监控。缓存中的数量只能作为快速判断,不能直接用于订单最终确认。
如果请求峰值远高于数据库稳定写入能力,同时业务允许用户看到排队结果,可以把预占或订单创建放入队列。前端显示排队状态,后台按可控速率消费,最终通过查询接口或通知返回结果。
这里的主要代价是用户体验从同步变成异步,系统必须处理消息积压和结果超时。库存锁定时间、支付窗口和排队时间要一起设计,否则用户还没有拿到订单,库存占用已经过期。
库存分桶适合单个商品极度集中、数据库单行锁竞争已经成为明确瓶颈的场景。实施前应先用压测数据证明问题来自热点行,而不是连接池、索引、网络或无效请求过多。
分桶后必须增加桶级流水、总库存汇总和回收逻辑。对于库存少、活动短的商品,分桶可能让运营、对账和售后都更复杂。能用单库原子更新解决的问题,不要用分桶制造新的状态空间。
医药、贵重物品、强监管商品或线下线上共享库存,通常更重视不超卖和可追溯。此时可以降低并发写入速度,增加预占确认、人工审核、库存冻结和严格对账。不要为了几毫秒的响应优势,放弃流水和业务审计。
如果业务允许少量“售罄误判”,可以采用更保守的库存阈值;如果绝对不能超卖,则必须让最终扣减受数据库约束或可靠库存服务控制。不同业务的损失函数不同,架构取舍不能只看技术指标。

高并发秒杀的难点从来不只是把库存扣到零,而是让每一次扣减、确认、释放和调整都能被证明。一个只保存库存总数的系统,在正常路径上看起来很快,遇到支付超时、消息重试和进程故障就会失去解释能力。
我给刚入门的后端工程师的建议是:先不要急着背缓存、锁和分片的方案。先画出库存状态机,定义业务事件,建立库存流水,给每个动作加上幂等约束,再用带条件的原子更新完成最小闭环。只有当压测数据证明单库方案确实成为瓶颈,才逐步引入缓存削峰、消息队列、库存分桶和分片。
库存余额是结果,库存流水才是证据;高并发优化解决的是速度,库存流水解决的是系统能否被信任。下一步可以从现有项目中挑一个库存接口,列出它会产生的所有业务事件,补一张流水表和一组对账 SQL,再针对重复请求、超时释放和乱序回调做三组故障测试。完成这一步,你才真正从“会写扣库存代码”进入了“能设计库存系统”的阶段。


读者评论
文章把可用、锁定、已售三种库存状态拆开讲,这一点很实用。以前我只关注库存字段是否扣减成功,忽略了支付超时和取消订单的释放逻辑,实际很容易出现库存长期被占用的问题。用 reservation_id 关联预占、确认和释放,确实比按商品编号处理更可靠。
库存流水的价值不只是记录历史,文中提到的库存恒等式更值得落地。出现库存对不上时,可以先用初始库存、可用库存、锁定库存和已售库存做反推,再定位漏记或重复记账。对于秒杀、优惠券这类高并发场景,这种对账思路比单看日志有效。
文中区分分析平台和扣库存主链路的边界比较准确。经营分析可以汇总订单、支付、库存和时延数据,但库存扣减仍需要放在具备原子性和明确事务边界的系统里。尤其是支付成功时应完成锁定转已售,而不是再次扣减,这个细节很容易被忽略。