数据库存:数据库管理员数据视角:用并发扣减验证降低超卖风险
库存只剩 1 件时,两个请求几乎同时到达,应用日志却可能显示“两个请求都成功”。这类事故最容易被误判为“数据库更新太慢”或“锁没有加好”,但我在排查库存异常时更常见的根因是:库存判断、扣减结果、订单状态和重试行为没有被放进同一套可验证的数据链路里。防止超卖的关键不是找到一条看起来正确的 SQL,而是让每一次扣减都具备条件、结果、流水和补偿依据。
本文从数据库管理员和数据可靠性的角度,拆解并发扣减为什么会失效,说明条件更新、受影响行数、事务、幂等、库存流水和对账之间如何协作,并给出一套可以在 MySQL 等关系型数据库中复现和验证的方案。文中的压测数字会明确标注为情景模拟或建议基准,不把未经环境验证的数据包装成真实线上结论。
库存超卖表面上表现为库存变成负数,或者订单数量超过可售库存。但从数据库角度看,至少有三个关系需要同时成立。
如果系统只保证第一条,仍然可能出现“库存没有负数,但同一个用户被扣了两次”的问题。如果只保证订单状态,又没有库存流水,就很难判断是重复请求、支付重试还是人工回补导致了库存偏差。
最基础的并发扣减可以写成下面这样:
UPDATE product_stock SET stock = stock - 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = ? AND stock > 0;
这条语句把“库存是否大于 0”和“库存减 1”放到了同一次数据库更新中。对于同一个商品库存行,数据库会按照并发控制规则处理竞争,后到的事务不会基于已经失效的库存条件继续扣减。
但是,这条 SQL 只解决了一个问题:在条件成立时进行一次原子扣减。它没有自动解决订单重复、支付失败、库存回补、跨服务事务、消息重复和实物库存不一致。
执行条件更新后,应用必须读取数据库驱动返回的受影响行数,而不是只判断 SQL 是否执行成功。
这里有一个经常被忽略的边界:不同数据库驱动和客户端配置对“受影响行数”的定义可能存在差异,特别是更新前后值相同、客户端启用特定兼容模式时,返回值解释可能不同。因此,正式上线前必须使用目标数据库版本、目标驱动和真实 SQL 做一次验证。

假设商品 A 的库存初始值为 1,应用采用以下逻辑:
现在请求 A 和请求 B 同时到达。在没有锁定读取或其他并发控制的情况下,两次查询都可能读到库存 1。两个请求都会通过应用层判断,然后分别执行扣减。即使数据库最终把库存更新成了 -1,系统也已经产生了两个“可以购买”的业务决策。
问题不在于数据库不会串行执行单行写入,而在于“读取库存”和“执行扣减”之间存在一个时间窗口。数据库可以保证每条 UPDATE 的执行秩序,却无法替应用层撤销已经基于旧读数做出的订单决策。
我通常会把库存异常分成四类,而不是统称为“超卖”。不同类型的异常,修复方式完全不同。
| 异常类型 | 典型现象 | 可能根因 | 优先排查对象 |
|---|---|---|---|
| 数量超卖 | 有效订单数量超过可售库存 | 先查后改、条件缺失、重复扣减 | 扣减 SQL、并发日志、请求号 |
| 库存负数 | 库存字段小于 0 | 更新未带 stock > 0 条件,或回补逻辑错误 | 库存表约束、扣减和回补流水 |
| 扣减丢失 | 订单成功但库存没有减少 | 跨服务调用失败、事务边界错误、异步消息丢失 | 订单表、消息表、库存流水 |
| 重复扣减 | 一次业务请求出现多条有效扣减 | 客户端重试、支付回调重复、消费者重复消费 | 幂等键、唯一索引、重试记录 |
有一次排查类似问题时,最初看到的是商品页面仍显示“有货”,但下单失败率突然升高。进一步对账后发现,数据库库存没有负数,扣减流水也没有超过可售量,真正的问题是缓存中的库存没有及时失效,页面读到了旧值。
这说明“用户看到可以买”和“数据库允许扣减”是两个不同的环节。缓存展示旧库存会造成用户体验问题,但只要最终扣减使用了带条件的数据库更新,通常不会直接造成库存数量超卖。反过来,如果应用相信缓存判断并直接写数据库,就可能把展示层的不一致扩大成交易层的不一致。
因此,我在事故判断时会先问一句:所谓超卖,是库存字段真的被扣过量,还是前端展示、订单状态、仓储实物和数据库中的某一层没有对齐?

