Taocarts 知识

多仓库库存同步方案怎么选

📅 2026-01-01 海外仓管理

多仓库库存同步方案怎么选系统架构设计:支撑日均 5000 单的背后
燕子上周半夜给我打电话:“客户买了一副TWS耳机,微信支付扣了两次款,库存却只减了一次。现在要赔人家一份耳机,还要赔手续费。”电话那头能听到她翻订单后台的键盘声。

这不是偶然。她的中国代购出海系统刚拓展到全品类,国内三个仓库(广州美妆仓、义乌小商品仓、深圳电子仓)同时跑。库存同步用的是最原始的“订单落库 → 挨个查仓库 → 扣完再写回”,结果支付回调超时重试触发了重复扣款。

事故经过
用户下单TWS耳机,第一次支付成功回调正常处理——库存从深圳仓扣了1件,订单状态推进到“待采购”。但网络抖动,回调没返回200,支付网关重试了同一笔订单。第二次回调进来时,代码没做幂等检查,直接再扣一次库存(这次库存已经是负数,但业务代码只检查了if (stock >= quantity),负数也通过),于是产生了两笔采购单。

第二天仓库发货时发现多了一笔无货可发的单子。

排查时间线
00:10 收到用户投诉,后台查到同一订单号支付流水两条。

00:25 翻日志:支付回调在 2025-03-15 22:31:05 和 22:31:12 两次进入,order_id 相同。

00:40 定位到扣库存函数:先查库存余额,再update,但在两次调用之间没有分布式锁,也没有检查订单是否已处理过。

01:15 发现三个仓库的库存表独立,没有全局事务。第二次扣款时查的是深圳仓实时库存(已经是0),但if ($stock >= $qty) 里用的是 >=,0 >= 1 为 false 本来应该拦截,可代码里写的是 if ($stock < $qty) return false; else { 扣减 } —— 第一次扣完后 stock=0,第二次 0<1 为 true,但逻辑写反了:应该是 if ($stock < $qty) 才返回false,结果分支写成了 if ($stock < $qty) { 扣减 } ? 不,仔细看日志:实际上是事务隔离级别用的 READ COMMITTED,第二次查询读到的是第一次提交前的旧值(0),但 0 >= 1 为false,代码却依然执行了update——因为开发者用了乐观锁但没带版本号,update 条件只写了 product_id,没有 stock = old_stock,导致第二次扣减覆盖了第一次。

根因分析
三条致命缺陷:

支付回调没有幂等处理 —— 同一 payment_intent 进来,没有检查 order.paid 状态。

库存扣减不是原子操作 —— SELECT 。 FOR UPDATE 在低隔离级别下失效,且 UPDATE 无前置条件。

多仓库同步用“遍历依次扣” —— 先查深圳仓,不够再查广州仓,中间没有锁,并发时两个订单可能抢同一个仓库的最后一件库存,导致超卖。

回头想想,这套系统连订单状态机都不完整——“已支付”状态没有防重入锁。

修复方案
重新设计多仓库库存同步架构,同时引入RPA自动化订单处理来减少人工干预。

库存同步选型:放弃“遍历仓库依次扣”,改用“库存中心统一预占 + 按仓库拆分预留”。每个仓库的扣减用 Redis Lua 脚本保证原子性,落库用消息队列异步批量写入。

订单状态机加幂等:支付回调入口先用 SETNX 抢锁(key = pay:order_id),成功才继续。

RPA集成:采购单生成后,由RPA机器人自动登录1688或供应商后台,填单、提交、抓取物流单号,全程无需人工。库存同步时,RPA实时读取供应商系统的库存快照,回写本地库存中心——这解决了一个痛点:代购网站的库存依赖供应商,手动同步滞后严重。

