PAYATHON 2026

E-commerce Application design:如何处理库存并发写入

支付阿杰

结论

不要在持有数据库行锁的事务里调用支付网关。

通常应把“购买”拆成两个阶段处理:

  1. 在一个很短的数据库事务中,通过条件更新或库存预占,原子地取得库存。
  2. 提交事务、释放锁,然后调用支付网关。
  3. 根据支付结果确认订单,或者释放预占的库存。
  4. 遇到支付超时、重复通知或进程崩溃时,依靠订单状态机、幂等处理和定时对账恢复,不要依赖一个跨越支付请求的数据库事务。

页面显示“有货”只是当时的库存快照,不代表用户结账时一定能买到。防止超卖的真正边界应放在数据库的原子写入上。

为什么原方案有问题

下面这种事务不适合覆盖完整的下单和支付流程:

BEGIN;

SELECT *
FROM product
WHERE id = 1
  AND status = 'AVAILABLE'
FOR UPDATE;

UPDATE product
SET quantity = 0,
    status = 'SOLD_OUT'
WHERE id = 1;

-- 调用支付网关

COMMIT;

它主要有三个问题。

事务持续时间过长

支付调用可能持续数秒,甚至超时。在此期间,数据库事务会一直持有行锁,可能导致:

  • 其他购买请求排队;
  • 锁等待超时;
  • 数据库连接被长期占用;
  • 热门商品的吞吐量急剧下降;
  • 死锁和故障恢复的成本上升。

数据库事务应尽量只包含数据库操作,一般不应跨越外部网络请求。

数据库无法回滚外部支付

支付请求超时,并不表示支付一定失败。例如:

  1. 支付网关已经扣款;
  2. 响应在传输过程中丢失;
  3. 应用收到 timeout;
  4. 应用回滚数据库事务。

结果就是用户已经付款,但库存和订单都被回滚了。数据库事务只能回滚本地数据库中的操作,无法撤销已经发生的外部支付。

其他用户看到有货并不等于会超卖

在常见的 MVCC 数据库中,其他事务可能继续读到提交前的旧库存,这是正常现象。

防止超卖不要求所有用户始终看到绝对实时的库存,只要求最终扣减库存的操作是原子的。第二个用户即使已经进入支付页面,也必须在提交订单时重新竞争库存。如果竞争失败,返回“库存不足”即可。

最简单的方案:原子条件更新

如果业务允许在创建订单时直接扣减库存,可以跳过 SELECT ... FOR UPDATE,改用一条带条件的 UPDATE:

BEGIN;

UPDATE product
SET quantity = quantity - 1,
    status = CASE
        WHEN quantity - 1 = 0 THEN 'SOLD_OUT'
        ELSE status
    END
WHERE id = :product_id
  AND quantity >= 1;

-- 检查 affected rows

COMMIT;

应用必须检查受影响的行数:

  • affected rows = 1:成功取得一件库存;
  • affected rows = 0:商品不存在或库存不足。

关键条件是:

quantity = quantity - 1
WHERE quantity >= 1

数据库会把条件检查和库存扣减作为同一条写操作执行。两个请求同时更新同一行时,数据库会将修改串行化。第一个请求扣减库存后,第二个请求会根据最新值重新判断 quantity >= 1。如果库存已经为零,第二次更新就不会命中。

还可以增加数据库约束,作为最后一道防线:

ALTER TABLE product
ADD CONSTRAINT chk_product_quantity_nonnegative
CHECK (quantity >= 0);

实际项目通常在 SKU 维度管理库存,而不是在商品 SPU 维度管理。例如:

sku_inventory (
    sku_id          BIGINT PRIMARY KEY,
    available_qty   INTEGER NOT NULL,
    reserved_qty    INTEGER NOT NULL,
    version         BIGINT NOT NULL,
    CHECK (available_qty >= 0),
    CHECK (reserved_qty >= 0)
)

更常见的方案:预占库存

支付可能失败或超时,所以更完整的做法通常不是立即把商品永久标记为已售出,而是先预占库存。

订单状态可以设计为:

PENDING_PAYMENT -> PAID
PENDING_PAYMENT -> CANCELLED
PENDING_PAYMENT -> EXPIRED

库存预占状态可以设计为:

RESERVED -> CONFIRMED
RESERVED -> RELEASED

第一步:在短事务内创建订单并预占库存

BEGIN;

UPDATE sku_inventory
SET available_qty = available_qty - :buy_qty,
    reserved_qty = reserved_qty + :buy_qty
WHERE sku_id = :sku_id
  AND available_qty >= :buy_qty;

-- affected rows 必须为 1,否则库存不足

INSERT INTO orders (
    id,
    user_id,
    status,
    amount,
    expires_at,
    created_at
) VALUES (
    :order_id,
    :user_id,
    'PENDING_PAYMENT',
    :amount,
    :expires_at,
    CURRENT_TIMESTAMP
);