下面的写法非常常见:
SELECT stock FROM product_stock WHERE product_id = ?; -- 应用层判断 stock > 0 后执行 UPDATE product_stock SET stock = stock - 1 WHERE product_id = ?;
即使两条语句都在一个事务中,也不能仅凭“放进事务”就断定它安全。普通 SELECT 通常是快照读取,读取到的值和后续 UPDATE 之间可能存在并发变化。只有当读取采用明确的锁定方式,并且事务范围、隔离级别和异常回滚都符合预期时,才有可能建立更强的控制。
更简单、更容易审计的做法,通常是直接使用条件更新,把业务约束放进 WHERE 条件,而不是让应用先读一个数字再自行判断。
悲观锁可以让多个事务在同一库存行上排队,但它只控制同一时刻的并发访问,不会自动识别“这已经是同一个业务请求”。如果客户端因为网络超时重试,第二次请求仍然可能在锁释放后再次扣减。
因此,锁解决的是并发访问顺序,幂等解决的是同一业务意图是否只能生效一次。把两者混为一谈,是库存系统中很典型的设计错误。
库存 UPDATE 返回 1,只能说明库存扣减动作已经被数据库接受。之后可能发生订单插入失败、支付服务超时、消息投递失败或事务回滚。如果系统已经把“扣减成功”直接当作“交易成功”,就会留下库存被占用却没有有效订单的孤儿扣减。
更严谨的状态应该至少区分:
如果库存初始为 100,系统错误地为 101 个订单分配了购买资格,但库存字段通过其他逻辑被截断为 0,那么数据库里可能没有出现负数,业务上却已经超卖。库存下限约束只能阻止某一类非法状态,不能证明有效订单数量没有超过可售量。
判断超卖应该同时计算库存余额、扣减流水、回补流水和有效订单数量,至少形成下面的核对关系:
理论库存余额
= 初始库存
有效扣减总量
+ 有效回补总量
其他明确的库存变更
有效订单占用量
<= 可售库存 + 已确认的补货或调拨量
缓存和消息队列可以削峰、降低数据库热点压力,但会引入新的状态边界。缓存预扣成功后,数据库写入可能失败;消息可能重复投递;消费者可能在处理订单后宕机;补偿消息也可能延迟。
我更倾向于把缓存和队列视为吞吐量优化组件,而不是最终正确性组件。最终库存、订单状态和库存流水仍然需要有一个可查询、可重放、可对账的权威数据来源。

很多库存争议,根本原因不是 SQL,而是字段语义不清。同一张表里的 stock 可能被不同团队理解为采购库存、仓库实物库存、可销售库存、已锁定库存或可用余额。
我建议至少区分以下概念:
| 字段或概念 | 含义 | 能否直接用于下单 |
|---|---|---|
| 实物库存 | 仓库或门店实际盘点得到的数量 | 不能直接使用,还要扣除损耗、冻结和不可售品 |
| 可售库存 | 当前允许系统对外销售的数量 | 通常是条件扣减的目标字段 |
| 锁定库存 | 已被订单暂时占用但尚未完成最终交易的数量 | 需要配合超时释放机制 |
| 可用余额 | 根据业务规则计算出的可继续分配数量 | 适合交易入口,但要明确计算口径 |
如果支付未完成的订单会占用库存,那么“扣减成功”未必表示库存已经永久消耗,而是从可售库存转移到了锁定库存。此时回补不是简单执行 stock = stock + 1,而是将订单占用状态和库存状态一起改变。
不是所有库存都需要同样严格的并发控制。商品限量销售、票务座位、药品现货和活动名额通常需要强约束,因为多分配一件就无法履约。普通促销赠品或可替代商品,可能允许短暂的最终一致。
判断时可以问四个问题:
如果答案指向“不能多分配、不能事后取消”,就应优先采用数据库条件更新或强事务控制,再考虑缓存和队列优化性能。不要先追求吞吐量,再补做正确性。
在同一个数据库中,如果库存表和订单表属于同一业务事务,可以考虑在一个事务里完成幂等记录、库存扣减和订单创建。关键不是事务越大越好,而是要让事务只包含必须原子完成的步骤。
一个较清晰的事务顺序可以是:
如果库存、订单和支付分布在不同服务,就不应假设一个本地事务能够覆盖全部操作。这时需要使用事务消息、可靠事件表、状态机或补偿任务,把“最终一定能对账”作为设计目标。
条件更新的正确性和性能都依赖商品定位条件。如果 product_id 没有唯一索引,数据库可能扫描大量记录,锁竞争范围和响应时间都会扩大。
EXPLAIN UPDATE product_stock SET stock = stock - 1 WHERE product_id = 10001 AND stock > 0;
检查执行计划时,我不会只看“有没有索引”,还会看预估扫描行数、实际执行时间、是否发生隐式类型转换,以及库存表是否存在重复商品记录。商品 ID 应该具备明确的唯一性,否则“更新了 1 行”也可能无法代表唯一商品被安全扣减。
应用层不能只返回一个布尔值。至少需要区分库存不足、商品不存在、重复请求、数据库异常和结果未知。
| 数据库或业务结果 | 接口处理 | 是否允许立即重试 |
|---|---|---|
| 受影响行数为 1 | 进入订单创建或库存占用成功流程 | 不应重复扣减 |
| 受影响行数为 0 且商品存在 | 返回库存不足或不可售 | 通常不建议立即重试 |
| 唯一键冲突 | 查询原请求状态并返回幂等结果 | 可查询,不应再次执行扣减 |
| 连接超时或提交结果未知 | 按请求号查询,不要盲目重放 | 先确认事务结果 |

