问题反馈
一个简单的投诉:"没法结账——库存不足"。产品在后台管理面板显示有货,库存数量看起来也没问题。但每一次尝试下单都因为库存校验错误而失败。
库存预留的工作原理
这套系统使用两阶段的库存模型:
- available_quantity —— 手头持有的总库存
- reserved_quantity —— 被活跃订单占用的数量
- purchasable(可购买数量) =
available_quantity - reserved_quantity
顾客一下单,reserved_quantity就会立即增加。只有在订单被取消或履约(发货)时才会减少。这是刻意的设计,可以防止多个顾客同时结账时出现超卖。
结账时的校验很简单:
const purchasable = stock.available_quantity - stock.reserved_quantity;
return purchasable >= quantity;
一旦purchasable归零,即使物理库存就摆在仓库里,也没人能买了。
根本原因:预留泄漏
查询数据库立刻就暴露了问题:
SELECT v.variant_name, s.available_quantity, s.reserved_quantity,
(s.available_quantity - s.reserved_quantity) AS purchasable
FROM inventory_stocks s
JOIN product_variants v ON v.id = s.variant_id
WHERE (s.available_quantity - s.reserved_quantity) <= 0;
有几个规格变体尽管有实体库存,purchasable却是0。已预留数量与可用数量完全持平,甚至超出。
深入查是哪些订单占用了这些预留库存,揭出了元凶:19个状态为pending、payment_status = 'awaiting_payment'的订单,有些甚至超过15天。这些都是顾客打开了支付网关页面、却从未完成支付的被放弃的结账订单。
系统没有让它们过期的机制,所以这些库存预留就永远被锁住了。
为什么库存从未被释放
releaseStock()函数是存在的,而且调用得也没问题,但只存在于一个地方:显式的订单取消流程。那个流程需要用户(管理员或顾客)主动去取消订单。
对于被放弃的支付来说:
- 顾客关掉了浏览器标签页
- 支付网关悄无声息地超时了
- 没有webhook被触发(没有支付=没有webhook)
- 没有任何用户按过"取消"
- 订单永远停留在
pending + awaiting_payment - 预留库存:永久锁死
这是一个典型的预留泄漏——一次两阶段提交,而第二阶段(释放)取决于一个可能永远不会发生的用户操作。
修复方案:两个部分
第一部分:立即数据修复
已经泄漏的预留需要直接在数据库里修正:
BEGIN;
-- Release reserved stock from abandoned orders
UPDATE inventory_stocks s
SET reserved_quantity = GREATEST(0, s.reserved_quantity - sub.qty_to_release)
FROM (
SELECT oi.variant_id, SUM(oi.quantity) AS qty_to_release
FROM sales_orders o
JOIN sales_order_items oi ON oi.order_id = o.id
WHERE o.status = 'pending'
AND o.payment_status = 'awaiting_payment'
AND o.created_at < NOW() - INTERVAL '24 hours'
AND o.deleted_at IS NULL
AND oi.item_type = 'product'
GROUP BY oi.variant_id
) sub
WHERE s.variant_id = sub.variant_id;
-- Cancel the abandoned orders
UPDATE sales_orders
SET
status = 'cancelled',
cancellation_reason = 'Payment not completed within 24 hours',
cancelled_at = NOW()
WHERE status = 'pending'
AND payment_status = 'awaiting_payment'
AND created_at < NOW() - INTERVAL '24 hours'
AND deleted_at IS NULL;
COMMIT;
注意GREATEST(0, ...)这个保护措施——它能防止在数据已经不一致的情况下,reserved_quantity变成负数。
第二部分:永久修复,自动过期调度器
现有项目里已经有一个NestJS调度器服务,用来自动完成已送达的订单。自动过期任务就跟着它一起加了进去:
@Cron(CronExpression.EVERY_HOUR)
async autoExpireAbandonedPayments(): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setHours(cutoffDate.getHours() - 24);
// payment_status isn't mapped on the TypeORM entity, so raw query is needed
const expiredOrders = await this.orderRepository.query(
`SELECT id, order_number FROM sales_orders
WHERE status = $1
AND payment_status = 'awaiting_payment'
AND created_at < $2
AND deleted_at IS NULL`,
[OrderStatusEnum.PENDING, cutoffDate],
);
for (const order of expiredOrders) {
const items = await this.orderItemRepository.find({
where: { order: { id: order.id } },
});
// Release reserved stock before cancelling
for (const item of items) {
if (item.item_type === CartItemTypeEnum.PRODUCT && item.variant_id) {
await this.inventoryStocksService.releaseStock(
item.variant_id,
item.quantity,
null, // system-initiated, no causer user
);
}
}
await this.orderRepository.update(order.id, {
status: OrderStatusEnum.CANCELLED,
cancellation_reason: 'Payment not completed within 24 hours',
cancelled_at: new Date(),
});
await this.orderTrackingService.createEvent(
order.id,
OrderEventTypeEnum.CANCELLED,
'Order auto-cancelled: payment not completed within 24 hours',
);
}
}
一个关键的坑:未映射的列
payment_status这一列在数据库里是存在的,但在TypeORM实体上并没有以@Column()的形式映射。TypeORM的find()和findOne()只能对已映射的字段做过滤。对于未映射的列,通过repository.query()做原始查询是一条退路,不需要为了一个调度任务的查询专门去修改实体。
需要留意的模式
任何在结账时增加计数、却只在显式用户操作时才减少计数的系统,都存在这种脆弱性。修复方案的形态总是一样的:
- 找出预留泄漏(占着库存超过其有效期的订单)
- 写一条带安全防护(
GREATEST(0, ...))的一次性修正查询 - 加一个定时任务,今后自动运行同样的逻辑
- 在改变订单状态之前释放资源,而不是之后
最后一点很关键:如果库存释放在循环中途失败,订单会保持在pending状态,下一次每小时执行时会重新尝试。但如果你先取消订单、后释放库存,一旦这两步之间出现失败,就会留下一个已取消、但库存仍被预留的订单。