钱扣了订单却没了:微服务分布式事务避坑复盘

一次让我至今心有余悸的线上事故:下单流程被拆成订单、库存、账户三个微服务依次配合,上线后大多风平浪静,某天财务对账却发现一笔诡异记录——用户的钱被扣了、库存也减了,订单却不存在,客服电话被打爆。扒日志还原现场才明白:那次请求账户成功扣款、库存成功减货,轮到最后写订单时,订单服务的机器恰好抖了一下、数据库连接超时,订单没写成,而前两步已经木已成舟,谁来还?在单体里这三步本可包在一个本地事务里要么全成要么全滚,可一拆成跨进程跨数据库的微服务,那个习以为常的事务就凭空消失了。这篇文章从这次钱货不一致的事故出发,把分布式事务这个坑和业界趟出来的几条路一次讲明白:为何拆微服务事务就没了、CAP 与 BASE 该选最终一致、2PC 为何沉重少用、用 Saga 把大事务拆成可补偿的小步、补偿和重试的前提是幂等、本地消息表加 MQ 保证通知不丢、TCC 与 Saga 如何选型,以及空回滚、悬挂、对账这些落地后才遇到的硬骨头。

那是一次让我至今心有余悸的线上事故。我们的下单流程被拆成了好几个微服务:订单服务负责创建订单,库存服务负责扣减库存,账户服务负责扣款。一次正常下单,需要这三个服务依次配合完成。上线后大部分时间风平浪静,可某天财务对账时发现了一笔诡异的记录:用户的钱被扣了,库存也减了,订单却不存在。用户那边更是炸了锅——钱没了,东西没买到,客服电话被打爆。

我扒开日志一点点还原现场,过程是这样的:那次请求里,账户服务成功扣了款,库存服务也成功减了库存,可轮到最后写订单记录时,订单服务所在的机器恰好抖了一下,数据库连接超时,订单没写成功。而前面两步已经"木已成舟"——钱扣了、货减了,谁来还?在单体应用里,这三步本可以包在一个本地数据库事务里,要么全成,要么全滚,根本不会有这种半拉子状态。可一旦拆成跨进程、跨数据库的微服务,那个我习以为常的"事务"就凭空消失了

这就是分布式系统里最经典、也最磨人的难题:分布式事务。多个服务各自操作自己的数据库,没有了那个天然的"要么全做、要么全不做"的保证,任何一步在中途失败,都会让整个系统陷入"钱货不一致"的尴尬境地。这篇文章,就从这次"钱扣了订单却没了"的事故出发,把分布式事务这个坑,以及业界趟出来的几条路,一次讲明白。

先摆几个关于分布式事务的想当然

动手之前,先把我自己曾经天真相信、后来被现实狠狠上课的几个念头摆出来。

想当然的念头 残酷的真相
"加个数据库事务不就行了?" 本地事务管不了别的服务、别的数据库,跨进程就失效
"几个服务调用都成功了,数据就一致了" 调用成功不等于全局一致,最后一步失败前面就回不去了
"拆微服务是纯粹的进步,有利无弊" 拆分换来了灵活,也亲手摧毁了单体里免费的强一致性
"分布式事务,追求强一致就对了" 强一致代价极高,多数业务该用的是"最终一致"
"重试一下失败的那步就能补救" 不设计补偿和幂等,盲目重试只会制造重复扣款

这些念头的共同病根,是把单体时代那个"免费、可靠、理所当然"的本地事务,想当然地以为在微服务里依然存在。可事实是,当你把一个系统拆成多个独立部署、各拥数据库的服务时,你在收获弹性与解耦的同时,也亲手拆掉了那张兜底的安全网。要理解这次事故,得先看清这张网到底是怎么没的。

第一件事:为什么一拆微服务,事务就没了

先回顾一下单体时代的幸福生活。在单体应用里,下单扣款减库存这三步操作的是同一个数据库,你只需要把它们包在一个事务里:BEGIN 开始,中间任何一步抛异常,ROLLBACK 一把全部撤销,全成功了才 COMMIT。数据库的 ACID 特性(原子性、一致性、隔离性、持久性)替你扛下了一切,你几乎不用操心"做了一半怎么办"。

