头图

每次大促结束,客服群里最热闹的不是晒单,而是催着查库存。A仓库说还剩二十件,B仓库显示已售罄,C仓库的系统里却还在卖。客户下了单,仓库说没货,退款投诉一条龙。更离谱的是,有时候同一个SKU在三个仓的库存加起来,比采购总数还多——数据漂移了。

做过跨境代购系统的都知道,多仓库库存同步不是简单的“加减”问题。国内仓、海外仓、供应商一件代发仓,再加上集运模式的临时库存,数据源分散,更新延迟,并发扣减冲突。这套系统跑不稳,就等着活动结束后通宵对账吧。

问题定义:库存不一致的三个根源

并发超卖。秒杀时同一SKU被多个订单同时扣减,后端没做锁或事务隔离级别不够,库存从50扣到-15。1688的库存数据滞后更明显——大促期间供应商的库存更新可能延迟几分钟到几十分钟,代购系统显示有货,实际已断货。

跨仓调拨的数据丢失。A仓调拨100件到B仓,A仓扣减了,B仓还没加上,中间窗口期如果B仓有订单,系统以为没货,拒单。更糟的是,调拨单在传输中丢失或重复执行,导致库存翻倍或归零。

分布式环境下的状态不同步。订单服务、库存服务、仓储WMS各自维护自己的库存缓存,Redis更新成功但MySQL写失败,或者消息队列积压导致消费延迟。一天下来,三个库三个数。

方案对比:集中式 vs 分布式 vs 事件溯源

方案A:集中式库存服务(单点数据库+行锁)。所有库存操作都通过一个中心服务,数据库使用SELECT 。 FOR UPDATE行锁。优点是强一致性,实现简单。缺点是性能瓶颈——大促时所有扣减请求串行化,TPS被压到几十,订单超时率飙升。

方案B:Redis分布式锁 + 异步落库。每个SKU的扣减通过Redis分布式锁保证原子性,成功后再发消息异步更新数据库。性能高(单节点可支撑几千TPS),但锁超时、锁误释放、Redis主从切换时的脑裂问题,都可能导致超卖。某次线上事故:Redis主节点宕机后从节点升主,锁数据还没来得及同步,两个订单同时拿到锁,库存扣重了。

方案C:事件溯源 + 幂等扣减(最终一致性)。这是多仓库场景下最稳健的模式。将每次库存变动(入库、出库、调拨、锁定)建模为不可变事件,写入事件流。库存的当前状态由事件流实时归约得出。读性能依赖快照,写性能取决于事件存储的吞吐。核心好处:天然支持审计、回放、补偿,多仓库之间的数据最终一致,但不适合对实时性要求极高的秒杀。

做过日单量破千的集运项目后,最终选择的是方案B的改进版:Redis Lua脚本做原子扣减,配合数据库唯一索引做防重,再加上定时对账任务兜底。taocarts的多仓库模块也采用了类似的架构,将库存扣减与订单创建放在同一个分布式事务中,通过本地消息表保证最终一致。

踩坑记录:分布式锁的两个致命坑

坑一:锁粒度太粗,拖垮性能。最初对“SKU+仓库”的组合加锁,秒杀时所有请求排队,RT从50ms飙到2000ms。后来改为库存预扣+流水号幂等:先扣减Redis中的可用库存,返回成功立即响应前端,异步生成库存流水记录,流水表唯一键(order_id, sku_id, warehouse_id)保证同一订单不会重复扣减。锁的粒度降低到“订单级别”,并发度大幅提升。

坑二:锁超时导致重复扣减。业务处理时间超过锁的过期时间,锁自动释放。另一个请求进来拿到锁,又扣了一次。解决办法是使用红锁(Redlock)看门狗机制自动续期。但更简单的方案是:不依赖锁来保证最终准确性,锁只用来控制并发窗口,真正的一致性靠数据库唯一索引和状态机兜底。

落地方案:Lua脚本原子扣减 + 流水表防重

Redis库存扣减的核心Lua脚本:

-- KEYS[1]: sku库存key,格式 stock:{sku_id}:{warehouse_id}
-- ARGV[1]: 扣减数量
-- ARGV[2]: 流水号(订单号+SKU+仓库,用于幂等)
local stock_key = KEYS[1]
local idempotent_key = "idempotent:" 。 ARGV[2]
local quantity = tonumber(ARGV[1])

-- 幂等检查:已处理过直接返回成功
if redis.call('EXISTS', idempotent_key) == 1 then

return 1
end

local current = tonumber(redis.call('GET', stock_key) or 0)
if current < quantity then

return 0
end

redis.call('DECRBY', stock_key, quantity)
redis.call('SETEX', idempotent_key, 3600, '1')  -- 锁住流水号1小时
return 1

在 taocarts 中这套逻辑封装为StockManager模块,同时配合后台任务每十分钟扫描一次库存流水表,将Redis中的变更批量同步到MySQL。同步时使用INSERT 。 ON DUPLICATE KEY UPDATE保证幂等。

跨仓库调拨的状态机设计也很关键:

// 调拨单状态流转
const TRANSFER_STATUS = [

'pending' => 0,

// 待审核

'allocating' => 1,  // 扣减源仓库存中

'in_transit' => 2,  // 运输中

'received' => 3,

// 目标仓已收货

'completed' => 4,

// 完成

'failed' => -1

// 失败回滚
];

// 回调处理目标仓入库时,必须校验调拨单状态=in_transit
// 避免重复收货导致库存翻倍

收尾

回过头看,多仓库库存同步的本质是权衡一致性与可用性。不允许超卖的强一致性场景(比如预售定金),用分布式锁+数据库行锁;允许短暂不一致的普通商品,用Lua脚本+异步落库+定时对账就够了。

那次凌晨三点爬起来修复库存数据,手动跑了十几条SQL才把三仓数量对齐。后来加了对账任务,每天凌晨自动比对Redis和MySQL的库存差异,差异超过阈值就告警。虽然不能完全杜绝问题,但至少不用通宵了。多仓库方案没有银弹,但把幂等、状态机、对账三板斧用好,能少熬很多夜。


yanmoheluo
1 声望1 粉丝