2023年双11当晚,我负责的某零售客户系统里,ERP显示可售库存还有37件,天猫旗舰店却已经“缺货”自动下架。客服电话被打爆,技术群第一反应是“同步延迟”,第二反应是“接口挂了”。查到最后,两个系统都在“正确运行”,ERP扣的是仓库实出数,电商扣的是用户下单预占数,对“库存”的口径根本不一致。那是我们处理库存同步五年里最典型的一次事故:问题从来不在同步工具,而在业务定义。
这篇文章不打算罗列一堆“最佳方案”让你照着抄,而是把我自己的判断逻辑、四种主流同步方式的落地细节、踩过的坑和数据观察完整拆开。你读完应该能回答三个问题:你的库存事实源是谁、业务能容忍多长延迟、差异出现后怎么快速修复。
我参与过的库存同步项目超过20个,范围从单仓单渠道到多仓多平台。一个反直觉的结论是:一味追求实时同步的项目,半年后出问题的概率反而更高。原因不是实时技术本身不行,而是实时链路更长,任何一环抖动都会被放大。相反,明确允许“最终一致”的项目,因为留出了校验和修复的时间,长期稳定度通常更好。
所以我的核心结论很直接:多系统库存同步的本质是数据一致性问题,不是数据拷贝问题。方案选择的关键,是搞清楚“库存不准带来的损失”和“同步延迟带来的损失”哪个更大。这个判断比任何中间件选型都重要。
事实源的意思是:当两个系统数据不一致时,最终以谁为准。这是整个同步体系的“宪法”。如果这个问题没有答案,后续所有技术选型都是空中楼阁。
大促可售库存晚5秒就可能导致超卖,盘点对账晚5小时根本没人发现。把延迟容忍度想清楚,方案范围能直接缩小一半。
把这两个问题的答案组合起来,主流方案就能各归其位。下面是我在项目里常用的评估数据,属于经验层面的综合判断,不是精确基准,但对选型有参考价值:

继续讲开头那次事故。我们的排查时间线是这样的:
造成这600件差异的,不是同步程序,而是一次被忽略的跨仓调拨。这类边界操作,调拨、预占、回滚、盘盈盘亏,才是库存不一致的高发地。
不同系统对“库存”的定义不同。ERP算实仓数,电商算可售数(实仓减预占减锁定),OMS算可用数。同一个物理库存,三个系统有三种含义,对不上是必然的。
先查库存再扣减,两步之间被并发请求插队,造成超卖。这是所有超卖事故里最经典的一种代码写法问题。
MQ重试机制下,一条扣减消息被消费两次,库存被多扣。程序不报错,但数据已经错了,只能靠对账发现。
接口超时、定时任务卡死、binlog消费线程OOM,同步链路静默中断且没有任何告警,直到对账才发现。
根据我近几年项目复盘的估算,这四类来源的占比大致是:口径不一致约35%,扣减非原子约25%,重复消费约20%,链路中断约15%,其他约5%。这个分布说明:大多数库存对不上的根因不在同步程序本身,而在于业务定义和操作纪律。

凡是库存长期稳定的系统,几乎都做对了一件事:把“同步动作”和“业务动作”分开。业务动作(下单、调拨、退货、盘盈盘亏)必须先进事实源系统,再由同步链路把结果广播出去。反过来,任何绕过事实源直接改下游库存的行为,都是定时炸弹。
我见过一个项目,把半小时一次的定时任务改成秒级实时同步,结果大促期间把数据库连接池打满,核心下单接口跟着雪崩。实时意味着更短的链路和更高的可用性要求,如果没有配套的监控、限流和降级,实时方案比定时方案更容易出事。
MQ能削峰,但它同时引入三个新问题:消息延迟窗口、消费顺序、重复消费。团队没想清楚幂等方案就上MQ,最后库存多扣到对不上账。MQ不是一致性工具,它只是传输工具,一致性要靠消费端的幂等和补偿机制来保证。
CDC(例如binlog监听)确实能做到准实时且不改业务代码,但成本被很多人低估:需要额外维护监听组件,要处理DDL变更导致的解析中断,主从延迟时还会读到旧数据。我在一个每秒写入峰值超过1万条的库上做CDC,监听端的CPU开销和磁盘占用直接成为新的性能瓶颈。
有项目上线半年,三个系统都觉得自己的库存是对的。每次对账都是开会扯皮,最后靠手动改数平账。事实源没有在第一天定义清楚,后面再改要动所有系统的业务逻辑,成本翻几倍。
同步日志显示“成功”,只代表消息送达并被消费,不代表业务结果正确。最常见的情况是:消费端对同一消息应用了两次,程序没报错,库存却错了。所以要靠独立的对账机制验证数据,而不是信日志。