可微服务架构下,订单、库存、账户是三个独立的服务,各连各的数据库,彼此只能通过网络接口(RPC/HTTP)调用。这时那个本地事务的边界,被服务的进程边界硬生生切断了:订单服务的事务,管不到库存服务的数据库;反过来也一样。于是"三步要么全成要么全滚"这个原本由数据库免费提供的保证,凭空蒸发了。下面这张图,把单体和微服务两种世界里的"事务边界"画在一起,差别一目了然:

看懂这张图,事故的本质就清楚了:微服务里每个服务的本地事务都各自独立提交,一旦中间某步失败,已经提交的前几步没有任何机制能自动撤销。这不是哪个工程师写错了代码,而是分布式架构的固有难题。所以解决它,不能再指望数据库免费兜底,而要在应用层主动设计一套"协调多个服务、保证最终一致"的机制。接下来,我们先认清这个问题的理论边界。

第二件事:认清边界,多数业务要的是"最终一致"

在动手找解法之前,得先把理论的天花板看清楚,否则容易往错误的方向使劲。分布式系统里有个绕不开的 CAP 定理:一致性(C)、可用性(A)、分区容错性(P),三者不可兼得。而在真实的网络环境里,网络分区(P)是迟早会发生的客观现实,你没得选,只能接受。于是真正的取舍,就落在了剩下的 C 和 A 之间:当网络出问题时,你是宁可拒绝服务保证数据强一致(CP),还是宁可继续服务、容忍数据暂时不一致(AP)?

对绝大多数互联网业务(包括我这个下单场景)来说,答案是后者:可用性优先,一致性可以"最终"达成,而不必时时刻刻强一致。这就引出了 BASE 思想——基本可用(Basically Available)、软状态(Soft state)、最终一致性(Eventually consistent)。它是对 ACID 的一种务实妥协:不再苛求每一刻都完全一致,而是允许系统短暂地处于中间状态,只要保证在可接受的时间内,数据最终会回归一致

想清楚这一点至关重要:它意味着,我那次"钱扣了订单没写成"的问题,正确的解法不是去追求那种"三步原子完成"的强一致幻觉(代价高得吓人),而是接受"中间可能短暂不一致",但通过一套机制保证——要么订单最终补写成功,要么前面的扣款和减库存最终被补偿撤销。方向对了,解法才有意义。

第三件事:强一致的 2PC,以及它为什么常常不被采用

如果你确实需要强一致,业界的经典方案是 两阶段提交(2PC)。它引入一个"事务协调者",把提交分成两个阶段:

阶段一(准备 Prepare):
  协调者 → 问所有参与者:"准备好提交了吗?"
  各参与者执行操作但不提交,锁住资源,回复"我准备好了/我不行"

阶段二(提交 Commit / 回滚 Rollback):
  若所有参与者都回复"准备好了" → 协调者通知所有人 COMMIT
  只要有一个回复"不行"或超时   → 协调者通知所有人 ROLLBACK

2PC 的思路很直观,理论上能保证强一致。但它在互联网高并发场景里很少被采用,原因是代价沉重:第一,同步阻塞——在两个阶段之间,所有参与者都得锁住资源傻等协调者发话,这期间相关数据无法被其它请求使用,并发性能被严重拖累;第二,协调者单点——协调者一旦在关键时刻宕机,参与者们就会卡在"已准备、等指令"的状态里进退两难;第三,实现复杂、对服务侵入大

// 2PC 的典型问题:资源在两阶段之间被长时间锁住
// 伪代码示意协调者视角
boolean allReady = true;
for (Participant p : participants) {
    if (!p.prepare()) {      // 阶段一:各方锁资源、预执行
        allReady = false;    // 这期间资源一直被锁,别的请求只能等
        break;
    }
}
if (allReady) {
    for (Participant p : participants) p.commit();   // 阶段二:统一提交
} else {
    for (Participant p : participants) p.rollback(); // 任一失败,全部回滚
}
// 高并发下,prepare 与 commit 之间的锁等待会拖垮吞吐