INSERT INTO inventory_reservation (
    order_id,
    sku_id,
    quantity,
    status,
    expires_at
) VALUES (
    :order_id,
    :sku_id,
    :buy_qty,
    'RESERVED',
    :expires_at
);

COMMIT;

库存预占提交后,其他购买请求就能看到减少后的可售库存。事务随即结束,不再持有数据库锁。

订单、预占记录和库存变化应放在同一个本地事务中。这样可以避免“库存扣了但订单没创建”或“订单创建了但库存没扣”的中间状态。

第二步:在事务外调用支付网关

reserve inventory
        |
        v
commit database transaction
        |
        v
call payment gateway

调用支付网关时,应传入稳定且唯一的幂等键,例如 order_id 或单独生成的 payment_request_id。重试同一笔支付时,必须继续使用相同的幂等键,避免重复扣款。

第三步:根据支付结果确认或释放库存

支付成功后:

BEGIN;

UPDATE orders
SET status = 'PAID',
    paid_at = CURRENT_TIMESTAMP
WHERE id = :order_id
  AND status = 'PENDING_PAYMENT';

UPDATE inventory_reservation
SET status = 'CONFIRMED'
WHERE order_id = :order_id
  AND status = 'RESERVED';

UPDATE sku_inventory
SET reserved_qty = reserved_qty - :buy_qty
WHERE sku_id = :sku_id
  AND reserved_qty >= :buy_qty;

COMMIT;

明确支付失败,或者订单到期并确认没有支付成功后,再释放库存:

BEGIN;

UPDATE inventory_reservation
SET status = 'RELEASED'
WHERE order_id = :order_id
  AND status = 'RESERVED';

-- 只有上一条语句实际更新成功时,才执行库存返还
UPDATE sku_inventory
SET available_qty = available_qty + :buy_qty,
    reserved_qty = reserved_qty - :buy_qty
WHERE sku_id = :sku_id
  AND reserved_qty >= :buy_qty;

UPDATE orders
SET status = 'CANCELLED'
WHERE id = :order_id
  AND status = 'PENDING_PAYMENT';

COMMIT;

实际实现时,应严格检查每次状态更新的受影响行数。只有成功将预占记录从 RESERVED 改为 RELEASED 的执行者,才有权返还库存。否则,重复执行的任务可能多次增加库存。

需要更稳妥时,可以先锁定预占记录:

SELECT status, sku_id, quantity
FROM inventory_reservation
WHERE order_id = :order_id
FOR UPDATE;

PAYMENT TIMEOUT 不能直接当作支付失败

支付超时表示“结果未知”,不表示“支付失败”。订单状态最好能够表达这种不确定性,例如:

PENDING_PAYMENT
PAYMENT_PROCESSING
PAID
PAYMENT_FAILED
CANCELLED
EXPIRED
REFUND_PENDING
REFUNDED

发生超时时,可以这样处理:

  1. 让订单继续保持 PENDING_PAYMENT,或者改为 PAYMENT_PROCESSING。
  2. 使用同一个幂等键向支付网关查询或重试。
  3. 接收支付网关发送的异步 webhook。
  4. 定时扫描长时间未完成的订单,主动查询支付状态。
  5. 只有确认未支付后,才释放库存。
  6. 如果库存已经释放,之后却收到支付成功通知,则按业务规则重新占用库存。无法重新占用时,自动退款或转交人工处理。

支付通知也必须支持幂等处理。例如:

BEGIN;

SELECT status
FROM orders
WHERE id = :order_id
FOR UPDATE;

-- 已经是 PAID 时直接返回成功,不重复确认库存
-- 仅允许 PENDING_PAYMENT / PAYMENT_PROCESSING -> PAID

UPDATE orders
SET status = 'PAID',
    paid_at = CURRENT_TIMESTAMP,
    payment_transaction_id = :transaction_id
WHERE id = :order_id
  AND status IN ('PENDING_PAYMENT', 'PAYMENT_PROCESSING');

COMMIT;

还应为支付网关交易号建立唯一约束:

CREATE UNIQUE INDEX uk_payment_transaction
ON orders(payment_transaction_id);

具体字段如何设计,取决于所用数据库是否允许多个 NULL 值,以及支付模型是一单一支付还是一单多次支付。如果允许一单多次支付,通常应单独建立 payment_attempt 表。

SELECT ... FOR UPDATE 是否可以使用

可以,但必须把锁限制在短事务内。

例如,需要读取价格、库存和销售状态,并执行多条相关写入时,可以这样处理:

BEGIN;

SELECT available_qty
FROM sku_inventory
WHERE sku_id = :sku_id
FOR UPDATE;

-- 应用检查 available_qty >= :buy_qty

UPDATE sku_inventory
SET available_qty = available_qty - :buy_qty,
    reserved_qty = reserved_qty + :buy_qty
WHERE sku_id = :sku_id;

INSERT INTO inventory_reservation (...);

