如何避免 eCommerce 库存超卖的 race conditions?
结论
不要把“检查库存”和“扣减库存”拆成两个独立操作,也不要锁定整张库存表。应通过一条带条件的原子更新语句,让数据库直接完成库存扣减:
UPDATE inventory
SET available = available - :quantity
WHERE product_id = :product_id
AND available >= :quantity;
受影响行数为 1,表示抢购成功;受影响行数为 0,表示库存不足。即使两个请求同时执行,数据库也只会允许其中一个成功,不会发生超卖。
如果支付可能持续数分钟,则应使用“限时库存预留”机制。下单时,以原子方式将库存从 available 转入 reserved;支付成功后确认扣减,支付失败或超时后释放预留库存。
为什么流程开始时检查库存不可靠
下面的写法存在典型的 check-then-act race condition:
读取库存
如果库存足够:
处理 checkout
扣减库存
假设库存为 1,请求 A 和请求 B 可能按以下顺序执行:
- A 读取库存,得到
1。 - B 读取库存,也得到
1。 - A 完成购买并扣减库存。
- B 根据之前读取的结果继续购买。
问题不在于少检查了一次,而在于检查和实际扣减之间存在时间窗口。增加 sold_out 标记也解决不了这个问题,因为读取标记和写入标记仍是两个可以并发执行的操作。
方案一:使用条件更新原子扣减库存
如果流程不需要长时间等待支付,最简单可靠的做法是在短事务中直接扣减库存。
BEGIN;
UPDATE inventory
SET available = available - 1
WHERE product_id = 123
AND available >= 1;
-- 应用程序检查受影响行数:
-- 1:扣减成功
-- 0:库存不足
COMMIT;
应用层伪代码如下:
const result = await db.execute(
`UPDATE inventory
SET available = available - ?
WHERE product_id = ?
AND available >= ?`,
[quantity, productId, quantity]
);
if (result.affectedRows !== 1) {
throw new OutOfStockError();
}
这里的重点是,available >= quantity 条件和库存扣减位于同一条 SQL 中。数据库会对相关记录实施必要的并发控制,不会让两个事务都根据同一个旧库存值完成扣减。
不要先执行:
SELECT available FROM inventory WHERE product_id = 123;
再根据查询结果执行无条件更新:
UPDATE inventory
SET available = available - 1
WHERE product_id = 123;
除非使用了正确的事务锁,否则这种写法仍可能超卖,甚至把库存减成负数。
方案二:使用行级锁处理复杂库存逻辑
如果扣减库存之前必须执行多项依赖当前库存的数据库操作,可以在事务中使用行级锁。
BEGIN;
SELECT available
FROM inventory
WHERE product_id = 123
FOR UPDATE;
-- 在应用程序中判断库存是否足够。
UPDATE inventory
SET available = available - 1
WHERE product_id = 123;
INSERT INTO orders (order_id, product_id, quantity, status)
VALUES (:order_id, 123, 1, 'confirmed');
COMMIT;
SELECT ... FOR UPDATE 会锁定选中的库存记录。其他事务如果也要锁定同一商品,通常必须等待当前事务提交或回滚。
应优先使用行级锁,不要使用 table locking。锁定整张表会导致不同商品之间互相阻塞,明显降低吞吐量和扩展能力。
事务应尽可能短。持有数据库锁时,不要等待用户填写地址、跳转到支付页面或完成第三方支付。这些操作可能持续数分钟,容易造成锁等待、连接耗尽和死锁。
支付耗时较长时:建立限时库存预留
现实中的 checkout 通常包含第三方支付,因此数据库事务不能一直保持打开。这时应把“占用库存”和“支付成功”分成两个业务阶段。
库存表可以保存:
CREATE TABLE inventory (
product_id BIGINT PRIMARY KEY,
available INTEGER NOT NULL CHECK (available >= 0),
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0)
);
预留记录用于保存归属和过期时间:
CREATE TABLE inventory_reservations (
reservation_id VARCHAR(64) PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status VARCHAR(20) NOT NULL,
expires_at TIMESTAMP NOT NULL
);
创建预留时,应在一个短事务内以原子方式扣减可售库存,并写入预留记录:
BEGIN;
UPDATE inventory
SET available = available - :quantity,
reserved = reserved + :quantity
WHERE product_id = :product_id
AND available >= :quantity;
-- 仅当受影响行数为 1 时继续执行。
INSERT INTO inventory_reservations (
reservation_id,
order_id,
product_id,
quantity,
status,
expires_at
)
VALUES (
:reservation_id,
:order_id,
:product_id,
:quantity,
'pending',
:expires_at
);
COMMIT;
如果库存更新影响了 0 行,应回滚事务并返回库存不足,不能继续创建支付请求。
支付成功后确认预留:
BEGIN;
UPDATE inventory_reservations
SET status = 'confirmed'
WHERE reservation_id = :reservation_id
AND status = 'pending';
-- 只有状态确实从 pending 变为 confirmed 时才继续。
UPDATE inventory
SET reserved = reserved - :quantity
WHERE product_id = :product_id
AND reserved >= :quantity;
COMMIT;
如果支付失败、用户取消或预留超时,则释放库存:
BEGIN;
UPDATE inventory_reservations
SET status = 'expired'
WHERE reservation_id = :reservation_id
AND status = 'pending';
-- 只有成功改变预留状态时才返还库存。
UPDATE inventory
SET available = available + :quantity,
reserved = reserved - :quantity
WHERE product_id = :product_id
AND reserved >= :quantity;
COMMIT;
还需要由后台任务定期处理已经过期但状态仍为 pending 的记录。处理过程同样要使用事务、条件更新或行级锁,以免后台任务和支付回调同时操作同一条预留记录。
支付回调必须具备幂等性
支付平台可能重复发送成功通知,客户端也可能重复提交请求。如果每次收到通知都扣减库存,数据仍会出错。
应为下单请求、预留记录和支付事件设置唯一标识,例如:
CREATE UNIQUE INDEX ux_orders_idempotency_key
ON orders (idempotency_key);
CREATE UNIQUE INDEX ux_payment_events_provider_event
ON payment_events (provider, event_id);
处理支付通知时,应先记录支付平台的事件 ID。如果触发唯一约束冲突,说明该通知已经处理过。此时应直接返回成功,不要再次确认订单或修改库存。
释放预留时也必须附带状态条件:
UPDATE inventory_reservations
SET status = 'expired'
WHERE reservation_id = :reservation_id
AND status = 'pending';
只有首次执行会影响一行。后续重复执行时,受影响行数为 0,因此不会重复返还库存。
多件商品订单的处理
如果一个订单包含多种商品,应在同一事务中处理全部库存。为了降低死锁概率,可以始终按照固定顺序锁定或更新商品,例如按 product_id 升序处理。
BEGIN;
SELECT product_id, available
FROM inventory
WHERE product_id IN (101, 205, 309)
ORDER BY product_id
FOR UPDATE;
-- 检查所有商品库存。
-- 任何一种商品不足时,回滚整个事务。
UPDATE inventory
SET available = available - 2
WHERE product_id = 101
AND available >= 2;
UPDATE inventory
SET available = available - 1
WHERE product_id = 205
AND available >= 1;
COMMIT;
必须检查每条更新语句的受影响行数。只要有一条失败,就应回滚整个事务,避免订单只预留了部分商品。
需要特别注意的事项
- 库存的最终判断必须由数据库完成,不能依赖页面显示的库存数字或应用程序缓存。
- 应为
product_id建立主键或索引,避免更新库存时扫描和锁定过多记录。 - 数据库约束应禁止
available、reserved等字段出现负数,作为最后一道防线。 - 不要在数据库事务中调用支付接口、发送邮件或等待用户操作。
- 对死锁、事务冲突和短暂的连接失败设置有限次数的重试,并确保每次重试使用相同的幂等键。
- 如果使用读写分离,库存判断和扣减都应访问主库,因为从库可能存在复制延迟。
- Redis 分布式锁可以辅助协调,但不能替代数据库中的条件更新、唯一约束和事务。
- 支付成功但库存确认失败、数据库提交成功但响应丢失等情况,无法仅靠普通事务彻底消除。系统需要通过幂等处理、订单状态机和定期对账进行恢复。
- “加入购物车”通常不应永久占用库存。如果业务确实需要保留库存,应设置明确的过期时间,并向用户显示剩余支付时间。
对于大多数 eCommerce 系统,基础设计可以采用原子条件更新,防止库存降到零以下;用短事务保持订单和库存记录一致;用带过期时间的 reservation 处理尚未完成的 checkout;再通过唯一约束和幂等状态转换处理重复请求与支付回调。这样既能防止超卖,也能让未完成交易占用的库存自动恢复为可售状态。
备注:内容仅供参考。