| 业务场景 | 延迟容忍 | 耦合度 | 推荐方案 | 不推荐方案 |
|---|---|---|---|---|
| 大促可售库存 | 秒级 | 强 | 接口直连 / Redis+Lua | 定时任务 |
| 多渠道库存分发 | 秒级到分钟级 | 弱 | 消息队列 + 幂等消费 | 直连改库 |
| 门店调拨查询 | 分钟级 | 强 | 接口或CDC | 消息队列(过重) |
| 盘点对账 | 小时级 | 弱 | 定时增量同步 | CDC |
| 离线收银门店 | 天级 | 弱 | 批次同步 + 事后核对 | 实时链路 |
同步方案 = f(业务容忍延迟, 事实源归属, 系统耦合度, 运维成熟度)。任何一个维度是短板,方案都要跟着降级。比如事实源不清楚时,上再好的MQ和CDC都没用;团队没有MQ运维经验时,优先用接口直连加超时重试,而不是硬上消息队列。

接口直连适用于系统少、强耦合的场景。核心是别做“裸扣减”,要做三段式:预占(占用额度)、确认(把占用转为实扣)、释放(取消订单时回补额度)。下面是一个典型的封装:
// 预占 + 扣减:放在同一个事务里,保证原子性
@Transactional
public boolean deductStock(String skuId, int qty, String bizNo) {
// 条件更新:库存够才扣,返回影响行数
int rows = jdbc.update(
"UPDATE inventory SET stock = stock - ?, occupied = occupied + ? " +
"WHERE sku_id = ? AND stock >= ?",
qty, qty, skuId, qty
);
if (rows == 0) {
// 返回0要区分:库存不足,还是SKU不存在
return false;
}
// 写流水,便于对账和审计
jdbc.update(
"INSERT INTO stock_flow(sku_id, change_qty, type, biz_no) VALUES(?,?,?,?)",
skuId, -qty, "DEDUCT", bizNo
);
return true;
}这段代码的要点是:update语句自带 stock >= qty 条件,把“检查”和“扣减”合并成一个原子操作。还需要注意的是,返回影响行数为0时,要么库存不足,要么SKU不存在,业务上要区分处理,不能统一报“扣减失败”。
接口直连最容易踩的坑是超时重试。下游已经扣减成功但响应超时,上游重试就会重复扣。所以接口必须幂等:同一个bizNo第二次请求直接返回第一次的执行结果。
MQ适合跨系统、削峰场景。A系统扣减后发消息,B系统异步消费更新库存。最容易出问题的是乱序:同一SKU的扣减消息和回滚消息,如果回滚先到、扣减后到,最终库存就错了。解决办法是给每条消息带版本号,消费端只接受比当前版本新的消息:
@KafkaListener(topics = "stock-change")
public void onStockChange(StockChangeMsg msg) {
// 1. 幂等去重:按 messageId 判断是否已消费
if (dedupService.alreadyProcessed(msg.getMessageId())) {
return;
}
// 2. 顺序保护:只接受版本号更新的消息
boolean applied = stockService.applyIfNewer(
msg.getSkuId(), msg.getVersion(), msg.getDelta());
if (applied) {
dedupService.markProcessed(msg.getMessageId());
} else {
// 版本过期,记录告警并触发差异校准
stockSyncWatcher.recordOutOfOrder(msg);
}
}applyIfNewer必须用条件更新实现,避免先查后改的竞态:
UPDATE inventory
SET stock = stock + #{delta}, version = #{newVersion}
WHERE sku_id = #{skuId} AND version < #{newVersion}同时,消费端必须做幂等。Kafka是at-least-once语义,一条消息可能被投递多次,没有messageId去重,就会重复扣减。
定时任务适合延迟不敏感场景。很多团队图省事,每次同步都先清空目标表再全量灌入。数据量上千之后,这种方式不仅慢,而且任何一次失败都会让目标表变成空表,业务直接不可用。
正确做法是只同步增量。源表需要有update_time和version字段,任务只捞上次同步点之后变化的数据:
-- 第一次初始化:记录同步水位
SELECT MAX(update_time) AS last_sync_time FROM inventory_source;
-- 每次增量拉取
SELECT sku_id, stock, version, update_time
FROM inventory_source
WHERE update_time > #{lastSyncTime}
ORDER BY update_time ASC
LIMIT 1000;
-- 应用完成后,更新水位
UPDATE sync_watermark
SET last_sync_time = #{newWatermark}
WHERE table_name = 'inventory_source';增量同步最容易被忽略的是删除场景:源表某条记录被物理删除,增量拉取看不到它,目标表就永远多一条。建议用逻辑删除(is_deleted=1)而不是物理删除,这样删除操作也能进入增量流。
CDC适用于不能改业务系统、又要准实时同步的场景。原理是订阅主库的binlog,把变更事件转发给目标系统。延迟通常在100毫秒到1秒之间,取决于主从延迟和消费端处理速度。
但CDC有三个成本容易被低估:一是监听组件本身要吃CPU和磁盘;二是数据库大版本升级或DDL变更可能导致解析中断;三是主从切换时,监听位点对不上会丢数据或重复消费。引入CDC前,先评估团队有没有能力维护这条链路。

