☰
跨库事务难落地?用本地消息表实现下单扣库存的最终一致性
2026/10/21 4:33:37 网站建设 项目流程

业务同学把需求扔过来的时候,原话很简短:“一个简单的跨库事务问题,你给个方案就行。”他眼里的“简单”,是整条需求读下来只有一句话:下单成功之后要扣掉库存。可一旦真正落到技术实现上,问题就会变成三连问:订单数据在订单库,库存数据在库存库,两边怎么保证同生共死?一边成功了、另一边失败时,订单和库存谁来赔?等到半夜对账发现少了一张单,是用程序补还是手动改?

很多后端开发第一次接触跨库事务,都是从“一个简单的跨库事务问题”开始的。需求描述从来不复杂,复杂的是背后那一整套一致性保障机制。这篇文章我不打算泛泛讲分布式事务理论,而是从一个真实、高频、又足够典型的场景入手:下单扣库存。先弄清楚“跨库”到底跨的是什么,再解释为什么本地事务一拆库就失灵,然后对比常见的几种解法,最后给出我自己在项目里验证过的落地细节和排错经验。适合正在做微服务拆库、双写改造、订单库存类系统的后端同学参考。

1. 先给跨库问题划个边界:它到底跨的是什么

跨库事务这四个字,拆开看就三个元素:一个事务,两个或以上的库,一个必须保证“要么全成、要么全败”的业务动作。但“库”这个东西,在实际系统里有很多种存在形式,不同形式带来的故障模型完全不同。

1.1 “跨库”在实践中至少有四种形态

第一种是同一个数据库实例里的多个 schema。比如一台服务器上装了数据库,里面既有order_db又有inventory_db,两者靠一次连接就能访问。这种跨库最轻,理论上如果数据库支持跨 schema 的事务,甚至可以直接用一条分布式事务协议包起来。但很多团队并不会开启这种能力,因为一旦实例故障,所有库一起挂,依赖“单实例”的强一致没有本质区别。

第二种是同一个机房里的多个数据库实例。订单在一个实例,库存在另一个实例,网络延迟低、故障率也低,但已经出现了真正意义上的“两台机器各自独立提交”的问题。没有统一的协调者,就不能保证两边同时成功或同时失败。

第三种是跨网络、跨机房的多个服务。微服务架构下最常见的形态:订单服务用订单库,库存服务用库存库,两者通过 RPC 或消息队列交互。这个形态里,网络分区、超时、重试、幂等等问题全都冒出来了。

第四种是跨存储引擎。比如订单量在关系型数据库,库存热点在缓存,支付流水在另一个同步工具。这类场景下,连“事务”的语义都要重新定义。

我见过很多“简单”的跨库需求,最后做复杂,都是因为没有先确认自己属于哪一种形态。不同形态对应不同的可选方案:同库不同 schema,也许不需要引入跨库事务;跨服务跨库,大概率要接受最终一致性。先谈形态,再谈方案,这个顺序不能省。

1.2 简单需求背后的真实现场

抛开理论,实际项目里“跨库事务”基本都是被架构拆出来的。最典型的是微服务改造:一开始一个单体应用、一个数据库,后来为了团队分工、为了独立发布、为了分开扩容,把表按域拆开。订单归订单服务管,库存归库存服务管,它们各连各的库。拆的时候大家都很开心,直到第一个“连环写”需求出现。

还有个常见现场是冷热分离。在线业务库只保留近期数据,历史数据定期归档到另一个分析库。业务操作既要更新在线库的当前状态,又要异步写入历史库的变更记录,一旦写入失败,日志和实际状态就对不上。

另一种是系统合并。公司收购了一个小项目,两边数据库结构不统一,业务要求“同一笔支付在两个系统里都记账”。这种场景最痛苦,因为你不光要处理技术上的事务,还要处理两套逻辑之间的口径差异。

这些案例有一个共同点:需求描述都是一句话,但实现时都要求“两个地方的数据状态不能打架”。我把这种要求称为跨库一致性。它不一定非要用数据库事务解决,但必须先被明确成问题,而不是被“套个事务”糊弄过去。

2. 为什么单库能保证一致,拆成两个库就忽然不灵了

先回答一个很多人其实没认真想过的问题:为什么在一个库里,事务做得那么自然?

