资讯动态

微服务分布式事务详解:从扣款报错到最终一致性的落地实践

发布时间:2026/10/13 13:39:01 来源:尧图企业网站定制
微服务最大的坑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 TCCTry-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把消息表里的待发送消息投递到库存服务 BB 消费消息处理扣库存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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个细节值得解释。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() { ListOutboxMessage 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 报错钱去哪了真相是钱哪也没去它只是卡在了一个没人处理的中间状态里。分布式事务做的不是把不可能变成可能——系统该失败还是会失败网络该抖动还是会抖动——而是把失败变成可发现的、可处理的、可修复的。把扣款和扣库存放进同一个强一致事务看起来很美好但代价是整个系统被单个慢节点拖死用消息表、重试、幂等、对账把问题摊开、把补偿机制铺好看起来绕了很远的路却让系统真正经得起故障。我个人在实际项目里的体会是选分布式事务方案之前先回答我能不能接受最终一致以及我的团队能不能维护好这套补偿代码这两个问题。能接受就勇敢地上消息表或事务消息不能接受就先反思服务拆分是否合理很多时候把强相关的表放回同一个库比引入任何分布式事务框架都划算。技术方案没有绝对的好坏只有代价和场景是否匹配。最后再分享一个小技巧不管最后选了哪种方案先在测试环境把故障注入跑一遍——杀掉下游服务、断网、让消息超时、让数据库连接池打满。你的方案能不能在真实故障下帮用户找回那笔消失的钱不是看文档而是看这些故障演练的结果。门槛不高但收益极高。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