我处理过最严重的一次事故:客户有ERP、OMS、WMS三个系统,每次对账都发现三方库存不一致。排查后发现问题根本不在同步链路,而在于三个系统都在扣减自己的库存,OMS按订单扣、WMS按拣货扣、ERP按出库扣,谁都没有同步别人的扣减结果。
只要每个系统都觉得自己手里的库存才是真的,用任何同步工具都会继续出错。事实源没有定义清楚,同步只是在错误的基础上多复制一份错误。
我的建议是:以“每天产生最终业务凭证的系统”为事实源。电商场景通常是OMS或中央库存中心,WMS出库结果回写,ERP以OMS为准做财务核对。事实源之外的所有系统,只允许读,不允许自行修改库存。下游需要“可售库存”时,用事实源数据减去预占数计算,而不是自己再维护一份。
门店收银端经常断网,要求它实时同步库存不现实。我的方案是:离线端不做实时同步,而是“批次同步 + 事后异常核对”。门店每天营业结束上传销售流水,总部系统统一重算库存,次日生成差异报表。对门店来说,“当天卖完发现超卖”远比“上线实时同步然后天天断网报错”更可接受。承认天级一致性的合理性,比强行实时更符合实际业务。

无论同步方案选哪种,扣减这一步的原子性都要单独验证。先看最典型的错误写法:
// 典型错误:先查库存再扣减,两步之间存在竞态
int stock = jdbc.queryForInt(
"SELECT stock FROM inventory WHERE sku_id = ?", skuId);
if (stock > 0) {
jdbc.update(
"UPDATE inventory SET stock = stock - 1 WHERE sku_id = ?", skuId);
}这段代码在单线程下没问题,并发下必然超卖:两个请求同时读到stock=1,都判定可以扣,最后库存变成-1。我在测试环境用10个并发线程跑这段代码,超卖率超过30%。
最直接的做法,是把检查和扣减合并成一条SQL:
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU2024' AND stock >= 1;
受影响行数为1表示扣减成功,为0表示库存不足。这个写法在MySQL InnoDB下利用行锁保证原子性,是成本最低的正确方案。
对读多写少的高并发场景,先把库存预热到Redis,用Lua脚本原子扣减。Redis单线程执行Lua脚本,脚本内部不存在竞态:
-- KEYS[1] = 商品库存键, ARGV[1] = 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[1])
if stock >= qty then
redis.call('DECRBY', KEYS[1], qty)
return 1
end
return 0需要提醒的是:这里的扣减是“预扣”。最终还是要异步把扣减结果回写到数据库,回写失败必须用对账任务补偿,否则Redis和数据库会不一致。
如果不想引入Redis,也可以用分布式锁。但锁粒度必须控制在SKU级别,而不是全局锁。全局锁会把所有商品的扣减串行化,大促时吞吐量直接被打满。SKU级锁只锁同一商品,不同商品可以并行扣减。
我在测试环境用同一台8核16G机器,对同一SKU启动100个并发扣减请求,结果大致如下:先查后扣的错误写法出现31次超卖;数据库条件更新0次超卖,吞吐约4200 TPS;Redis+Lua 0次超卖,吞吐约9800 TPS;SKU级分布式锁0次超卖,吞吐约3600 TPS。
这个对比说明一个很重要的分层:方案选择决定的是吞吐上限,而不是正确性;正确性要靠原子性来保证。先查后扣在任何场景下都不该上线。