因为单库事务的所有关键环节,都被同一个存储引擎包办了。它独占数据文件,管理事务日志,掌握所有锁的状态,当需要回滚时,它能根据日志把每一条修改恢复原状。原子性、一致性、隔离性、持久性,本质上是一台机器上的“内存态”。一旦拆到两个库,这个前提就没了。

2.1 本地事务的“全局视野”完全依赖单一引擎

你可以把单库事务想象成一间只有一个管理员的大仓库。管理员知道每一个货架上放了什么,知道哪些货物正在被出库,出到一半发生火灾,管理员能根据手里的台账把所有货架恢复原状。这个管理员就是数据库引擎,台账就是事务日志。

跨库之后,相当于把货物分到了两间仓库,各有一个管理员,两个管理员之间没有共同的台账。A仓库告诉客人“发货成功”,B仓库那边却停电了,出库手续没办成。A仓库管理员再能干,也无法撤销B仓库没发生的动作,因为两个仓库之间没有一根“统一回滚”的线。

现实中这根线很难存在,因为它必须跨越网络协议、跨越故障域、跨越两套独立的日志系统。数据库厂商可能有自己的方案,但你不能假设两个库都来自同一家、都开了同样的配置。所以从架构层面看,跨库事务要解决的,本质上是“两个独立故障域之间的状态协商”,而不是什么SQL层面的小问题。

2.2 2PC 在理论上很漂亮,落地时却经常变成追账现场

顺着“怎么协商”这个问题往下走,教科书会把你带到两阶段提交。两个库的事务都先进入预备状态,各自告诉协调者“我准备好了”,协调者确认所有参与者都准备好了,再下达最终的提交命令。

听上去很完整,但实际落地时有几个很难吞下去的副作用。

首先是锁。为了防止“我准备好了”之后又被别人改掉数据,参与者必须把涉及的数据锁住,一直锁到协调者发出最终指令。网络快的时候还好,协调者一宕机,这些锁就可能挂很久,线上业务直接卡死。

然后是协调者本身。它要是准备阶段之后、提交阶段之前挂掉,所有参与者都会停在一个“既不能说成功、也不能说失败”的中间态。这个状态在数据库术语里叫悬空事务。你在运维时往往需要写脚本去人工核对,过程非常痛苦。

我在某个项目里见过类似的情况:核心链路用了全局事务中间件,开发期一切正常,到了线上大促之前压测,发现一个大事务锁住几张热点表,接口耗时直接从 30ms 涨到 2s。后来我们做了大量优化,把大事务拆小,才把性能救回来。那次之后我就记得一句话:2PC 这类方案,强一致性是真实存在的,代价也是真实存在的,它适合低频、核心、资源充足的小范围场景,不适合作为所有跨库操作默认解。

3. 扫一遍市面上的“简单”方案,先说清楚各自的隐性成本

既然全局事务不是默认解,那还有什么路?我见过不少团队从“用全局事务”跳到“用消息队列”,似乎只要把同步事务改成发消息,问题就自动消失了。这同样是个幻觉。方案没有绝对好坏,只有成本和场景是否匹配。

我把主流方案放在一张表里,先看总结,再逐个解释。

方案一致性强度核心机制主要隐性成本适合场景
全局事务/2PC强一致两阶段提交锁冲突、协调者单点、故障恢复复杂低频、强一致、资源宽裕
事务消息最终一致事务消息或本地消息表需要消费者幂等,依赖消息中间件微服务异步解耦
本地消息表/发件箱最终一致业务库写事件表,异步投递额外表、定时任务、人工对账无 MQ 或想降低依赖
Saga 补偿最终一致/可补偿正向事务+补偿事务补偿逻辑复杂,易漏补偿长流程、多步骤业务
定时对账+人工修复最终一致批处理核对差异延迟高,不能应对高频失败低频对账、兜底

这张表里没有任何一个方案是“零成本”。选择的关键不是追求看起来最先进的那个,而是想清楚自己愿意为一致性付出多少代价。

3.1 全局事务中间件:表面简单,代价藏在运维侧

很多中间件做得确实很易用,开发只需要在方法上打个注解,它就会在后台启动整个事务协调流程。我实际用下来的最大感受是:开发简单,运维不简单。你需要在生产环境部署协调者,需要配置参与库的账号权限,当事务卡住时,还要能看得懂日志、做得出手动处理。