下面是一套简化的 MySQL 表结构。它不是完整电商模型,但足以用于验证库存扣减、幂等和审计。
CREATE TABLE product_stock (
product_id BIGINT NOT NULL,
stock INT NOT NULL,
locked_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
PRIMARY KEY (product_id),
CONSTRAINT chk_stock_nonnegative CHECK (stock >= 0),
CONSTRAINT chk_locked_stock_nonnegative CHECK (locked_stock >= 0)
) ENGINE=InnoDB;
CREATE TABLE inventory_transaction (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
product_id BIGINT NOT NULL,
transaction_type VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
before_stock INT NOT NULL,
after_stock INT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_inventory_request_type (request_id, transaction_type),
KEY idx_inventory_product_time (product_id, created_at)
) ENGINE=InnoDB;
CREATE TABLE order_info (
order_id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
order_status VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (order_id),
UNIQUE KEY uk_order_request (request_id)
) ENGINE=InnoDB;这里有三个设计重点。第一,库存表的主键直接定位商品。第二,库存流水保存变更前后库存,使异常排查不必依赖应用日志。第三,订单和流水都保存 request_id,让重试、回调和异步消费可以回到同一个业务意图。
需要说明的是,MySQL 对 CHECK 约束的实际行为与版本有关,发布前应确认目标版本是否真正执行该约束。即使数据库能够阻止负库存,也不要把 CHECK 当成并发扣减方案,它只是最后一道数据边界。
在订单和库存位于同一个数据库的情况下,可以使用类似下面的事务逻辑。示例重点是顺序和判定,不绑定某种编程语言。
START TRANSACTION; -- 先根据 request_id 判断是否已经处理 SELECT order_id, order_status FROM order_info WHERE request_id = ? FOR UPDATE; -- 如果已经存在,则返回已有订单结果,不再执行扣减 -- 读取扣减前库存,用于库存流水 SELECT stock FROM product_stock WHERE product_id = ? FOR UPDATE; -- 原子扣减 UPDATE product_stock SET stock = stock - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = ? AND stock >= ?; -- 必须检查受影响行数 -- affected_rows = 1 才允许继续创建订单 INSERT INTO order_info ( order_id, request_id, product_id, quantity, order_status, created_at ) VALUES (?, ?, ?, ?, 'PENDING_PAYMENT', CURRENT_TIMESTAMP); INSERT INTO inventory_transaction ( request_id, order_id, product_id, transaction_type, quantity, before_stock, after_stock, created_at ) VALUES (?, ?, ?, 'DEDUCT', ?, ?, ?, CURRENT_TIMESTAMP); COMMIT;
实际实现时,需要避免在同一个事务里长时间调用支付、物流或其他远程服务。远程调用一旦放在数据库事务内部,锁持有时间会随着网络延迟变长,热点商品更容易产生锁等待和死锁。
示例中同时出现了锁定读取和条件更新,初学者可能会认为这两步重复。它们承担的作用并不完全相同。
如果库存流水不要求记录扣减前后值,也可以简化为直接条件 UPDATE,再根据明确的业务逻辑写入流水。但如果选择简化,就要确认流水记录能够通过其他方式可靠地获得变更结果,不能因为少了一次查询就失去审计能力。
库存流水有两种常见记录方式。增量流水只记录本次增加或减少多少,存储成本低,但排查时需要按时间顺序聚合。快照流水同时记录变更前和变更后库存,审计直观,但必须保证读取和写入处于正确的事务边界。
| 记录方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 增量流水 | 结构简单,写入字段少 | 需要重新聚合,定位异常较慢 | 流水量极大、已有可靠快照表 |
| 快照流水 | 能直接看到变更前后状态 | 写入逻辑更复杂,数据量略高 | 高价值库存、审计和事故复盘 |
| 事件流水 | 能表达扣减、回补、冻结、解冻等动作 | 需要状态聚合和事件幂等 | 跨服务、异步补偿和状态机 |
下面这些查询不能替代完整测试,但适合做日常巡检和事故初筛。
-- 检查负库存
SELECT product_id, stock, updated_at
FROM product_stock
WHERE stock < 0;
-- 检查同一请求是否产生重复扣减
SELECT request_id, COUNT(*) AS deduct_count
FROM inventory_transaction
WHERE transaction_type = 'DEDUCT'
GROUP BY request_id
HAVING COUNT(*) > 1;
-- 检查订单和库存扣减数量是否不一致
SELECT
o.product_id,
SUM(o.quantity) AS order_quantity,
SUM(t.quantity) AS deduct_quantity
FROM order_info o
LEFT JOIN inventory_transaction t
ON o.order_id = t.order_id
AND t.transaction_type = 'DEDUCT'
WHERE o.order_status IN ('PAID', 'FULFILLED')
GROUP BY o.product_id
HAVING SUM(o.quantity) <> COALESCE(SUM(t.quantity), 0);第三条查询在真实环境中还需要处理取消订单、拆单、合单、退货和多商品订单。它的价值不在于直接给出最终答案,而在于帮助我们发现“订单状态和库存事件无法解释”的数据行。

