E-commerce Application design:如何处理库存并发写入
结论
不要在持有数据库行锁的事务里调用支付网关。
通常应把“购买”拆成两个阶段处理:
- 在一个很短的数据库事务中,通过条件更新或库存预占,原子地取得库存。
- 提交事务、释放锁,然后调用支付网关。
- 根据支付结果确认订单,或者释放预占的库存。
- 遇到支付超时、重复通知或进程崩溃时,依靠订单状态机、幂等处理和定时对账恢复,不要依赖一个跨越支付请求的数据库事务。
页面显示“有货”只是当时的库存快照,不代表用户结账时一定能买到。防止超卖的真正边界应放在数据库的原子写入上。
为什么原方案有问题
下面这种事务不适合覆盖完整的下单和支付流程:
BEGIN;
SELECT *
FROM product
WHERE id = 1
AND status = 'AVAILABLE'
FOR UPDATE;
UPDATE product
SET quantity = 0,
status = 'SOLD_OUT'
WHERE id = 1;
-- 调用支付网关
COMMIT;
它主要有三个问题。
事务持续时间过长
支付调用可能持续数秒,甚至超时。在此期间,数据库事务会一直持有行锁,可能导致:
- 其他购买请求排队;
- 锁等待超时;
- 数据库连接被长期占用;
- 热门商品的吞吐量急剧下降;
- 死锁和故障恢复的成本上升。
数据库事务应尽量只包含数据库操作,一般不应跨越外部网络请求。
数据库无法回滚外部支付
支付请求超时,并不表示支付一定失败。例如:
- 支付网关已经扣款;
- 响应在传输过程中丢失;
- 应用收到 timeout;
- 应用回滚数据库事务。
结果就是用户已经付款,但库存和订单都被回滚了。数据库事务只能回滚本地数据库中的操作,无法撤销已经发生的外部支付。
其他用户看到有货并不等于会超卖
在常见的 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
发生超时时,可以这样处理:
- 让订单继续保持
PENDING_PAYMENT,或者改为PAYMENT_PROCESSING。 - 使用同一个幂等键向支付网关查询或重试。
- 接收支付网关发送的异步 webhook。
- 定时扫描长时间未完成的订单,主动查询支付状态。
- 只有确认未支付后,才释放库存。
- 如果库存已经释放,之后却收到支付成功通知,则按业务规则重新占用库存。无法重新占用时,自动退款或转交人工处理。
支付通知也必须支持幂等处理。例如:
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、唯一索引和合法状态转换约束保护数据。 - 处理所有状态变化时都要检查受影响行数,避免重复确认或重复返还库存。
处理库存和支付时,不需要把整个购买流程塞进一个长事务。应先用很短的原子操作决定谁能取得库存,再通过可重试、可对账的状态机处理支付,因为外部支付无法纳入本地数据库事务。
备注:内容仅供参考。