更麻烦的是,这类方案通常要求所有参与方支持标准协议。如果其中一个库是历史遗留系统,或者用的是不带完整事务能力的产品,全局事务就无从谈起。所以它在金融、账务这类标准化程度很高的系统里很常见,在五花八门的业务系统里反而没那么好使。

3.2 事务消息与本地消息表:最终一致的经典路线

这条路线的核心思路很直白:不强求两边同时提交,而是先把“本次操作要发生”这个事实可靠地记录下来,然后异步通知对端去执行。对端执行成功,则万事大吉;对端执行失败,就重试;重试到无能为力,就转入死信人工处理。

事务消息把“记录事实”这件事交给了消息中间件来兼容,本地消息表则是自己实现“记录事实”。两者的最终效果都是为了得到一条不会丢、可以追、能重放的事件流。

这个方案的隐性成本主要在消费端。消费者必须做好幂等,否则重试一次就多扣一次库存,比跨库不一致还可怕。其次是延迟,从“订单已提交”到“库存已扣减”之间会有一个可见的时间窗口,业务上必须接受这种短暂不一致。

3.3 Saga 补偿:适合长流程,不适合短平快

Saga 把一个大事务切成多个本地事务,每个本地事务成功之后,记录一个反向补偿动作。假设流程是“创建订单 -> 扣库存 -> 调用支付”,只要任一步失败,就把之前成功的步骤用补偿逻辑退回去。

听上去比 2PC 灵活,但它有一个绕不开的问题:补偿逻辑得自己写,而且补偿本身也要幂等、也要可靠。我见过一个项目,正向流程三天写完,补偿和逆向对账写了两个星期。如果你只是一个“下单扣库存”的短流程,引入 Saga 大概率是过度设计,本地消息表已经够用。

4. 用本地消息表重写“下单扣库存”,从强锁走向最终一致性

下面用一个具体案例收拢前面的讨论。假设场景是这样:

  • 订单服务:负责创建订单,订单数据存在order_db。
  • 库存服务:负责扣减库存,库存数据存在inventory_db。
  • 用户下单时,订单库要新增订单记录,库存库要扣减对应商品库存。两者不能出现“订单成功但库存没扣”的情况。

4.1 先看最容易想到的伪方案:顺序写两个库

一开始最容易想到的是在订单服务里,先写订单库,再通过 RPC 调库存服务,让库存服务去扣库存。代码大概是:

public Long createOrder(OrderCreateCmd cmd) { Long orderId = orderDao.insert(cmd); inventoryService.deduct(cmd.getProductId(), cmd.getQuantity()); return orderId; }

这不算跨库事务,而是顺序调用。它的问题非常直观:如果第二行调用库存服务时超时,或者库存服务内部报错,订单已经入库了,库存却没扣。更坏的是,如果库存服务其实执行成功了,只是返回值丢了,订单服务重试一次,库存又会被扣两次。

有人会说,那我把两个操作放在一个本地事务里不就好了?问题是inventoryService.deduct走的是另一个服务连接,本地事务没法把远程调用纳入回滚范围;即便纳入,远程事务已经提交,回滚也无能为力。

这种伪方案就是“简单跨库事务”最常见的翻车现场。代码看着很顺,测试环境也能跑通,上生产之后一遇到网络抖动,订单和库存就开始对不上。

4.2 订单库先落本地事实,再把扣库存变成一条事件

本地消息表方案的骨架是这样:订单服务在自己的数据库里,先做“订单新增”和“待办事件写入”两件事,这两个操作在一个本地事务里提交。随后由一个后台任务或消息中间件把事件投递给库存服务。

表结构可以这样设计:

-- 订单库:订单表 CREATE TABLE orders ( id BIGINT PRIMARY KEY, product_id BIGINT NOT NULL, quantity INT NOT NULL, status VARCHAR(32) NOT NULL ); -- 订单库:发件箱表,也叫 outbox CREATE TABLE outbox ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, payload VARCHAR(2000) NOT NULL, created_at DATETIME NOT NULL, published_at DATETIME DEFAULT NULL, UNIQUE KEY uk_event_id (event_id) );

创建订单的方法变成:

@Transactional public Long createOrder(OrderCreateCmd cmd) { Long orderId = orderDao.insert(cmd); outboxDao.insert(new OutboxEvent( UUID.randomUUID().toString(), "STOCK_DEDUCT", "{\"orderId\":\"%s\",\"productId\":\"%s\",\"quantity\":%d}" .formatted(orderId, cmd.getProductId(), cmd.getQuantity()) )); return orderId; }

发件箱表虽然是同一库里多出来的一张表,但它带来的价值是:只要订单数据能在,事件数据就必然能在,因为它们在同一个数据库事务里提交。这样就不会出现“订单都建了,扣库存的指令却没留下痕迹”的问题。

4.3 消费侧的幂等与执行:库存库的独特索引是真正的防线

库存服务不需要直接暴露一个需要前端等结果的同步接口,而是消费事件。库存库里加一张操作流水表,专门记录每一次扣减动作:

CREATE TABLE stock_operation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, business_id VARCHAR(64) NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_business_id (business_id) );

这里的business_id必须和上游事件里的orderId对应,保证同一笔订单的扣减只生效一次。消费端逻辑大致是这样:

  1. 接收STOCK_DEDUCT事件。
  2. 尝试向stock_operation插入一行,business_id用订单号。
  3. 如果插入成功,说明这笔扣减还没处理过,继续更新库存余量。
  4. 如果插入时触发唯一键冲突,说明这是一条重复消息,直接返回成功,什么都不做。

这样做的好处是状态判断完全交给数据库的约束,不靠“先查一下有没有处理过再插入”这种存在并发缝隙的方式。库存余量不够时怎么办?先不做任何扣减,记录一条“扣减失败”的事件,触发订单侧的失败处理,把订单标记为失败或走补偿,而不是在库存表上强行写负数。

最终一致性的味道在这里体现得很清楚:用户下单后的极短时间内,订单可能是“已创建”,库存还没扣;几毫秒或几秒之后,库存扣减成功,流程闭合。对这个业务来说,这种看不见的短暂窗口完全可以接受。

5. 真正会咬人的都是细节:幂等、乱序、死信与对账

方案搭起来之后,难题才真正开始。我发现线上最大的坑,几乎都集中在四个细节上。

5.1 幂等设计必须放在数据库约束层,不要寄希望于“先查后写”

我第一次做消费端幂等时,用的是“先 select 查一下,如果存在就跳过”。单测怎么测都通过,结果并发压测时还是出现了重复扣减。原因很简单:两个线程同时收到重复消息,同时查,都没查到记录,然后一起插入,一起扣减。

之后我把所有幂等判断都改成“唯一索引 + 插入失败则忽略”的套路。只要数据库保证唯一约束,重复插入必然有一个失败,失败的那个直接当成功返回。代码是很简单的,但很多初接触的人会以为“先查后写”就够了。事实是,幂等不能靠业务判断,只能靠数据库约束,这是我在多个项目里反复验证过的一条铁律。

5.2 消息乱序和迟到:最终一致的常见次生灾害

本地消息表只保证了“事件不丢”,不保证“事件一定按顺序到达”。一旦有重试,迟到事件就可能乱序。比如有两个操作:先把库存从10扣到8,再把8回补到9。如果第二条消息因为网络原因先到了,业务上就可能出现“版本跳跃”或者直接失败。

针对具体场景,有几种做法。对同一商品的库存操作,最好按商品维度做串行;无法串行时,在事件里带上版本号或操作时间戳,消费者判断是不是旧操作,旧操作直接忽略。这里的原则是:能通过数据结构避免的乱序,就不要硬写成复杂的判断逻辑。

拉取模式比推送模式更容易控制顺序。本地消息表的定时扫描天然是顺序读取的,我把这当成一个顺手的好处:只要单线程消费者按created_at扫描,事件不会在源头就乱。

5.3 死信、重试上限和人工对账:最终一致性是运维出来的

消息重试不能无限进行。我常用的做法是给outbox或消费日志加一个retry_count字段,超过上限就更新状态为dead,同时触发告警。读到死信时,运维需要拿着原始事件和两边库的数据做核对,决定是重发、补偿,还是彻底放弃。

这里我要特别强调:不要直接改数据库状态来“修复”数据。改库一时爽,改完之后所有审计链路都断了。正确做法是构造补偿事件或重发事件,让系统自己把状态拉回来。哪怕慢一点,也要保证所有操作在业务日志里都有迹可循。

