PAYATHON 2026

如何避免 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 可能按以下顺序执行:

  1. A 读取库存,得到 1。
  2. B 读取库存,也得到 1。
  3. A 完成购买并扣减库存。
  4. 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;再通过唯一约束和幂等状态转换处理重复请求与支付回调。这样既能防止超卖,也能让未完成交易占用的库存自动恢复为可售状态。

备注:内容仅供参考。