COMMIT;

第二个并发事务会等待第一个事务提交,然后读取最新库存。如果只剩一件库存,第一个事务会成功,第二个事务恢复执行后会发现库存不足并退出。

行锁会让同一 SKU 的库存更新串行执行,但这不一定是设计缺陷。库存本来就是需要一致性保护的共享计数器。问题在于锁会持有多久:

  • 持续数毫秒的数据库事务通常可以接受;
  • 跨越支付网关调用、持续数秒或数十秒的事务通常不可接受。

不同 SKU 位于不同的行上,一般不会互相阻塞。竞争通常只会集中在同一个热门 SKU 上。

乐观锁如何实现

乐观锁通常通过 version 字段实现:

SELECT available_qty, version
FROM sku_inventory
WHERE sku_id = :sku_id;

假设读取到:

available_qty = 1
version = 17

更新时,同时校验旧版本:

UPDATE sku_inventory
SET available_qty = available_qty - 1,
    reserved_qty = reserved_qty + 1,
    version = version + 1
WHERE sku_id = :sku_id
  AND version = 17
  AND available_qty >= 1;

如果两个请求都读到 version = 17:

  • 第一个请求更新成功,把版本改为 18;
  • 第二个请求执行时,WHERE version = 17 已经不成立,受影响行数为零;
  • 第二个请求可以重新读取并重试,也可以直接返回库存不足。

对应的伪代码如下:

for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
    Inventory inventory = inventoryRepository.findBySkuId(skuId);

    if (inventory.getAvailableQty() < buyQty) {
        throw new OutOfStockException();
    }

    int affected = inventoryRepository.reserve(
        skuId,
        buyQty,
        inventory.getVersion()
    );

    if (affected == 1) {
        return;
    }
}

throw new ConcurrentUpdateException();

不过,单纯扣减库存时,下面这种条件更新通常已经够用:

UPDATE sku_inventory
SET available_qty = available_qty - :buy_qty
WHERE sku_id = :sku_id
  AND available_qty >= :buy_qty;

它不需要先读取版本,数据库往返次数更少。version 更适合一次更新多个业务字段,并且需要确认这些字段在读取后没有被其他操作修改的场景。

乐观锁并不表示数据库完全不加锁。数据库执行 UPDATE 时,仍然需要保护被修改的行。这里的“乐观”是指应用不会提前长时间持锁,而是在提交写入时检测冲突。

多件商品订单要避免死锁

一个订单包含多个 SKU 时,应在同一个事务中预占全部库存,并始终按照固定顺序处理,例如按 sku_id 升序:

SELECT sku_id, available_qty
FROM sku_inventory
WHERE sku_id IN (:sku_ids)
ORDER BY sku_id
FOR UPDATE;

只要任一 SKU 库存不足,就回滚整个事务。固定加锁顺序可以明显降低两个订单交叉锁定不同 SKU 时发生死锁的概率。

即便如此,应用仍应识别数据库返回的 deadlock 或 serialization failure,并对整个短事务进行有限次数的重试。

高并发场景的进一步处理

普通电商系统通常可以先采用数据库条件更新或短事务预占。只有单个 SKU 的写入量超过数据库承受能力时,才需要引入更复杂的架构,例如:

  • 将同一 SKU 的扣减请求路由到同一个队列分区并串行消费;
  • 按仓库、库存批次或库存桶拆分热点行;
  • 使用缓存承接流量,但仍以数据库或库存服务作为最终一致性边界;
  • 通过消息队列和 transactional outbox 可靠地发布订单及库存事件;
  • 为秒杀活动设置独立的库存令牌和排队系统。

不能只靠 Redis 分布式锁保证库存正确。锁可能因为租约到期、网络分区或进程暂停而失效,Redis 更新与数据库提交之间也仍然存在一致性问题。即使使用缓存或消息队列,数据库条件更新、唯一约束、状态转换校验和幂等处理仍不可缺少。

实际落地时应保证的规则

  • 购物车不占库存,除非业务明确支持有时限的购物车预留。
  • 页面显示的库存只用于提示,结账时必须重新校验。
  • 库存扣减应使用原子条件更新或短事务行锁。
  • 不要在数据库事务中调用支付、物流等外部服务。
  • 订单创建和库存预占应放在同一个本地事务中。
  • 支付请求、支付回调、取消订单和库存释放都要实现幂等。
  • 支付超时应按“结果未知”处理,通过查询、回调和对账确定最终状态。
  • 预占记录必须有过期时间和可靠的释放任务。
  • 使用 CHECK、唯一索引和合法状态转换约束保护数据。
  • 处理所有状态变化时都要检查受影响行数,避免重复确认或重复返还库存。

处理库存和支付时,不需要把整个购买流程塞进一个长事务。应先用很短的原子操作决定谁能取得库存,再通过可重试、可对账的状态机处理支付,因为外部支付无法纳入本地数据库事务。

备注:内容仅供参考。