所以 2PC 更适合那些强一致要求极高、并发不大的场景(比如某些金融核心)。对我这种"可用性优先、能接受最终一致"的下单业务,硬上 2PC 是杀鸡用牛刀,还会把系统的吞吐拖垮。我们需要的,是一种更轻、更拥抱最终一致的方案——这就是下一节的主角。

第四件事:Saga 模式——把大事务拆成"可补偿"的小步

对最终一致场景,我最终选择的是 Saga 模式。它的思路朴素而有力:既然没法让多步原子完成,那就把一个大事务拆成一连串各自本地提交的小事务,并为每一个小步骤,预先准备好一个"反向操作"——也就是补偿(compensation)。正向一路执行下去,一旦某步失败,就沿着反方向,把已经做过的步骤逐个补偿撤销。

// Saga 编排:正向步骤 + 对应的补偿动作
public void createOrderSaga(Order order) {
    List<Runnable> compensations = new ArrayList<>();
    try {
        accountService.deduct(order.getUserId(), order.getAmount());
        compensations.add(() -> accountService.refund(order)); // 登记补偿:退款

        stockService.reduce(order.getSkuId(), order.getQty());
        compensations.add(() -> stockService.restore(order));  // 登记补偿:还库存

        orderService.create(order); // 最后一步:写订单
        // 全部成功,正常结束
    } catch (Exception e) {
        // 任何一步失败:逆序执行已登记的补偿,把做过的撤销掉
        Collections.reverse(compensations);
        for (Runnable c : compensations) {
            safeRun(c); // 补偿本身也要容错,失败要重试/告警
        }
        throw new BizException("下单失败,已回滚", e);
    }
}

用 Saga 重写后,我那次的事故就有了归宿:如果写订单失败,系统会自动触发"还库存"和"退款"两个补偿,把用户的钱退回、库存补上,最终回到一致状态——而不是把用户晾在"钱没了货也没了"的绝境里。Saga 有编排式(orchestration,一个协调者统一指挥)和编舞式(choreography,各服务通过事件互相触发)两种风格,核心都是这套"正向推进 + 失败补偿"的逻辑。

第五件事:补偿和重试的前提——幂等,幂等,还是幂等

Saga 听起来很美,但它有个绝对不能省的前提:每一个操作和它的补偿,都必须是幂等的。所谓幂等,就是"同一个操作执行一次和执行多次,结果完全相同"。为什么它如此关键?因为分布式环境里充满了超时和重试:一次扣款请求超时了,你不知道它到底成没成,只能重试;如果扣款不是幂等的,这一重试就可能扣两次钱——而这恰恰是比"钱货不一致"更可怕的事故。

// 用唯一业务流水号 + 去重表, 把扣款做成幂等
public void deduct(String txnId, Long userId, BigDecimal amount) {
    // txnId 是这笔交易全局唯一的流水号
    if (txnLogMapper.exists(txnId)) {
        return; // 这笔已经处理过, 直接返回, 绝不重复扣
    }
    // 在同一个本地事务里: 记流水 + 扣款, 保证原子
    txnLogMapper.insert(txnId);
    accountMapper.decrease(userId, amount);
}
// 这样无论这个请求被重试多少次, 钱都只会被扣一次

实现幂等的常见手段,就是给每笔业务操作分配一个全局唯一的流水号,在执行前先查"这个流水号处理过没有",处理过就直接跳过。补偿操作同理——"退款"也必须幂等,否则重试退款可能退两次。可以说,没有幂等,一切重试和补偿都是在制造新的灾难。这是分布式事务里最不该偷的懒。

第六件事:用"本地消息表 + MQ"保证补偿/通知不丢失

还有一个隐蔽的问题:补偿动作、或者跨服务的最终通知,本身也可能因为网络抖动而失败、丢失。比如订单写成功了,要发个"扣减积分"的消息给积分服务,可消息刚要发,本服务就崩了——这条消息就丢了,积分永远扣不上。解法是经典的 本地消息表:把"要发的消息"和业务数据,放在同一个本地事务里一起写进数据库,保证"业务成功"和"消息已记录"原子绑定;再由一个后台任务,可靠地把消息表里的消息投递到 MQ,投递成功才标记完成,失败就重试。