// 支付回调幂等 + 订单状态机
function handlePaymentCallback($paymentIntentId, $orderId) {

$redis = getRedis();

$lockKey = "pay:lock:{$paymentIntentId}";

if (!$redis->setnx($lockKey, time() + 30)) {

// 已在处理中,直接返回

return false;

}

try {

$order = getOrderById($orderId);

// 状态机检查:只有待支付状态才能推进

if ($order['status'] !== 'pending_payment') {

logWarning("订单状态已变更,重复回调丢弃", $orderId);

return false;

}

updateOrderStatus($orderId, 'paid');

// 多仓库库存预占(原子操作)

$items = $order['items'];

$preResult = multiWarehousePreDeduct($items);

if ($preResult['success']) {

updateOrderStatus($orderId, 'stock_reserved');

dispatchJob('auto_purchase', $orderId); // 触发自动采购

} else {

// 库存不足,回滚订单

updateOrderStatus($orderId, 'payment_success_no_stock');

refund($paymentIntentId);

}

return true;

} finally {

$redis->del($lockKey);

}
}

// 多仓库预扣:Lua + 哈希记录分配
function multiWarehousePreDeduct($items) {

$lua = <<<LUA

local warehouse_keys = redis.call('keys', 'stock:warehouse:*')

-- 伪代码:遍历物品,找到有足够库存的仓库并扣减

-- 实际逻辑复杂,这里展示原子扣减单个仓库

local product_key = KEYS[1]

local qty = tonumber(ARGV[1])

local current = tonumber(redis.call('get', product_key) or 0)

if current >= qty then

redis.call('decrby', product_key, qty)

return 1

end

return 0
LUA;

// 每个商品分别尝试不同仓库(简化)

foreach ($items as $item) {

$deducted = false;

foreach (getCandidateWarehouses($item['product_id']) as $wh) {

$key = "stock:warehouse:{$wh['id']}:product:{$item['product_id']}";

if ($redis->eval($lua, [$key, $item['qty']], 1)) {

recordAllocation($wh['id'], $item['product_id'], $item['qty']);

$deducted = true;

break;

}

}

if (!$deducted) return ['success' => false, 'reason' => "product {$item['product_id']} out of stock"];

}

return ['success' => true];
}

自动采购功能接RPA的接口:订单进入 stock_reserved 状态后,队列消费者调用RPA服务的下单API,传递商品链接、数量、地址。RPA模拟人工下单,成功后回调更新订单状态为 purchased,并触发物流单生成。

// 自动采购任务
class AutoPurchaseJob {

public function handle($orderId) {

$order = getOrder($orderId);

if ($order['status'] !== 'stock_reserved') return;

try {

$rpaResult = callRPA($order);  // 调用内部RPA服务

if ($rpaResult['code'] === 200) {

updateOrderStatus($orderId, 'purchased');

updateOrderTracking($orderId, $rpaResult['tracking_number']);

} else {

// 重试或人工介入

retryLater($orderId, 300);

}

} catch (Exception $e) {

logError("auto_purchase failed", $orderId, $e->getMessage());

}

}
}
Prevention

这次事故后,我们定了三条规则:

所有支付回调入口必须上分布式锁(Redis SETNX + 过期时间),锁内检查订单状态机。

库存扣减统一走库存中心,对外只暴露预占/确认/释放三个原子接口。跨仓库分配用 Lua 脚本在 Redis 内完成,避免多步操作。

订单状态机必须包含“已支付未占库存”的中间态,任何对库存的操作都要从这个状态出发,杜绝跳过状态直接扣。

回头看看,当初没有拆微服务反而是对的——团队就五个人,库存同步用 Redis 做缓存层 + 单库事务足够。如果硬拆成“订单服务”和“库存服务”,这次事故的排查时间至少翻倍,跨服务分布式事务更麻烦。

燕子后来把 RPA 集成到了自动采购和物流单打印,日均订单从 500 单提到 5000 单,仓库那边人工干预大幅减少。她说最实用的改进,其实是订单状态机的每个节点都加了“超时回滚”和“重试上限”——那些没被注意的边界条件,才是系统真正的边界。

wechat wechat qr