很多压测报告只记录响应时间和 HTTP 状态码,这对库存系统是不够的。接口返回 200,不代表扣减成功;接口超时,也不代表数据库事务一定回滚。
一次合格的库存并发测试,至少应同时记录:
只有把这些数据放在一起,才能判断一个方案是“正确但慢”“很快但错账”,还是“当前正确,继续扩容后会被热点行拖垮”。
我建议先从最小场景开始,而不是一上来就模拟复杂促销。将商品库存设为 1,同时发起 2 个独立请求,并人为增加应用层读取和写入之间的延迟。这样可以放大竞态窗口,快速观察普通方案与条件更新方案的差异。
-- 测试前 UPDATE product_stock SET stock = 1, version = 0 WHERE product_id = 10001; -- 并发请求的目标 -- request_id = req-A, quantity = 1 -- request_id = req-B, quantity = 1 -- 测试后检查 SELECT product_id, stock, version FROM product_stock WHERE product_id = 10001; SELECT request_id, order_id, transaction_type, quantity FROM inventory_transaction WHERE product_id = 10001 ORDER BY id;
预期结果不是“两个请求都返回成功”,而是最多一个请求完成有效扣减。另一个请求应该得到库存不足、幂等已处理或明确的异常状态。最终库存不能低于 0,成功扣减流水数量不能超过初始可售库存。
下面是一组用于方案评审的情景模拟,不是某个真实系统的线上统计。假设库存为 10,000 件,并发请求 20,000 次,每次请求购买 1 件,数据库为单主实例,商品库存集中在一行,未引入缓存和队列。
| 方案 | 预期有效扣减上限 | 主要观察点 | 潜在瓶颈 |
|---|---|---|---|
| 先查后改,UPDATE 无条件 | 可能超过 10,000 | 订单数、负库存、重复扣减 | 应用层竞态和错误成功判定 |
| 条件 UPDATE | 不超过 10,000 | 受影响行数、锁等待、失败率 | 热点库存行竞争 |
| 条件 UPDATE 加幂等键 | 不超过 10,000 | 重复请求是否只产生一次有效流水 | 唯一索引冲突和重试处理 |
| 缓存预扣加异步落库 | 取决于补偿是否可靠 | 缓存余额、消息积压、落库差异 | 最终一致性和故障恢复 |
这组模拟说明了一个重要事实:条件 UPDATE 的首要价值是建立正确性下限,而不是承诺最高吞吐量。当 20,000 个请求争抢同一库存行时,数据库需要排队并发写入;如果业务流量远高于单行处理能力,下一步应该优化热点分布,而不是继续堆叠更复杂的锁。
在 MySQL InnoDB 中,应结合慢查询日志、性能监控和事务状态观察锁等待。可以从下面几类信息入手:
数据库管理员不应只提供一个平均响应时间。库存热点通常呈现长尾特征,平均值可能是 20 毫秒,但 P99 已经达到数秒。对于下单链路,P99 的锁等待往往比平均值更能解释用户感受到的失败。

第二组测试应让同一个 request_id 连续发送多次,同时让不同 request_id 竞争同一个商品。预期是同一个请求无论发送 1 次还是 5 次,都只能产生一条有效扣减流水;不同请求则按照库存余量竞争。
SELECT request_id, transaction_type, COUNT(*) AS row_count FROM inventory_transaction WHERE product_id = 10001 GROUP BY request_id, transaction_type HAVING COUNT(*) > 1;
如果这条查询返回了重复扣减记录,说明系统的幂等机制没有真正落在数据库约束上,或者补偿逻辑使用了不同的请求标识。仅依赖应用代码中的“先查询是否存在,再插入”仍然可能出现并发竞态,关键幂等键应尽量交给唯一索引兜底。