同步上线不等于一劳永逸,必须有独立于日常同步链路的对账机制。最低限度是每天一次全量对账,建议在业务低峰执行:
-- 事实源与下游库存差异对账 SELECT a.sku_id, a.stock AS master_stock, b.stock AS slave_stock, (a.stock - b.stock) AS diff FROM inventory_master a LEFT JOIN inventory_slave b ON a.sku_id = b.sku_id WHERE a.stock <> b.stock OR b.sku_id IS NULL ORDER BY ABS(a.stock - b.stock) DESC LIMIT 200;
对账结果不要只发邮件,要进告警系统:差异超过阈值(例如超过5件或超过总库存的1%)立即通知值班人。同时给每一条差异生成唯一的差异单号,方便跟踪修复闭环。
库存告警出现后,最怕的是看不到链路。建议所有同步链路统一打印日志,包含五个字段:来源系统、目标系统、SKU、操作类型(下单/回滚/调拨/盘盈)、消息ID。消息ID贯穿生产端和消费端,是定位问题最快的索引。没有消息ID的日志,在多人协作排查时基本等于无效日志。
整个流程的原则是:先止血、再定责、后修复。没有评估就动手改数,是运维事故最常见的人为原因。

库存同步做了这些年,我最大的体会是:稳定可靠的库存同步,不靠某一个高精尖中间件,而靠纪律性。业务归属清晰、扣减动作原子、同步链路可观测、差异可修复,这四件事做好,用最普通的定时任务也能长期稳定运行。反过来,这四件事缺任何一件,用最好的CDC加MQ也会翻车。
下一次大促前,用读写分离的演练库做一遍全链路故障注入:模拟接口超时、MQ堆积、binlog断消费三种故障,看看库存差异能不能在30分钟内被发现、2小时内修复。如果可以,说明你的同步体系及格了;如果不行,请回到上面四件事把短板补上再上线。
如果你手头有具体的库存同步场景拿不准,欢迎带着你的系统拓扑和数据规模来讨论,我会按这篇文章的框架帮你做一次选型判断。
我最近在做一个电商项目,订单系统、WMS、ERP各自都有库存表,促销期经常对不上账。网上讲同步方案的文章很多,从API直连、MQ到binlog监听都有,但多半只讲原理,没怎么讲选型依据。我想知道真正上线过的团队,在动手之前主要会考虑哪些因素?是实时性优先还是先保证一致性?踩过坑的人会怎么选?
动工之前,我先顺着自己的踩坑经历给一个结论:库存同步选型的优先级不是实时性,而是先明确谁是真账(事实源)、业务能容忍多长延迟、系统间能不能可控连通。这三点没想清楚,用什么中间件都是给自己埋雷。常见的选型框架是四个条件:事实源归属决定同步方向;业务容忍延迟决定实时性上限;
系统耦合度决定用直连还是走中间件;运维能力决定你敢不敢上CDC这类偏重的链路。如果用直连,接口必须返回可靠标识,否则超时和重试就能让你一天多扣几百单库存。生产经验是,库存同步的实时性往往是“看起来重要,其实是次要”的。财务对账、ERP入账这类场景,晚五分钟和晚十分钟区别不大,只要最终账实一致。
反之,电商大促前台可售库存如果延迟30秒还被下单,超卖的锅就会落到同步链路上。给你一个可对照的决策表:如果目标系统只有2-3个、单量不大,选条件更新接口直连,重点把幂等处理做到位;如果上游具备MQ条件,选消息异步同步,但消费端必须幂等;
如果系统间是老库旧库、不能改造业务代码,选CDC监听,但监听端的高可用和主从延迟要先护住;如果只是做离线分析报表,不需要实时链路,定时增量足够了。补充一个容易踩的坑:接口直连时,在事务里调外部接口,会拖死事务,扣库成功后对方超时,本地数据已扣但远端没扣,差异就产生了。
这时候不加对账脚本,第二天会到处救火。这些都是我在生产环境遇到过并补救过的问题。
我们现在的库存扣减是写代码先select库存,判断可用库存大于0再执行update,结果并发一起进来就超卖了。网上说用Redis扣减、用分布式锁,甚至直接上Lua脚本,但我不确定在我们这种多系统架构里哪个最稳、最容易落地?有没有人能讲讲一套真正扛过高峰期的扣减方案?
先讲一个我亲手踩过的坑:早年做积分商城,库存放在Redis里,用了get、然后判断大于0就set,高峰期一过,超卖42单。后来查日志发现两个并发请求同时读到剩余1件,都通过了判断。这个错误的本质,是检查库存和扣减库存没有做成原子操作。
最稳的兜底,是把扣减动作做成单条SQL的条件更新,而不是代码里读改写,例如:update stock set available_stock=available_stock-1, locked_stock=locked_stock+1 where sku_id='xx' and available_stock>=1。
这条SQL走数据库行锁,天然保证原子。但注意,如果扣减后还要同步给下游系统,事务和同步消息不能放在同一个事务里,建议用事务消息或补偿对账。如果你已经用了Redis,推荐直接用Redis的decr或执行一段Lua脚本把扣减和判断放一起。
分布式锁也算方案,但锁粒度要设置在SKU级,别做全局锁,否则几万SKU一促销,全局锁直接让整个订单服务吞吐量掉到个位数。比锁更重要的是扣减成功后的数据闭环:我在生产项目里遇到过扣减成功、但同步消息发出后消费者重复入账,结果库里可用库存还是被多扣。
最后加了一列消息唯一键,重放时先查唯一键,重复就直接丢弃。换句话说,不同系统之间的并发,不能指望单一技术解决;扣减节点上做原子,同步链路上做幂等,再配合定时差异校验,才是防超卖的完整解。
我的情况是这样:公司定了用消息队列同步库存,跑了两周后发现总库存数是对得上的,但某些SKU在两个系统里就是差几件。虽然最终一致,中间却给运营报了错误可售数。我想知道有哪几类方法能快速发现差异、定位到具体某一条消息?修复时该先调库存还是先锁单?
如果你的总库存对得上、个别SKU差,常见原因有三类:重复消费导致多扣;消费顺序颠倒导致状态覆盖;更新回传被网络或事务打断。我的排查习惯是:先跑差异,再查消息,再看业务日志,而不是直接调库存。可落地的差异发现手段,是建一个同步对账任务,每30分钟取一次两边库存表,按sku聚合并比较。
不要只比总量,要比每一个维度,包括可用数、锁定数、在途数。建议对账任务输出一张差异表,字段包括:sku_id、本端库存、对端库存、差异数量、首次发现时间、最后发现时间。这个表能帮你判断差异是持续存在还是偶发抖动。
定位到具体消息前,消息表必须有这几个字段:业务单据号、SKU、变更类型、变更数量、发送时间、消费时间、幂等键。有一次我们排查多扣的18件商品,就是靠幂等键查到了同一条消费记录被处理了两次,消费者线程在重试时没有检查唯一约束。后来加了消费去重表,同一幂等键只允许一条成功记录,问题立刻消失。
修复环节也有纪律。先评估差异是否会影响在售,优先让账面恢复,而不是立刻去改源头。需要先锁定该SKU的库存变动,再发起调库单,记录原库存、现库存、差异原因,由业务负责人审批。这样即使修错,也能回滚。那种直接update库存表的做法,成本低但严重消耗系统信任,宁可慢一点也要走审批。
我们有一部分门店在商场地下,网络很差,甚至收银时会断网,门店POS没法实时和总部数据库连接。网上搜到的都是在线实时同步方案,默认网络可靠。想问问有没有人真的在离线或弱网环境里成功落地过库存同步?是不是只能放弃实时同步,等到有网的时候再批量上传?怎么上传才能降低差异?
我的判断:在离线或弱网场景里,把“实时同步”作为目标本身就是错的。你需要保证的优先级是:门店能正常收银、账实同步可追溯、最后合并到总部当天能对上账。允许延迟到分钟级甚至小时级,这不是妥协,反而是把系统做简单、做可靠的关键。
我在零售连锁项目里落地的方案是:门店本地放一个轻量数据库,POS直接读写本地库存,订单落本地库。系统每收到一批订单,就生成一个增量明细包。店铺闭店后通过内网或4G VPN上传到总部,也可以用人工拷盘的方式先跑起来。数据流上是这样:交易明细落本地;门店打烊后执行一次增量打包;
总部收到增量文件后按门店维度合并,重算总仓的渠道可用库存;再生成一份差异报表回传门店。订单在上传过程中如果断网就重传,文件名带上业务日期和批次,便于幂等处理。第二批上传前,会先检查这个批次是否已合并,重复数据直接丢弃。这个方案上线后的具体数据:人工Excel时代,月差异率在2%左右,每月对账要3天;
换成增量文件合并后,差异率降到0.2%以下,而且每一笔差异都能追溯到具体单据。核心不是技术多先进,是每笔业务都有编号、每个文件都有幂等,差异可回查。所以,如果你的业务不要求跨店实时共享库存,真心建议别碰实时同步,把精力留给对账逻辑和异常处理。