我还习惯在每天凌晨跑一个对账任务,把订单库的待支付/已支付订单和库存库的扣减流水做一次比对。差异会单独落表,第二天人工处理。这个兜底动作看起来土,却是分布式系统里最让人安心的防线。

6. 上线自测清单和事后排错路径

本地消息表方案能不能上线,不能靠拍胸脯。我自己在上线前会按这套清单走一遍,每一题都有明确的“过”或“不过”的判断标准。

第一题是故障注入。把订单库写成功后、事件扫描任务被暂停的状态持续几分钟,然后恢复扫描,确认积压的outbox事件能在恢复后全部处理干净,没有丢失。这个场景里失败的不是代码逻辑,而是调度服务本身。

第二题是重复消息模拟。手工把某条STOCK_DEDUCT事件重复投递两次,看stock_operation是否只有一行记录,看库存是否只扣减一次。设计这道题的时候,最好选择一条线上真实跑过的高频商品,不要自己造一个没有并发压力的假数据。

第三题是并发竞态。把某个商品的库存初始值设为 1,同时发起 10 笔下单请求。正确结果应该是:只有 1 笔最终创建成功,其余 9 笔要么在库存侧被拒绝,要么在订单侧被反向处理,库存最终值不变为负数。

第四题是网络超时场景。人为让库存服务的 RPC 挂起 5 秒,确认订单服务不会因为超时回滚而漏掉本地事件。因为事件已经落在outbox表里,重试机制会接管后续流程。

第五题是监控可见。上线前必须确认:outbox表积压量有指标,消费失败率有指标,死信数量有告警,对账任务有结果表。没有这三件套,最终一致性方案就是裸奔。

上线之后,排错路径也有固定的顺序。先查outbox有没有未发送事件;再查库存侧有没有对应business_id的stock_operation;然后查消费日志里有没有抛异常;最后查死信表。一般走到第三四步,问题就能定位。最怕的是直接打开数据库查订单状态和库存快照,查到差异也不知道该怪谁。

7. 决策复盘:多数项目需要的是取消跨库,而不是一个更牛的全局事务

最后把视角拉高一点。标题里那个“简单的跨库事务问题”,我见过太多次,最后都变成了“不简单的分布式一致性事故”。所以我想把决策思路放在收尾部分,它比具体代码更重要。

遇到跨库需求时,我基本按这张表来做判断:

业务特征推荐做法理由
两个表本来可以在同一库,只是项目拆得乱先合并数据归属或做单库事务用最小的复杂度换取最强的保证
低频、强一致、资金相关全局事务/2PC,但严格控制范围和时长愿意为强一致买单
高频、短流程、有一方异步无感知本地消息表 + 消费者幂等综合成本最低
多步长流程、存在明确补偿动作Saga 或事务消息 + 补偿逻辑避免单点大事务
同步、强一致、跨服务跨库尽量从架构上消除,不硬做事务跨服务强一致的成本会持续叠加

你会发现,最优先的策略永远不是“选一个更强大的事务方案”,而是“想办法让跨库操作消失”。如果订单和库存本身属于同一个业务聚合,能不能把它们放进同一个物理库?如果只是报表类需求,能不能容忍异步同步,不再走同一事务?如果两个服务其实可以由同一个服务统一管理,那跨库问题就从源头被取消了。

我见过不少团队,为了一个本可以合并的数据模型,硬生生引入全局事务中间件,结果每天的运维都耗在排查悬空事务和死锁上。这些成本,远比最初“拆库”时省下的那点扩容收益要高。跨库事务的本质,是对系统边界的挑战。边界划得越细,一致性成本越高;边界划得合理,事务问题就越少。

在我自己维护的几个交易链路里,真正可靠的做法始终是同一套组合拳:业务库本地写订单和事件,消费者靠唯一索引幂等,定时任务兜底重试,对账单每天核对差异。这套方案不性感,甚至有点土,但它扛过了很多次线上抖动,也让我在半夜接电话的次数明显下降。下次再听到“一个简单的跨库事务问题”,我大概会先笑一笑,然后把这句话翻译成“一个需要先想清楚边界、再决定要不要引入复杂度的问题”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询