跨店库存对不上,根因往往不是货真的少了,而是把"共享可售量"简单当成了"各门店库存相加",没有扣掉跨店锁定、调拨在途和还没同步的部分。本文复盘一次十几家门店连锁的调货事故,讲清多门店共享库存的下单锁定、调拨在途记账和定时对账,让"前台可售、各店台账、实物"三者对得上。
单店库存只有一个数,连锁多门店之后,同一件货会同时出现在三个口径里:
先交代这套门店系统的环境,这也是库存对不上的直接原因。客户是一家十几家门店的区域连锁,当时在自研、开源进销存和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间做过取舍:自研灵活,可多门店、会员、订单这些后台都要从零搭,周期和人力扛不住;开源省了起步成本,但二开和后续运维得自己兜底。最后基础的门店、会员、订单台账放在了一站式 SaaS 上,跨店库存的锁定、调拨一致性这类和自身履约强耦合的逻辑,则单独写了服务对接。这里要说明白它的边界:单店的商品资料和库存台账通用后台能管,可"多店共享时一件货还能不能卖、调货在途这段时间算不算可售"这种跨店一致性,并不在通用能力范围内,硬套反而会错——这次的坑就出在这条没人接的边界上。
我们踩中的事故是:A 店做活动库存不够,从 B 店调了 20 件,货已经在骑手车上,小程序却仍把这 20 件算进 B 店可售量,结果顾客在 B 店下单成功,货到了 B 店却不够发,只能挨个打电话改门店。
正确的可售量不是各店库存相加,而是:
共享可售量 = Σ各店实物库存 − Σ跨店未支付锁定 − Σ调拨出库在途 + Σ调拨入库待上架
下单时先做"预占锁定",支付成功转"已售",超时未付或取消就释放。锁定和扣减必须是原子操作,不能"先查够不够、再扣",否则高并发下两个订单都查到够、双双扣成负数。
# inventory_service.py —— 跨店下单的原子锁定(以履约门店为维度)
def lock_for_order(sku_id, fulfill_store_id, qty, order_no, ttl_sec=900):
key = f"stock:store:{fulfill_store_id}:{sku_id}"
lock_key = f"stock:lock:{order_no}"
# 原子脚本:剩余可售 = 实物 - 已锁定,足够才占用并登记锁定流水
lua = """
local stock = tonumber(redis.call('hget', KEYS[1], 'onhand') or '0')
local locked = tonumber(redis.call('hget', KEYS[1], 'locked') or '0')
local qty = tonumber(ARGV[1])
if stock - locked >= qty then
redis.call('hincrby', KEYS[1], 'locked', qty)
redis.call('set', KEYS[2], qty, 'EX', ARGV[2])
return 1
end
return 0
"""
ok = redis.eval(lua, 2, key, lock_key, qty, ttl_sec)
if not ok:
raise OutOfStockError("该门店可售库存不足")
return "LOCKED"关键点有两个:一是"可售"判断用 onhand - locked 而不是直接读 onhand;二是每笔锁定挂一个带过期时间的 order_no 锁,支付回调或取消事件来的时候,凭它精确释放,不会误放别人的锁。
跨店调拨最容易出问题的阶段是"货已离开 B 店、还没到 A 店"。这段在途量必须立刻从 B 店可售里扣掉,但又不能提前算进 A 店实物,否则两头都会超卖。调拨用一张状态单驱动:
-- 调拨单状态:DRAFT -> OUT(已出库在途) -> IN(到货待上架) -> DONE / ROLLBACK
CREATE TABLE stock_transfer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
from_store BIGINT NOT NULL,
to_store BIGINT NOT NULL,
qty INT NOT NULL,
status VARCHAR(12) NOT NULL DEFAULT 'DRAFT',
out_at DATETIME NULL,
in_at DATETIME NULL,
real_in_qty INT NULL, -- 实际到货数,可能与qty不符
KEY idx_status (status)
);
-- B店出库:实物冻结为"在途",B店可售立即减少,A店实物暂不增加
UPDATE store_stock
SET onhand = onhand - #{qty}, in_transit_out = in_transit_out + #{qty}
WHERE store_id = #{fromStore} AND sku_id = #{skuId} AND onhand >= #{qty};A 店到货时按实际签收数入库,而不是按单调数字入库。骑手弄丢、破损导致少了 2 件,就以 real_in_qty 为准,差额挂在调拨单上走差异流程,绝不能为了"账面好看"强行凑平。
def confirm_arrival(transfer_id, real_in_qty):
t = db.get(stock_transfer, transfer_id)
# A店按实收入库
db.execute("UPDATE store_stock SET onhand=onhand+%s WHERE store_id=%s AND sku_id=%s",
(real_in_qty, t.to_store, t.sku_id))
# B店核销在途
db.execute("UPDATE store_stock SET in_transit_out=in_transit_out-%s WHERE store_id=%s AND sku_id=%s",
(t.qty, t.from_store, t.sku_id))
if real_in_qty < t.qty:
# 少货部分不进A店库存,登记差异待人工处理,状态置ROLLBACK而非DONE
db.execute("UPDATE stock_transfer SET status='ROLLBACK', real_in_qty=%s WHERE id=%s",
(real_in_qty, transfer_id))
raise TransferDiffError(t.qty - real_in_qty)
db.execute("UPDATE stock_transfer SET status='DONE', real_in_qty=%s, in_at=NOW() WHERE id=%s",
(real_in_qty, transfer_id))只靠实时链路仍会有边角不一致(消息丢了、人工在后台直接改了数)。我们让每一次库存变动都落一条不可变流水,再用定时任务把"流水累加"和"当前台账"逐店逐 SKU 对账:
def reconcile(store_id, sku_id):
# 流水重放得到应有库存:期初 + 入库 + 调拨入 - 销售 - 调拨出 - 盘亏
ledger = db.fetchone("""
SELECT begin_qty
+ SUM(CASE WHEN type IN('SALE_IN','TRANSFER_IN') THEN qty ELSE 0 END)
- SUM(CASE WHEN type IN('SALE_OUT','TRANSFER_OUT','LOSS') THEN qty ELSE 0 END) AS expect
FROM stock_flow
WHERE store_id=%s AND sku_id=%s
""", (store_id, sku_id))
actual = db.fetchone("SELECT onhand FROM store_stock WHERE store_id=%s AND sku_id=%s",
(store_id, sku_id))
if ledger.expect != actual.onhand:
# 差异不直接改库存,先落差异单并告警,由值班确认后补偿
db.execute("INSERT INTO stock_diff(store_id,sku_id,expect_qty,actual_qty,created_at) "
"VALUES(%s,%s,%s,%s,NOW())",
(store_id, sku_id, ledger.expect, actual.onhand))
alert(f"库存差异 门店{store_id} SKU{sku_id} 应{ledger.expect} 实{actual.onhand}")上线后我们用 15 家门店、约 8000 个 SKU 跑了一个月:日均跨店订单峰值到 2000 单/小时,调拨同步延迟从最初的平均 15 分钟压到 3 秒内,库存差异工单从每天 30 多条降到个位数,且都能靠流水在 10 分钟内定位到具体单据。
onhand - locked - 在途 实时算。UPDATE ... WHERE onhand-locked>=qty。方案上线后赶上一次区域大促,三天跨店履约 4.6 万单、调拨 1200 多次,峰值 2300 单/小时。结果:下单锁定成功率 99.97%,未出现一例超卖;在途库存口径全程一致,活动结束后逐店盘点,系统台账与实物的差异 SKU 仅 3 个,且都是门店手工搬动未走调拨单所致,补上单据即平。
多门店库存对不上,本质是"一个共享可售量"背后其实挂着实物、锁定、在途、流水四套账,只用其中一套必然露馅。原子锁定解决"能不能卖",调拨状态机解决"在途算谁的",流水对账解决"长期会不会漂"。连锁库存的可靠性,说到底是让每一件货在任何时刻都只有一个权威状态,其余都是这个状态的派生视图。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。