读者评论
文章开头双11的库存事故太典型了,我们项目也遇到过类似问题,最后发现是口径不一致。作者把业务定义放在技术选型前面,这个思路很清醒。四种方案的匹配度表格很实用,特别是对延迟容忍度的分类,能直接指导选型。
做库存同步这些年,最怕的就是一上来就聊技术,忽略业务场景。作者用“先定场景再选技术”这条主线,把接口直连、MQ、定时任务、CDC的适用条件讲得很清楚。CDC那段提醒很到位,高写入库的监听成本确实容易被低估。
不算一篇操作手册,但比堆代码的文章有用。五种误区里“事实源不清”那条深有体会,我们系统上线半年多了还在扯皮。决策公式(延迟容忍度、事实源归属、耦合度、运维成熟度)可以作为检查清单用。
库存同步本质是数据一致性问题的观点很赞同。MQ不是银弹,消费端的幂等和补偿才是核心。文章里“同步成功不等于数据正确”说得太对了,我们踩过重复消费的坑,最后也是靠独立对账机制发现的。希望补充对账设计细节。
作为非技术背景的运营,最受益的是“口径不一致”那段。ERP实仓数、电商可售数、OMS可用数,三个系统三个数,对不上是必然的。希望技术团队也按这个思路,先定义事实源和延迟容忍度,再选同步方式,避免每次大促都出问题。