-- 业务操作与消息, 在同一个本地事务里原子写入
START TRANSACTION;
  INSERT INTO orders (id, user_id, amount, status) VALUES (...);
  -- 把"待发送的消息"也写进本地消息表, 与订单同生共死
  INSERT INTO local_message (msg_id, topic, payload, status)
         VALUES ('m-1001', 'deduct_points', '{...}', 'PENDING');
COMMIT;

-- 之后由后台任务轮询 status=PENDING 的消息, 投递到 MQ
-- 投递成功 → UPDATE status='SENT'; 失败则保留, 下轮重试
-- 配合消费端幂等, 实现"消息至少一次且不重复生效"

这套"本地消息表 + MQ + 消费端幂等"的组合,是实现可靠最终一致的经典骨架:它把"业务"和"通知"绑成原子,再用重试兜住投递的不确定性,最终保证下游一定会收到、且只生效一次。到这儿,这次事故的系统性解法就齐了。我把决策思路收成一张图:

把这套体系建起来,"钱货不一致"这类事故就能被牢牢兜住。最后,拧成几条可直接照做的铁律:

  1. 别幻想跨服务还有本地事务,微服务一拆,强一致的免费午餐就没了,要在应用层主动设计。
  2. 先想清楚要强一致还是最终一致,多数互联网业务该选后者,别动辄上 2PC。
  3. 用 Saga 把大事务拆成可补偿的小步,为每个正向操作预备好反向补偿。
  4. 所有操作和补偿都必须幂等,靠唯一流水号去重,否则重试就是重复扣款。
  5. 补偿和通知用本地消息表 + MQ 兜底,保证它们不会因网络抖动而丢失。
  6. 一定要有对账/兜底任务,定期扫描不一致数据并修复,做最后一道防线。
  7. 能不用分布式事务就别用,合理的服务边界划分,常常比事后补救更治本。

一张分布式事务方案选型表

把几种主流方案的特点、适用场景汇成一张表,下次做技术选型时心里能有杆秤。

方案 一致性 特点 适用场景
2PC / XA 强一致 同步阻塞、锁资源、有协调者单点 强一致要求高、并发不大(金融核心)
TCC 强一致(较 2PC 轻) Try-Confirm-Cancel 三段,需改造业务,侵入大 对一致性和性能都有要求的核心交易
Saga 最终一致 拆小步 + 补偿,无长锁,补偿逻辑要自己写 长流程、多步骤的业务(下单、退货)
本地消息表 最终一致 业务与消息原子写库,后台投递 MQ 跨服务异步通知、解耦场景
事务消息(MQ) 最终一致 依赖 MQ 的事务消息能力,半消息+回查 已有 RocketMQ 等支持的场景
对账补偿 最终一致(兜底) 定时扫描修复,谁也兜不住时的最后防线 所有方案都应配套的安全网

顺带说说 TCC:比 Saga 更强一致的一种选择

表里提到的 TCC(Try-Confirm-Cancel),值得单独说两句,因为它常和 Saga 拿来比较。TCC 把每个操作拆成三段:Try 阶段做资源预留(比如"冻结"账户里的钱,而不是直接扣),Confirm 阶段真正提交(把冻结的钱真正扣掉),Cancel 阶段释放预留(解冻)。

它和 Saga 最大的区别在于"预留"这个动作。Saga 是直接做实(直接扣款),失败了再补偿(退款),所以中间状态对外是"可见"的——别人能看到钱真的少了一会儿。而 TCC 先冻结,中间状态对外表现为"这笔钱被锁住了、但还没真正动",隔离性更好,更接近强一致。代价是对业务的侵入更大:你得为每个资源都设计出 Try/Confirm/Cancel 三套逻辑,改造成本高得多。

所以选型上有个朴素的权衡:对隔离性、一致性要求更高的核心交易(如支付),TCC 更合适;对长流程、步骤多、能容忍中间状态短暂可见的业务(如下单、履约),Saga 更轻、更实用。没有银弹,只有适不适合。我的下单场景最终用 Saga,正是因为它步骤长、且业务上完全能接受"失败后补偿撤销"这种最终一致的语义。