如果商品库存集中在一个关系型数据库,单个商品的并发量可控,且订单和库存可以放在同一事务中,我建议优先采用条件 UPDATE、唯一幂等键和库存流水。这个方案结构清楚,故障排查成本低,也不需要先引入缓存和消息队列。
最低落地要求包括:
如果团队目前仍采用“查询库存后在代码里判断”,最优先的改动不是优化 SQL 格式,而是把库存条件移动到 UPDATE 中,并补上失败结果处理。
对于支付未完成前需要保留库存的业务,不建议把所有流程都叫“扣减”。更清晰的方式是把库存动作拆成冻结、解冻和最终消耗。
-- 冻结库存 UPDATE product_stock SET stock = stock - ?, locked_stock = locked_stock + ?, version = version + 1 WHERE product_id = ? AND stock >= ?; -- 支付成功,锁定库存转为已消耗 UPDATE product_stock SET locked_stock = locked_stock - ? WHERE product_id = ? AND locked_stock >= ?; -- 订单取消,释放锁定库存 UPDATE product_stock SET stock = stock + ?, locked_stock = locked_stock - ? WHERE product_id = ? AND locked_stock >= ?;
三态模型的价值在于,库存余额能够解释订单生命周期。支付失败不是“扣减失败”,而是“冻结成功后需要解冻”。如果所有动作都只修改一个 stock 字段,后续很难区分销售消耗和超时释放。
当单个商品成为绝对热点,所有请求竞争同一数据库行,条件更新仍可能成为瓶颈。这时可以在入口使用缓存原子扣减、库存令牌或消息队列削峰。
但我建议保留以下底线:
如果业务不允许“预扣后再取消”,就要谨慎评估缓存方案。缓存的高吞吐优势通常伴随更复杂的补偿链路,不能只对比接口耗时。
多仓库场景下,库存超卖可能发生在库存池之间,而不是单一商品行。比如总库存为 100 件,但华东仓和华南仓分别被不同服务独立扣减,汇总层没有统一协调,最终总量可能被多次分配。
这时需要先确定库存归属:
没有清晰的库存池边界时,单条 SQL 无法解决跨仓库的业务竞争。数据库管理员应推动业务方明确“哪张表、哪一个字段、哪一个服务是可售库存的权威来源”。
库存异常排查往往需要同时关联订单表、库存表、库存流水、支付记录和补偿记录。单靠数据库管理员临时写 SQL,容易形成一次性排查,无法持续发现问题。
以九数云这类数据分析工具为例,可以把数据库中的订单、库存流水和补偿任务同步到分析层,建立按商品、仓库、渠道和时间窗口切分的对账看板。这里的价值不是让分析工具参与扣库存,而是把以下异常变成可持续监控的指标:
如果使用九数云进行这类分析,仍要注意数据延迟和同步口径。分析层适合监控趋势、定位异常批次和生成日报,不适合替代交易数据库中的实时条件扣减。扣减发生在哪里,最终约束就应该在哪里生效;看板只能帮助我们更早发现偏差。

条件更新的最大优点是边界清楚:库存不足时数据库不更新,应用通过受影响行数获取结果。它适合大多数普通库存和中等并发场景,尤其适合团队希望先降低系统复杂度的情况。
它的短板也很明确:热点商品的库存行会产生写竞争,所有请求最终都要争用同一个数据行。如果商品访问高度集中,即使 SQL 逻辑正确,响应时间也会因为排队升高。
悲观锁适合库存变更和订单写入必须强一致、并且业务步骤较少的场景。它的逻辑容易理解,数据库也能清楚地控制并发顺序。
代价是锁持有时间会受事务内其他操作影响。把远程接口、复杂计算或大批量写入放在锁内,都会扩大等待范围。使用悲观锁时,我会重点检查索引、事务访问顺序、超时时间和死锁重试策略。
乐观锁可以通过版本号避免覆盖更新:
UPDATE product_stock SET stock = stock - ?, version = version + 1 WHERE product_id = ? AND stock >= ? AND version = ?;
如果受影响行数为 0,可能是库存不足,也可能是版本已经变化。应用需要重新读取并决定是否重试。对于库存极少、竞争极强的商品,频繁重试可能把数据库压力进一步放大,因此乐观锁并不一定优于条件更新。
缓存预扣适合大量请求在短时间内集中涌入,并且业务可以接受订单进入异步确认流程的场景。它能够把热点请求从数据库前移,降低数据库单行更新压力。
但缓存方案要额外承担数据恢复、主从切换、消息可靠投递、重复消费、超时释放和定期对账。对于没有稳定运维能力的团队,缓存预扣可能带来的故障复杂度高于它解决的性能问题。
队列能把瞬时请求转化为相对平滑的消费速度,但用户会面对“已提交、处理中、待确认”等异步状态。系统必须告诉用户哪些状态可以取消、哪些状态已经锁定库存、哪些状态最终会失败。
如果队列消费失败只依赖人工查看日志,库存系统就会形成新的黑盒。至少应具备消息唯一键、消费状态、失败原因、重试次数和死信处理。
| 方案 | 正确性控制 | 吞吐量表现 | 实现成本 | 优先考虑条件 |
|---|---|---|---|---|
| 条件更新 | 强 | 中等,受热点行影响 | 低 | 大多数普通商品 |
| 悲观锁事务 | 强 | 中等,取决于事务长度 | 中等 | 同库多步强一致流程 |
| 乐观锁 | 强,但需要处理冲突 | 冲突低时较好 | 中等 | 可重试且冲突率可控 |
| 缓存预扣 | 依赖补偿和对账 | 高 | 高 | 热点流量和短时峰值明显 |
| 消息队列 | 依赖幂等和状态机 | 高,延迟更明显 | 高 | 允许异步确认和削峰 |

