日期:2026-09-06
我们接手这家连锁餐饮的线上化改造时,最刺眼的问题不是系统多旧,而是线上线下根本是两本账。小程序商城卖的是一套库存数,门店 POS 里又是另一套,总部的人每天早上对着两份表格对账,对到中午也合不上。线上超卖是家常便饭,顾客下单支付成功,到店取餐发现做不了,投诉直接甩给门店,门店又甩给电商团队。更麻烦的是会员权益也对不上:小程序里充的储值,门店 POS 有时不认,老顾客觉得自己被区别对待,好几次在点评上给了差评。我们当时第一反应是想赶紧做双向实时同步,把两套数拉平,后来被业务侧一句话点醒:你先告诉我,到底以谁家的数据为准?这个问题答不上来,同步做得越快,错得越离谱,反而把两本错账焊死在一起,后面更难拆。
我们最后定的方案是先把门店 POS 库存作为单点真源,mall 侧只消费、不直接写。小程序商城每次下单,先向门店库存聚合层预扣,扣成功才允许支付;预扣失败或库存不足,前端直接提示无货或转预售,不让用户付了钱再尴尬。门店自己盘点、报损、调拨,只改 POS 一侧,聚合层秒级拉取,mall 端看到的永远是从真源算出来的可用量。缺货时我们加了一层智能调度:同城市的其他门店若有货,推荐转就近自提或发起调拨,而不是干巴巴报错让用户走人。会员储值也归一到同一套账户中心,mall 和 POS 都从账户中心读余额,不再各存一份,储值、券、积分三件套终于对得齐,老顾客在小程序充的钱,到店也能直接刷。
最棘手的是实时同步的一致性。我们一开始想用消息队列做双向同步,结果门店盘点瞬间会产生一大波库存变更事件,mall 侧消费慢了就会导致短暂超卖。后来改成预扣模式:写操作只在真源(POS 与库存中心)发生,mall 侧只读加本地缓存,缓存失效靠事件轻量通知,不回写,这样写放大被压到最低,也不会因为消费滞后而串味。超卖防护是第二个坑,光预扣不够,高并发下同一件商品可能被多个订单同时预扣成功,我们在预扣环节用了带版本号的乐观锁,版本冲突直接失败重试,宁可少卖不让超卖。促销峰值是第三个坑,大促时某几个爆品库存争抢激烈,我们把热点商品单独做库存分片,预扣请求按商品哈希路由,避免全局锁把整库拖慢。调拨路由是第四个坑,跨店调拨要算时效和成本,我们按距离和库存深度给每个门店打分,优先从近且深的门店调,避免为了凑单把货拉到天边。
上线约五个月,线上超卖事故从改造前每月十几起降到零,门店与电商团队的账目争议基本消失。库存一致率(mall 展示量与 POS 真源偏差超过阈值的比例)从原来的大约百分之四降到千分之一以下。缺货率因为有了就近调拨推荐,反而比改造前下降约两成,顾客到店无货的情况少了。订单履约时效从平均四十多分钟压到二十分钟以内。运维侧最直观的感受是:早上不用再有人专门对账了,这份人力省下来做运营,算下来一年接近省了一个专职岗。会员储值争议工单也跟着降了七成,点评上的相关差评基本绝迹。
这件事最实在的教训是,做库存共享之前先别急着写同步代码,先把以谁为准这个问题和业务方钉死。我们当初差点一上来就做双向同步,被业务一句话拦住,现在看那个拦是对的。预扣优于事后补偿,这是踩过超卖的亏之后形成的共识,版本号乐观锁看着麻烦,真到高并发时才显出价值。热点商品分片这条当时还有争议,觉得过度设计,后来大促验证确实扛住了,算是提前还的债。调拨路由打分这层,业务一开始觉得多余,上线后却发现它顺带把跨店调货成本降了下来,算意外收获。mall 侧只读不写这个边界,后来在会员、券、储值好几个模块也都沿用了同一思路,边界划清楚,系统反而简单了。
回头看,这笔改造投入最大的不是技术,是和业务方把以谁为准这个问题吵明白的那两周。技术上的预扣、乐观锁、库存分片,拆开看都不新鲜,难的是忍住没去写那个看起来很美的双向同步。我们后来做其他客户的库存项目,第一件事就是先问真源在哪,这习惯就是这次养成的。
案例片段(已脱敏): 库存聚合层预扣配置(示意): key = stock:{storeId}:{skuId} 预扣 Lua:if redis.call('hget',key,'ver')==ARGV[1] then redis.call('hincrby',key,'avail',-qty) return 1 else return 0 end 大促当天某爆品 SKU 预扣峰值 QPS 约 3200,版本冲突重试率约 3%,无一次超卖; 门店盘点事件平均推送到 mall 可见延迟 1.2s,库存一致率监控曲线无突变,对账人力由 1 人/天降为 0,会员储值争议降七成。