微服务最大的坑:A 扣钱了 B 报错,钱去哪了?(分布式事务详解)
先说一个我处理过的真实事故,场景你一定不陌生:用户下单,订单服务 A 先调支付服务扣款,扣款成功后再去调库存服务 B 扣减库存。结果 A 返回成功、钱确实扣了,B 却因为库存服务临时宕机直接抛了个超时。用户那边看到的是"订单创建失败,请重试",等他再下一单,又被扣了一次。后台一查,既没有订单记录也没有扣减记录,钱就那么凭空"消失"了一笔——准确说,是卡在了某个无人认领的中间状态。那段时间我每天都在回答同一个问题:这单到底成没成?钱到底扣没扣?
这就是微服务化之后最典型的分布式事务问题。单体应用时代,一个数据库事务就能把所有读写包起来,要么全成功要么全回滚,ACID 帮你兜底。但微服务把数据按业务边界拆到了不同的库、甚至不同的服务器上,本地事务的边界被切断,原来一次commit能解决的事,现在变成了跨服务、跨网络、跨数据源的"多方协作"。而网络是不可靠的,服务是会挂的,消息是会丢的,事务不再天然成立。
这篇文章我把分布式事务这件事从头到尾捋一遍:为什么单体里没问题、微服务里就成了坑,主流的几种方案各有什么代价,从"扣钱"这种真实场景出发该怎么选,最后给一套可以直接落地、带代码和表结构的参考实现,以及我实际踩坑之后沉淀下来的排查思路。不管你是刚接手微服务项目的新人,还是已经被线上"数据不一致"折磨过的老手,这篇应该都能给你一些可参考的东西。
1. 先搞清楚问题根源:为什么微服务里事务会失效
1.1 单体时代的"安全区":ACID 到底帮你扛住了什么
在聊分布式事务之前,得先明白单体应用里数据库事务为什么能那么"理所当然"。一个事务有四个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),简称 ACID。你执行BEGIN,做一堆操作,然后COMMIT,数据库保证这中间任何一步出错,之前的操作全部回滚,数据回到事务开始前的状态。你不需要关心"扣款成功但插订单失败"这种场景,因为它们在同一个数据库连接、同一个事务里,根本不可能出现部分成功。
这里有个容易被忽略的点:ACID 承诺的"一致性"并不是业务层面帮你算好账,而是保证数据库从一个合法状态转到另一个合法状态。真正的业务规则——比如"扣了钱必须有对应订单"——是写在事务内部的代码逻辑里的。事务只是给你画了一个安全区,在这个区里你做任何事,要么全部生效,要么全部不生效。
单体时代的所有服务都在这个安全区里,所以没人觉得事务是个需要讨论的话题。你只需要保证代码顺序别写错,剩下交给数据库。
1.2 拆分之后,事务边界被切在服务之间
微服务改造之后,这个安全区被打碎了。订单数据在订单库,支付流水在支付库,库存数据在库存库,甚至这三个库分布在不同的机器上。你当然可以尝试用一个跨库的分布式事务去包住三个库,但这引来了两个问题:性能和可用性。跨库事务需要协调多个节点,延迟从毫秒级变成几百毫秒甚至秒级,而且任何一个节点挂了,整个事务都得挂着等。对于面向用户的接口来说,这是不可接受的。
更关键的是,微服务的核心思路是"每个服务自己管自己的数据、自己的事务"。支付服务只知道自己的钱扣没扣成,订单服务只知道自己的单建没建成,它们之间通过接口调用、消息通知来协作,但没有任何一个全局协调者去保证"钱扣了单就必须建成"。一旦出现网络超时、服务重启、消息丢失,系统就会进入一个"谁都不知道对方做了什么"的混沌状态。
所以分布式事务要解决的,本质上不是"能不能像单体一样全回滚",而是"当一个服务成功、另一个服务失败时,系统最终能达成一致——要么一起成功,要么一起失败,哪怕这个一致是延迟达成的"。
1.3 你以为的"报错"只是表象,真正的问题是状态残留
回到开头的场景。A 扣款成功,B 报错,这个"报错"只是你看到的现象。真正的问题是:扣款记录已经持久化到支付库了,但订单库里没有对应订单,库存库里没有对应扣减。三个数据源各自处于不同的状态,就像三个人各拿一张纸条,上面写了三个版本的事实。
如果只是接口报错让用户重试,问题会叠加:用户重试,支付服务可能再次扣款;就算你加了"短时间内同一用户同一金额只扣一次"的防重,这个标记也存在一个数据源里,另一个服务并不知道。最终你面对的不是一笔坏账,而是一堆残留的中间状态——支付成功但无订单、订单已建但未付款、库存已扣但订单取消,等等。
理解了这一点,后面所有方案的核心思路就清晰了:**不是追求一个神奇的机制让" A 成功 B 就必须成功",而是接受" A 和 B 可能短暂不一致"这个事实,然后通过日志、消息、定时任务、对账等手段,把系统最终拉回一致状态。**这也就是 CAP 理论里讲的,分布式系统在分区故障(P)发生时,只能在一致性(C)和可用性(A)之间二选一。分布式事务的种种方案,本质上都是在不同维度上寻找一个可以接受的平衡点。
2. 主流的分布式事务方案,逐个拆开看代价
2.1 两阶段提交(2PC/XA):理论最正统,实践最痛苦
两阶段提交是最早的分布式事务方案,很多中间件都实现了 XA 规范。它的流程分两步:第一阶段叫准备(Prepare),协调者问所有参与者"能不能提交",每个参与者把事务相关操作执行完但没有真正提交,而是处于待提交状态,并回复"准备好了";第二阶段叫提交(Commit),协调者收到所有人"准备好了"就发提交指令,任何一个参与者说"不能提交"或者超时没回应,协调者就发回滚指令。
听起来完美:要么全部提交,要么全部回滚。但代价非常沉重。首先,Prepare 阶段所有的资源都要被锁定,事务期间这些数据谁都别想动,在并发场景下是灾难,吞吐量直接断崖式下跌。其次,协调者是单点,一旦协调者在第二阶段挂了,所有参与者都卡在"待提交"状态,数据被锁死,业务全部瘫痪。最后,参与者之间如果有网络分区,协调者永远等不到某个节点的回应,整个事务只能一直挂起。
我在实际项目里几乎没有见过生产环境用纯 XA 方案跑核心链路的,它不是技术上做不到,而是它在分布式系统里引入了一个比"数据不一致"更严重的故障模式:整个系统被一个慢节点拖死。它适合的领域非常窄——比如数据库本身需要强一致、并发极低的内部系统。对于互联网业务那种高并发、高可用要求的场景,2PC 基本可以划掉。
2.2 TCC(Try-Confirm-Cancel):把业务拆成三段,补偿靠写业务代码
TCC 是另一种经典的强一致方案,核心思想是把每个服务的业务操作拆成三个阶段。Try 阶段:尝试执行业务,完成所有资源的预检查和预留;Confirm 阶段:确认执行业务,真正提交;Cancel 阶段:取消执行业务,释放预留资源。拿扣款举例,支付服务的 Try 就是"冻结用户 100 元",Confirm 就是"确认冻结,真正扣走",Cancel 就是"解冻,退回 100 元"。
TCC 的优势是它是业务层面的方案,不依赖数据库的全局锁,灵活性高;但它最大的问题也出在"业务层面"这四个字上——你需要为每一个参与事务的服务手写 Try、Confirm、Cancel 三套逻辑,而且这三套逻辑必须满足幂等。订单服务要把"创建订单"拆成"预留订单号/预创建草稿"+"确认建单"+"删除草稿",库存服务要拆成"预占库存"+"确认扣减"+"释放库存"。这一套代码下来,工作量不是翻倍,是乘了三倍,而且业务复杂之后,Cancel 逻辑的正确性极难保证——你确定你的 Cancel 一定能成功?Cancel 本身失败了怎么办?
TCC 的另一个隐性问题是它假设每个服务都能提供"预留"能力。很多老系统根本没有"冻结余额"这种概念,只有"扣减余额",你要么改底层表结构,要么引入额外的"冻结表",这都意味着对存量系统的侵入性改造。TCC 不是"不需要考虑一致性"的银弹,而是把一致性保证的复杂度从框架转移给了业务开发人员。
2.3 本地消息表:最朴素,但最容易落地
本地消息表(Local Message Table)又叫 Outbox Pattern,思路非常接地气:在发起方的数据库里,除了业务表之外再建一张消息表。业务操作和写消息表放在同一个本地事务里,要么都成功,要么都失败。比如订单服务在同一个事务里做两件事:扣库存(自己的业务)+ 往消息表插入一条"库存已扣"的消息。这两个操作在一个数据库事务里,不可能出现"扣了库存但没写消息"的情况。
之后,一个异步任务定时扫描消息表,把状态是"待发送"的消息发出去给下游服务(比如通过 MQ),下游处理成功后回执,再把消息状态改成"已发送"。如果发送失败,消息留在表里,定时任务下轮继续拉。只要保证消息表的数据不丢,下游收到消息后做幂等处理,最终一致性就能达成。
说实话,这个方案并不高大上,但它有几个实打实的优点:不依赖任何重型中间件,一张表 + 一个定时任务就能跑;本地事务保证业务和消息的一致性,逻辑上非常干净;排查问题的时候,消息表里的数据就是完整证据链。缺点是:需要自己写定时任务和重试逻辑;消息表会随着业务量增长而膨胀,需要定期清理;如果下游长时间不消费,消息堆积在业务库里,会给数据库带来压力。但对大多数团队来说,它的简单、可控、可排查,远超它的缺点。
2.4 事务消息(半消息):把"本地消息表"下沉到 MQ
事务消息是 RocketMQ 提供的能力,可以理解成"把本地消息表逻辑搬到了 MQ 里"。流程是:业务方先发送一条"半消息"(Half Message)到 MQ,此时消息在 MQ 里是"待确认"状态,消费者看不到也消费不了;然后业务方执行本地事务;本地事务执行完后,向 MQ 提交确认(Commit)或回滚(Rollback)指令。如果本地事务执行过程中应用崩溃了,MQ 那边会反向询问(回查)业务方:你那个本地事务到底成没成?业务方需要提供一个回查接口,告诉 MQ 真实状态,MQ 根据回查结果决定把消息投递给消费者还是删除。
这套机制的好处是本地消息表的定时任务逻辑被 MQ 接管了,而且消息的可靠性有 MQ 专业保证。代价是:你得引入 RocketMQ(或者其他支持事务消息的 MQ),有一个回查接口要写,而且回查接口本身也必须实现幂等。另外事务消息只解决"消息发送方的事务"问题——它保证的是"本地事务成功则消息一定可靠投递",但消息消费方的处理失败仍然需要靠重试和幂等来解决。
所以事务消息和本地消息表在核心思想上完全一致,只是一个把复杂度藏在业务代码里,一个把复杂度交给中间件。如果你的团队已经用了支持事务消息的 MQ,优先用中间件方案;如果没这个条件,本地消息表一点不比它低端。
2.5 最大努力通知:不保证强一致,但现实够用
最后一种方案最"佛系",它不追求业务操作和通知同时成功,而是尽最大努力把结果通知到对方,通知失败了就反复重试,直到成功为止。最常见的实现是基于定时任务的重试表,比如支付回调通知业务方:"你这笔订单支付成功了",通知失败就 30 秒后重试、5 分钟后再试、1 小时后再试,退避时间逐渐拉长。
这种方案适合什么场景?业务方不要求实时拿到结果,接受延迟;而且需要通知方主动告知的异步业务。比如支付平台通知商家"结算完成",商家晚几分钟收到没关系,但不能一直收不到——所以重试到最终成功,或者人工兜底介入。
很多人觉得"最大努力通知"不算分布式事务方案,但我认为它恰恰是分布式事务体系里最实用的一环。因为它承认了一点:**任何分布式系统都无法保证一次尝试就成功,真正保证一致性的,是持之以恒的重试,而不是一次性的提交。**你甚至可以把消息表方案里的"重试发送"理解成最大努力通知的一种特化实现。
2.6 各方案快速对比
| 方案 | 一致性级别 | 数据源要求 | 侵入性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 2PC/XA | 强一致 | 需支持 XA | 高 | 高 | 极少用于互联网核心链路 |
| TCC | 最终一致/偏强 | 业务需预留能力 | 最高 | 最高 | 资金类强管控场景 |
| 本地消息表 | 最终一致 | 仅需单库事务 | 低 | 低 | 大多数业务场景首选 |
| 事务消息 | 最终一致 | 需引入 MQ | 中 | 中 | 已有 MQ 的团队 |
| 最大努力通知 | 最终一致(弱) | 无特殊要求 | 低 | 低 | 通知类、对账类场景 |
3. 方案选型:别急着上框架,先想清楚一致性等级
3.1 你的业务到底需要哪种一致?
聊方案之前,最该做的一件事是回答一个问题:**这个业务真的需要同步强一致吗?**我见过不少团队一上来就说"我们用分布式事务框架",问起业务场景,不过是"创建订单后要给他发个短信通知"——这种场景明明可以用异步消息,根本不需要把两件事放进同一个事务。过度设计是分布式事务项目里最常见的失败原因。
按一致性需求,业务可以粗分为几类:资金类(扣款、转账、退款)要求最高,通常需要严格最终一致甚至强一致,每一笔都要对得上;订单类(下单、支付、库存联动)要求最终一致,用户能接受"下单后稍等片刻看到扣减结果";通知类(短信、推送、站内信)要求最低,晚几分钟甚至丢了都能接受,重试几次就行。
拿资金类举例,支付服务扣款成功之后,通知账户系统入账,这两个操作如果拆成两个服务,你需要的不是一个让"扣款的同时入账"的分布式事务,而是一个能保证"扣款记录不丢、入账最终完成、两边账目可对平"的账务体系。这就指向了对账 + 重试 + 消息表,而不是 2PC。
3.2 一个务实的组合策略:消息表 + 幂等 + 定时对账
我自己在项目里最常用的组合,是本地消息表 + 消费方幂等 + 定时对账。为什么不用 TCC?因为业务团队人力有限,为每个服务写三套逻辑不现实。为什么不用 2PC?因为为了一个下游接口的偶尔超时,把整个链路的可用性拉低,账算不过来。为什么用消息表?因为它把"业务操作"和"通知下游"的耦合切成了一条清晰的生产者—消费者链路,每一环都有迹可循。
具体链路是这样的:订单服务 A 在自己的库事务里"插入订单 + 写入消息表";一个定时任务(或 MQ)把消息表里的待发送消息投递到库存服务 B;B 消费消息,处理扣库存;B 处理成功后回执;A 更新消息状态。如果 B 处理失败,消息留在表里,A 的重试任务继续投递。如果 B 处理成功但回执丢了,A 会重复投递,但 B 有幂等,重复处理不会造成重复扣减。如果整个链路最终都失败,还有定时对账兜底——通过比对订单表、消息表、库存表的数据,找出"失败但没补偿"的记录,人工或脚本介入。
这套方案里没有全局锁、没有协调者、没有复杂的状态机,但它把一个分布式问题降维成了一连串可以监控、可以排查、可以手动修复的单点问题。复杂度不是消失了,而是被拆小了,拆到每一环都能独立理解和处理。
3.3 选型的底层逻辑:C 端和 A 端怎么平衡
我知道很多人会问:那我的核心链路要求"用户付了钱必须立刻看到购买成功",用消息表异步通知,用户在页面上看到"处理中"体验不好怎么办?
这个问题的答案其实是:用户需要的不是"实时一致",而是"实时反馈"。你可以先返回"下单成功,正在处理",通知用户稍后查看结果;也可以做成页面轮询或推送,让用户感知到系统在推进。真正不能接受的不是"延迟",而是"付了钱之后什么都没有"。所以你会发现,实际生产里很少见哪个电商在下单和库存之间用同步强一致事务,基本都是异步最终一致。
如果你的场景确实需要强同步(比如同一个接口里必须同时完成扣费和发券,用户立刻看到两个都成功),那可以考虑 TCC 或者让两个操作都在一个服务里做——也就是"能不拆就不能拆"。微服务拆分的设计原则里有一条被经常忽略:**没有明确事务边界和一致性需求的业务,最好留在同一个服务、同一个库里。**很多分布式事务难题,根源其实在架构阶段没有想清楚该不该拆。
4. 实操:用"下单扣库存"场景手把手落地一套消息表方案
4.1 场景定义和表结构设计
场景:用户下单,需要同时完成"创建订单"(服务 A,订单库)和"扣减库存"(服务 B,库存库)。注意,这里为了演示,我把扣库存放在服务 B;真实系统的扣库存动作可以在服务 B 里做,但"订单和库存的联动"通过消息异步完成。
服务 A 的订单库需要两张表:orders(订单表)和outbox_message(本地消息表)。
CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-创建中 1-已确认 2-已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `outbox_message` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `msg_id` VARCHAR(64) NOT NULL COMMENT '消息唯一ID', `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型: ORDER_CREATED / STOCK_DEDUCT', `biz_id` VARCHAR(64) NOT NULL COMMENT '业务ID: 订单号', `payload` JSON NOT NULL COMMENT '消息体', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待投递 1-已投递 2-已确认 3-死信', `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数', `next_retry_time` DATETIME NOT NULL COMMENT '下次重试时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_retry` (`status`, `next_retry_time`), UNIQUE KEY `uk_msg_biz` (`biz_type`, `biz_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个细节值得解释。msg_id用 UUID 或雪花 ID 保证唯一;payload存 JSON 消息体,比如订单号和扣减数量;uk_msg_biz这个唯一键是幂等的基础——同一条业务消息只能有一条记录,重试不会重复插入;next_retry_time是定时任务扫描的依据,避免高频空扫。
4.2 核心流程一:本地事务里写业务 + 消息
创建订单的接口,核心代码大概是这个逻辑(伪代码):
@Transactional public void createOrder(CreateOrderRequest req) { // 1. 创建订单,状态为"创建中" Order order = new Order(); order.setOrderNo(genOrderNo()); order.setUserId(req.getUserId()); order.setAmount(req.getAmount()); order.setStatus(OrderStatus.CREATING); orderMapper.insert(order); // 2. 写一条待投递消息 OutboxMessage msg = new OutboxMessage(); msg.setMsgId(UUID.randomUUID().toString()); msg.setBizType("ORDER_CREATED"); msg.setBizId(order.getOrderNo()); msg.setPayload(json("{\"orderNo\":\"" + order.getOrderNo() + "\",\"skuId\":\"" + req.getSkuId() + "\",\"quantity\":" + req.getQuantity() + "}")); msg.setStatus(MessageStatus.PENDING); msg.setNextRetryTime(now()); outboxMessageMapper.insert(msg); }由于这两个 insert 在同一个事务里,订单和消息要么同时成功,要么同时回滚。这就是整个方案最关键的保证:消息一旦落库,说明业务操作一定成功了。后续无论发生什么,消息表里都有一条"待办事项"在等着处理。
有同学可能会问:为什么不直接调库存服务?因为同步调用的失败会导致整条创建订单失败,而我们已经把订单和消息写进去了,不能回滚。异步消息的好处是把"库存扣减失败"从"下单接口的同步路径"里剥离开,接口的响应速度和成功率都不受下游影响。
4.3 核心流程二:投递消息与消费幂等
投递环节我用一个最简单的方案:定时任务扫表投递。当然,你也可以把消息表里的记录转发到 MQ,让 MQ 做投递和重试,这是另一种串联方式,后面再讲。
@Scheduled(fixedDelay = 5000) public void pollOutbox() { List<OutboxMessage> pendingList = outboxMessageMapper.scanPending( MessageStatus.PENDING, now(), 100); for (OutboxMessage msg : pendingList) { try { // 投递给库存服务,这里是同步 RPC 或发到 MQ boolean ok = stockServiceClient.deductStock(msg.getPayload()); if (ok) { // 投递成功,标记完成 outboxMessageMapper.confirm(msg.getId()); } else { // 业务失败,记录重试 outboxMessageMapper.increaseRetry(msg.getId()); } } catch (Exception e) { outboxMessageMapper.increaseRetry(msg.getId()); } } }库存服务这边的消费逻辑,最核心的是幂等:
@Transactional public void deductStock(String payload) { DeductRequest req = parse(payload); // 幂等表里查一下这单是不是已经扣过了 StockLog log = stockLogMapper.selectByOrderNo(req.getOrderNo()); if (log != null) { return; // 已处理过,直接返回成功 } // 真正的扣库存逻辑 int rows = stockMapper.deduct(req.getSkuId(), req.getQuantity()); if (rows == 0) { throw new InsufficientStockException("库存不足"); } // 记录处理日志,作为幂等依据 stockLogMapper.insert(log); }这里有两个易错点。第一,幂等判断和扣库存必须在一个事务里,不能先查 log 再扣库存,中间有并发窗口会导致重复扣减;第二,如果库存不足抛出异常,消息会被送回重试队列,但此时库存永远不会够,消息会一直重试直到变成死信。所以你需要在消费端对"业务确定性失败"(比如库存不足、参数非法)和"系统临时失败"(网络超时、数据库连接失败)做区分:确定性失败直接标记死信,通知人工处理;临时失败才进入重试。不加区分的无限重试,只会把一个小问题放大成消息堆积的故障。
4.4 重试退避与死信机制
重试不是越快越好、越多越好。如果每秒重试一次,而下游服务正在重启,所有消息会在重启期间打爆下游。我习惯的方案是按retry_count控制退避间隔:第 1~3 次,间隔 10 秒;第 4~6 次,间隔 1 分钟;第 7 次之后,间隔 5 分钟;超过 15 次还没成功,把消息状态改成死信,并触发告警。退避逻辑可以在定时任务扫描时计算:
-- 扫描条件:状态待投递,且 next_retry_time <= now() SELECT * FROM outbox_message WHERE status = 0 AND next_retry_time <= NOW() ORDER BY next_retry_time ASC LIMIT 100; -- 更新语句:根据 retry_count 计算出下一次重试时间 UPDATE outbox_message SET retry_count = retry_count + 1, next_retry_time = DATE_ADD(NOW(), INTERVAL CASE WHEN retry_count < 3 THEN 10 SECOND WHEN retry_count < 6 THEN 1 MINUTE ELSE 5 MINUTE END), status = CASE WHEN retry_count >= 15 THEN 3 ELSE 0 END WHERE id = ?;retry_count >= 15时状态置为死信。死信不是终点,而是需要人工介入的起点,后面我专门讲排查。
如果你用了 MQ 做投递层,这部分的逻辑可以交给 MQ 的重试机制,outbox_message表只需要维护到"已投递"状态,消息的消费重试由 MQ 负责。两种模式各有优劣:纯表方案依赖可控、排查直观,但实现重试退避要自己写;MQ 方案省事,但出问题时跨两个系统查链路更费劲。小团队我建议先纯表方案跑起来,跑通了再考虑引入 MQ。
4.5 对账任务:最后一道安全网
就算你把上面的逻辑都写对了,分布式系统里依然可能发生"所有正常路径都走完,但还是有问题"的情况。比如定时任务挂了一晚上,消息没投递;比如消息表被误删;比如库存服务处理成功后写日志失败导致消息一直重试。所以必须有一道对账任务兜底。
对账的思路是:定时(比如每小时)比对服务 A 的订单表和消息表,找出"订单存在但消息表没有对应记录"或者"消息长期处于待投递状态"的数据;再比对库存服务的扣减日志和服务 A 的订单表,找出"订单创建了但库存没扣"的脏数据。拿"订单存在但消息表无记录"来说,正常情况不可能出现(因为订单和消息在同一个事务里),一旦出现了就说明有人手动改了数据、或者有非正常路径绕过事务插入订单,这种记录必须告警并人工确认。
对账任务本身不能写入业务库,最好独立建一个对账服务,从各服务的库里只读拉取数据,交叉比对。比对结果输出成报表,差异数据进工单系统。这一步通常被省略,但在我看来它才是整个方案的灵魂,因为没有对账,你永远不知道前面的机制到底有没有生效。我有一次就是靠对账任务发现一个服务一直在收到包体损坏的消息,所有重试全部失败,而监控竟然没告警——因为线程都在处理"失败重试"这个正常逻辑,没有指标暴露出来。
5. 实战踩坑实录与问题排查思路
5.1 高频坑位速查表
| 现象 | 直接原因 | 根治思路 |
|---|---|---|
| 消息表数据一直 pending,但任务没扫到 | 定时任务挂了 / 扫描条件写错(时间比较用了本地时区) | 监控任务心跳;统一用 UTC 或指定时区比较 |
| 下游重复扣减 | 幂等判断和业务操作不在同一事务 | 幂等表和业务表同库同事务 |
| 同一个订单发了两条消息 | 唯一键没建,重试时重复插入了消息 | 对 biz_type + biz_id 加唯一键 |
| 下游处理成功但消息永远 confirm 不了 | 消费成功回执丢失,或回执事务和消费事务不是同一个 | 把"处理业务+写处理日志"做成一个事务;回执失败允许依赖下游幂等重投 |
| 消息越积越多,数据库压力大 | 没有清理策略,死信也不处理 | 定期归档已确认消息;死信即时告警 |
| 用户重试下单被扣两次 | 防重只是"短时间内同用户同金额",没有全局锁 | 在订单服务加幂等键,同一个幂等键只允许创建一单 |
这里每一条都是我真金白银踩过的。最典型的坑是第三个:第一版消息表没加唯一键,定时任务扫描时网络抖动导致一条消息被两个线程同时捞起来投递,下游收到两条一模一样的减库存消息,虽然消费做了幂等没出事,但消息表里多了重复记录,后面排查怎么都对不上账。后来加了uk_msg_biz唯一键,插入冲突直接忽略,问题从根上杜绝。
5.2 如何排查"钱去哪了"这桩悬案
线上真的出现"A 扣钱了 B 报错"的事故时,排查顺序比排查手段更重要。我的标准动作是五步:
第一步,锁定时间窗口和流水号。把用户的订单号、支付流水号、操作时间记下来,这是整个排查的坐标原点。
第二步,按"业务表 → 消息表 → 下游日志"的顺序捞数据。先查服务 A 的订单表和支付记录,确认这笔钱在 A 侧的状态;再查 A 的 outbox 表里对应消息的状态,是 pending 还是 confirmed 还是 dead;最后查库存服务的消费日志和库存变动记录。三步数据一对,基本能定位到断点在哪一环。
第三步,如果 outbox 消息是 pending 且重试了几十次,基本可以确定下游有问题,不用在 A 侧浪费时间;如果 outbox 消息压根不存在,那问题在 A 的事务提交环节,可能是代码走了一条不再写消息的旁路逻辑。
第四步,看下游的错误日志分级。我前面提过的"业务确定性失败"(比如库存不足)和"系统临时失败"(比如连接超时)处理方式完全不同:临时失败继续重试即可;确定性失败是重试多少次都没用的,必须人工介入调整业务数据或状态。
第五步,所有自动排查都做完之后,如果数据还是对不上,启动对账脚本做全量比对,一般都能揪出那种"消息丢失 + 回执丢失 + 补偿丢失"三连击的边缘案例。
这五步走完,资金基本能定位个八九不离十。剩下定位不到的概率极低——那种情况通常不是分布式事务的问题,而是代码里某个 if 分支把正常流水漏掉了,属于业务逻辑 bug,和事务方案无关。
5.3 监控与告警,别让隐患藏在"正常"里
最后我一定要强调监控。不是所有问题都会直接表现为"报错",更多时候是"一切都正常,但账是不平的"。所以围绕这套方案,至少要盯四个指标。
第一个是 outbox 表中 pending 消息的数量和最长等待时间。正常情况下 pending 数应该很小,接近 0;如果持续增长,说明投递链路堵了。第二个是重试次数的分布,大量消息重试超过 5 次还没成功,说明下游已经有问题了,不是偶发抖动。第三个是死信数量,只要有死信就应该告警,因为死信背后必然有"确定性失败"的业务需要人工处理。第四个是下游消费的幂等冲突次数,这个指标容易被忽略——如果重复投递频繁触发幂等判断,说明回执经常丢失,链路本身不够稳。
这些指标都适合做成 Grafana 面板或接入现有监控体系。别只监控技术指标(CPU、内存、QPS),业务指标(消息积压、幂等冲突、对账差异)才是分布式事务方案能不能持续跑下去的关键。**我见过太多系统上线时一切正常,三个月后因为没有监控,消息表里积压了十几万条 dead 消息,账彻底对不上,最后只能人工一张张补。**那感觉真的不好受。
6. 一点个人经验总结
回到文章开头那个问题:A 扣钱了 B 报错,钱去哪了?真相是,钱哪也没去,它只是卡在了一个"没人处理"的中间状态里。分布式事务做的不是把"不可能"变成"可能"——系统该失败还是会失败,网络该抖动还是会抖动——而是把失败变成"可发现的、可处理的、可修复的"。把扣款和扣库存放进同一个强一致事务,看起来很美好,但代价是整个系统被单个慢节点拖死;用消息表、重试、幂等、对账把问题摊开、把补偿机制铺好,看起来绕了很远的路,却让系统真正经得起故障。
我个人在实际项目里的体会是:选分布式事务方案之前,先回答"我能不能接受最终一致"以及"我的团队能不能维护好这套补偿代码"这两个问题。能接受,就勇敢地上消息表或事务消息;不能接受,就先反思服务拆分是否合理,很多时候把强相关的表放回同一个库,比引入任何分布式事务框架都划算。技术方案没有绝对的好坏,只有代价和场景是否匹配。
最后再分享一个小技巧:不管最后选了哪种方案,先在测试环境把故障注入跑一遍——杀掉下游服务、断网、让消息超时、让数据库连接池打满。你的方案能不能在真实故障下帮用户找回那笔"消失"的钱,不是看文档,而是看这些故障演练的结果。门槛不高,但收益极高。