那些 Saga 落地后才会遇到的硬骨头

把 Saga 框架搭起来只是开始,真正放到线上跑,你还会撞见几块更硬的骨头。我把踩过的几个一并记下来,免得你重蹈覆辙。

第一块是"空回滚"。补偿请求到达时,正向操作可能压根没执行成功(比如扣款请求超时,实际并没扣),这时贸然执行"退款"补偿,就会凭空退出一笔不存在的钱。解法是补偿操作要能识别"我要补偿的那个正向动作到底做没做过"——还是靠流水号:补偿时先查正向记录在不在,不在就直接当作空回滚,什么都不做。

第二块是"悬挂"。和空回滚相反:补偿请求因为网络乱序,先于正向请求到达并执行了,等正向请求姗姗来迟,它又把资源给占上了,结果这笔资源再也没人来补偿,永久泄漏。解法是正向操作执行前,也要查一下"是不是已经被补偿过了",被补偿过就拒绝执行正向。

// 同时防住空回滚与悬挂: 正向和补偿都先查状态再动手
public void cancel(String txnId) {
    TxnLog log = txnLogMapper.find(txnId);
    if (log == null) {
        // 空回滚: 正向没执行过, 插一条"已取消"占位, 防止悬挂
        txnLogMapper.insertCancelled(txnId);
        return;
    }
    if (log.isCancelled()) return; // 幂等: 已补偿过, 直接返回
    accountMapper.refund(log.getUserId(), log.getAmount());
    txnLogMapper.markCancelled(txnId);
}

public void deduct(String txnId, Long userId, BigDecimal amt) {
    TxnLog log = txnLogMapper.find(txnId);
    if (log != null && log.isCancelled()) {
        return; // 悬挂防护: 已被取消, 拒绝迟到的正向操作
    }
    // ... 正常扣款 + 记流水(幂等)
}

第三块,也是最该有的一层,是对账与告警。再周密的补偿逻辑,也总有补偿本身反复失败、数据卡在中间态的极端情况。所以一定要有一个独立的对账任务,定期扫描那些"超过合理时长仍处于中间状态"的事务,尝试自动修复,修不了就告警给人工。它是整个分布式事务体系的最后一道防线——当所有自动机制都兜不住时,至少要保证"不一致"这件事本身,不会被悄无声息地掩盖。

这几块硬骨头的共同启示是:分布式事务的复杂度,从来不在"正常流程"里,而全藏在"异常流程"的犄角旮旯——超时、乱序、重复、部分失败。把正常路径跑通只是入门,把这些异常路径一个个想周全、兜干净,才是真正的功夫所在。

写在最后

这次"钱扣了订单却没了"的事故,给我最深的教训其实跟具体技术方案无关,而是一个认知上的转变:微服务拆分从来不是免费的。我们津津乐道于它带来的解耦、独立部署、弹性伸缩,却很容易忽略,它在悄无声息中拿走了单体时代那个最宝贵、也最被我们视为理所当然的东西——本地事务那张"要么全成、要么全不做"的安全网。拆分的每一刀,都是在用一致性的确定性,去换取架构的灵活性。

所以分布式事务的种种方案,本质上都是在"亲手拆掉安全网之后,再用工程手段把它一点点重新织回来"。Saga、TCC、本地消息表、对账……没有一个像本地事务那样优雅免费,它们都需要你写更多代码、想更多边界、容忍更多复杂度。这也提醒我们:在挥刀拆分微服务之前,先掂量清楚每一条跨服务的调用,是否真的值得付出重建一致性的代价。有时候,一个划分得当、把强相关数据收拢在同一个服务里的边界,远比事后用一堆补偿逻辑去救火要明智得多。架构的智慧,往往不在于你会用多少种花哨的分布式事务方案,而在于你能不能从一开始,就尽量让自己少需要它们。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

不是模型幻觉:RAG 知识库问答自信胡说的排查

2026-5-30 1:17:22

技术教程

一行 .Result 拖垮整个应用:C# 异步死锁避坑

2026-5-30 1:41:33

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索