库存扣减失败不等于所有异常都可以直接重试。库存不足是明确失败,通常不应立即重试;数据库连接超时则可能处于结果未知状态,直接重试可能造成重复扣减。
结果未知时,正确顺序通常是:
这也是为什么 request_id 不能只存在于应用日志中。它必须进入订单、流水、消息和补偿记录,成为跨系统定位同一业务意图的关联键。
很多系统在扣减时考虑了幂等,却在订单取消和支付失败回补时忽略了重复通知。支付服务可能重复发送失败回调,定时任务也可能再次扫描到同一个订单。如果每次都执行 stock = stock + quantity,就会出现库存虚增。
回补流水应使用独立的业务动作标识,例如 order_id + RELEASE。数据库唯一索引保证同一订单的同一类释放动作只能生效一次。
INSERT INTO inventory_transaction (
request_id, order_id, product_id, transaction_type,
quantity, before_stock, after_stock, created_at
) VALUES (?, ?, ?, 'RELEASE', ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
id = id;上面的写法只是展示幂等思路。不同数据库对重复键处理语法不同,正式实现时应结合事务隔离、返回值语义和异常处理进行验证,不能直接复制到所有数据库环境。
对账不是简单比较两张表的总数。有效的对账需要先统一时间窗口和状态口径,例如只统计已支付订单,还是包含待支付冻结订单;是否扣除退款;是否包含仓库盘点调整。
建议建立三类余额:
三者不一定在每个时刻完全相等,但任何差异都应该有明确定义。例如异步消息尚未消费造成的短暂差异必须有 SLA;如果超过 SLA 仍未收敛,就应该自动告警。
| 异常 | 建议级别 | 处置动作 |
|---|---|---|
| 库存出现负数 | 高 | 立即停止相关商品销售入口,保留现场数据并核查流水 |
| 订单成功但无扣减流水 | 高 | 冻结自动履约,确认是否需要补扣或人工处理 |
| 重复 request_id 出现多次有效扣减 | 高 | 停止重复消费,计算受影响订单并执行幂等修复 |
| 分析层与交易库短暂差异 | 中 | 检查同步延迟,超过阈值再升级 |
| 锁等待持续升高 | 中高 | 检查热点商品、事务长度、索引和重试放大 |

尤其要检查 quantity 的输入边界。很多示例默认每次只买 1 件,但真实接口可能允许一次购买多件。如果 SQL 只写 stock > 0,而没有写 stock >= quantity,就可能在批量购买时出现库存被扣成负数或扣减数量不符合预期。
UPDATE product_stock SET stock = stock - ?, version = version + 1 WHERE product_id = ? AND stock >= ? AND ? > 0;
死锁重试尤其需要谨慎。数据库层面的死锁重试可以重新执行事务,但业务层不能重新生成一个新的 request_id,否则数据库无法识别这是同一笔业务重放。
如果团队使用九数云这类分析工具做管理看板,建议把“实时交易状态”和“离线分析状态”明确分层。实时系统负责阻止错误发生,分析系统负责发现趋势、定位批次、比较渠道和追踪补偿结果。
没有故障演练的库存方案,往往只在正常路径上成立。至少应该模拟以下情况:
每次演练都应记录最终库存、订单状态、流水数量、补偿耗时和人工介入次数。只有当系统能够在故障后自动收敛,方案才算具备真正的生产可用性。

发现负库存后,不建议立即执行一条“加回去”的 SQL。第一步应冻结相关商品的销售入口,保留库存表、订单表、流水表、应用日志和消息记录,避免后续操作覆盖事故现场。
接着按照时间顺序重建库存变化:
修复 SQL 应该来自确认后的差异,不应该直接根据当前负数猜一个回补数量。否则可能把数据库修正为非负,却让库存流水和订单关系更加混乱。
如果库存字段没有负数,而有效订单数量超过可售量,优先检查是否存在“预占资格”和“正式扣减”两套流程。常见问题是活动服务先发放购买资格,库存服务随后异步扣减;资格发放没有使用同一幂等键,导致两个订单都认为自己合法。
这类问题不能只修复库存表,还要核查订单创建入口、活动名额表、优惠券核销表和消息消费记录。最终需要确认“谁拥有分配资格”,并让这个资格本身具备唯一性。
如果库存和订单在同一个数据库内,优先考虑让它们处在同一事务中,订单插入失败时库存扣减一起回滚。这样能减少补偿数量,但事务必须足够短。
如果两者跨服务,就要接受最终一致性。此时应记录库存扣减事件,订单服务消费后创建订单;如果订单创建失败,由补偿任务产生释放动作。补偿动作必须有状态、有重试次数、有最终告警,不能只依赖定时脚本悄悄执行。
锁等待高不等于数据库方案错误。先检查是否所有商品都集中更新同一行、是否存在无索引扫描、事务是否包含远程调用、是否因为客户端超时而重复发起请求。
只有在确认 SQL、索引、事务范围和重试策略都合理后,仍然无法满足峰值吞吐,才应该评估库存分段、令牌桶、缓存预扣或队列削峰。过早引入这些组件,可能把一个可定位的锁竞争问题变成多个难以对账的分布式状态问题。
数据库对账只能证明系统内部的订单、库存和流水是否一致,不能证明仓库里真的有这么多货。仓库盘点、损耗、错发、退货未入库都会让实物库存与系统库存产生差异。
因此,完整库存治理应分为两层:系统内对账负责发现交易链路异常,仓储盘点负责确认实物差异。两者都需要记录调整原因和责任边界,不能用人工修改 stock 字段代替库存事件。
如果现在只能做一轮改造,我建议按照以下顺序实施:
这套方案不一定让系统拥有最高吞吐量,但能先建立清晰的正确性底线。对于很多库存系统来说,先把“知道发生了什么”做好,比盲目追求更高 QPS 更重要。
当系统出现性能瓶颈时,建议按以下顺序判断:
如果前四项还没有解决,直接上缓存或队列通常只是把问题往后推。高并发架构优化应该建立在正确性已经被测试和监控证明的基础上。
一个库存方案是否可靠,我不会只看它能否在压测中跑出漂亮的吞吐量,而会看以下结果能否被复核:
我的最终判断是:库存超卖不是一个“有没有加锁”的单点问题,而是一个需要被数据库、订单状态、幂等机制和对账系统共同证明的数据问题。条件更新是可靠起点,受影响行数是实时判定,库存流水是事后证据,幂等和补偿是故障恢复,对账则是长期验证。
下一步可以先选一个真实商品,记录初始库存,构造“库存为 1、并发请求为 2”的最小测试,再逐步加入重复请求、订单失败、支付回调重复和数据库超时场景。测试结束后不要只看接口响应,而要核对库存表、订单表、流水表和补偿记录。能把这四张表对上的方案,才是真正降低了超卖风险的方案。

我在复现库存为 1、两个请求同时购买的场景时发现,两个请求都可能先读到库存为 1,然后各自通过应用层判断,最后同时执行扣减。以前我以为给查询语句加事务就足够了,但实际更困惑:到底应该依赖锁,还是把库存条件直接写进 UPDATE?
超卖的根本原因,不是数据库不会计算减法,而是“库存判断”和“库存扣减”被拆成了两个存在时间间隔的动作。请求 A 读取库存为 1 后,请求 B 也读取到 1;如果应用层都判断为“库存充足”,两个请求就可能继续执行后续扣减。
更稳妥的做法是把可扣减条件放进同一条 UPDATE:
UPDATE product_stock SET stock = stock - 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = ?AND stock > 0;这条语句的关键不在于“减 1”,而在于数据库会在执行更新时再次验证 stock > 0。对于库存为 1 的商品,两个并发请求中通常只有一个请求能让条件成立并完成扣减,另一个请求最终得到 0 行受影响。
场景最终库存成功扣减数风险判断 先查再改,未做并发保护可能为 0,也可能出现订单多于库存可能大于 1存在竞态窗口 带 stock > 0 的条件更新不低于 0不超过初始库存适合基础扣减 条件更新但不检查受影响行数通常不变负无法准确判断接口可能误报成功 我在一个可复现的最小测试中将初始库存设为 1,并发发送 2 个扣减请求。
正确的验收标准不是“接口没有报错”,而是成功扣减数等于 1、失败数等于 1、最终库存等于 0。应用必须检查数据库返回的受影响行数:为 1 才能继续创建扣减成功的业务记录,为 0 则应返回库存不足或进入重试判断。
需要注意,条件更新主要解决“同一库存被并发扣成负数”的问题,并不自动解决订单重复、支付失败、库存回补和跨服务一致性。它是库存扣减的底线控制,不是完整交易系统的全部方案。
我遇到过扣库存 SQL 明明返回成功,但订单服务随后超时的情况。此时数据库里的库存已经少了 1,用户却没有拿到订单,我想知道什么时候应该使用同库事务,什么时候必须接受最终一致性?
判断是否使用同一个事务,首先看库存表和订单表是否位于同一个数据库、同一个事务边界内。如果两张表在同库,并且创建订单与扣减库存必须同时成功,那么优先考虑短事务:先写订单,再执行条件扣减,或先扣库存再写订单,任何一步失败都回滚。一个常见的同库流程是: START TRANSACTION;
INSERT INTO orders(order_id, user_id, status) VALUES (?, ?, 'PENDING');UPDATE product_stock SET stock = stock - 1 WHERE product_id = ?AND stock > 0;— 检查受影响行数是否为 1 — 成功后提交,失败则回滚 COMMIT;这里最容易踩的坑,是只判断 INSERT 是否成功,却没有检查 UPDATE 的受影响行数。订单写入成功并不等于库存扣减成功;如果库存不足仍然提交,就会出现“有订单、无库存扣减”的脏业务状态。
同库事务的优势是边界清晰,但事务不能无限扩大。不要把调用支付接口、发送外部网络请求、等待人工审核等操作放在数据库事务里,否则锁持有时间会随外部延迟增长,热点商品可能出现锁等待和死锁。如果订单服务和库存服务属于不同数据库,或者库存扣减需要经过缓存、消息队列,就不能假设一个本地事务能覆盖所有操作。
这时应使用订单状态机和补偿机制,例如订单先进入 PENDING,库存扣减成功后变为 RESERVED,支付完成后变为 CONFIRMED,超时或支付失败则执行幂等回补。
架构情况优先方案主要风险 订单与库存同库短事务 + 条件更新事务过长、锁等待 跨服务但吞吐要求一般状态机 + 可靠消息 + 补偿消息重复、状态延迟 秒杀或热点商品预扣库存 + 异步落单 + 对账缓存与数据库不一致 我的判断是:如果业务要求下单与库存必须强一致,且数据确实在同一个事务资源内,就使用短事务;
如果已经跨服务,就不要强行伪装成强一致,而要把“最终一定能对账、失败一定能补偿”设计成显式能力。
我曾经把超时请求直接重试,结果发现第一次请求可能已经在数据库中成功,只是响应没有返回到客户端。现在我不确定:如果每次都使用 stock > 0 的条件更新,库存不会变负,那重复请求是否仍然会造成业务错误?
会。stock > 0 只能限制库存不能被扣成负数,不能判断“这是不是同一个业务请求”。例如用户购买 1 件商品时,第一次请求已经扣减成功,但客户端因为网络超时再次提交;第二次请求仍然是合法的库存扣减,数据库无法仅凭商品 ID 判断它是重试还是新购买。
正确做法是给每次业务操作分配唯一 request_id 或 order_id,并在库存流水表上建立唯一约束:
CREATE UNIQUE INDEX uk_inventory_request ON inventory_transaction(request_id);处理流程应先确认请求是否已经成功,再执行扣减。可以将“插入幂等记录”和“库存更新”放进同一个事务:如果 request_id 已存在,直接返回第一次操作的结果;如果不存在,才执行条件扣减。这样重试请求不会再次消耗库存。
重试场景没有幂等有 request_id 幂等 第一次扣减成功,响应超时可能再次扣减返回原操作结果 第一次扣减失败,客户端重试可能产生重复业务记录可重新判断库存 消息重复投递重复扣减或重复回补重复消息被识别 取消订单重复通知库存被多次回补回补操作保持幂等 库存流水不应只保存变更数量,还建议记录 request_id、order_id、product_id、operation_type、before_stock、after_stock、operator 和 created_at。
before_stock 与 after_stock 能帮助排查“库存少了几件”,request_id 则能回答“是哪一次业务请求导致的”。回补库存也必须使用同样的幂等思路。取消订单消息可能重复到达,如果每次都执行 stock = stock + 1,就会产生虚增库存。
可以为“扣减”和“回补”分别建立唯一业务键,例如 order_id + operation_type,确保同一订单的 RELEASE 操作最多生效一次。因此,数据库管理员验收库存方案时,不应只压测库存是否出现负数,还要验证:重复提交 100 次是否只产生一条有效扣减流水;
消息重复消费时是否只回补一次;客户端超时重试后,订单、库存和流水是否仍然能对应起来。
我以前做压测时只关注接口返回的成功率和平均响应时间,测试结束后看到接口没有报错,就认为库存方案没问题。后来我意识到,真正重要的可能是成功订单数、库存流水数、锁等待和最终库存之间能不能对上,应该怎样设计验证?
只看接口成功率远远不够。库存系统的核心验收指标是数据不变量:成功扣减次数不能超过初始可售库存,最终库存不能小于 0,每个幂等请求最多产生一条有效扣减流水,订单状态必须能在库存流水中找到对应记录。可以先做一个最小并发测试,商品初始库存设为 100,并发发送 1,000 个唯一请求。
测试结束后至少采集以下结果: 指标期望结果异常信号 成功扣减数不超过 100大于 100 表示超卖 最终库存等于 100 – 成功扣减数不相等表示可能漏扣或重复扣 有效扣减流水等于成功扣减数数量不一致表示审计链断裂 重复 request_id为 0大于 0 表示幂等失效 死锁与锁等待在可接受范围内持续升高表示热点竞争 数据库侧可以先检查负库存: SELECT product_id, stock FROM product_stock WHERE stock 再检查重复业务请求: SELECT request_id, COUNT(*) AS cnt FROM inventory_transaction GROUP BY request_id HAVING COUNT(*) > 1;
还要做订单与流水对账。例如统计已确认订单的商品数量,再与有效扣减流水数量比较。如果两者不一致,即使库存字段看起来正常,也可能存在“订单创建成功但库存未扣减”或“库存扣减成功但订单失败”的隐性异常。在我的测试判断中,响应时间只是容量指标,不是正确性指标。
一个接口即使平均耗时很低,只要在并发下出现成功订单数超过库存,方案就不能上线;反过来,短时间锁等待升高也不一定代表逻辑错误,可能只是热点行竞争,需要结合吞吐、P95 延迟、死锁次数和补偿积压一起判断。当同一商品的更新请求长期集中在一行,条件更新虽然仍能保证基本正确性,却可能成为吞吐瓶颈。
此时再评估缓存预扣、消息队列、库存分段或令牌化方案,但无论采用哪一种,都必须保留数据库流水、幂等和最终对账,否则只是把超卖风险从数据库表转移到了缓存或消息链路。


读者评论
文章把“库存扣减成功”和“订单交易成功”区分开来,这一点很实用。尤其是受影响行数、订单状态和库存流水结合后,排查重复扣减会更有依据。
条件更新比先查询再扣减更容易理解和审计,但文中也说明了它不能单独解决幂等、支付失败和库存回补问题,边界讲得比较客观。
对受影响行数可能受数据库驱动配置影响的提醒很有价值。实际落地时,确实不能只看接口返回结果,还应在目标版本和真实驱动环境中验证。
文章没有把缓存、消息队列或悲观锁当成万能方案,而是强调最终数据要可追溯、可对账。对设计库存系统的团队来说,这种数据可靠性视角较有参考意义。