那是一次让我至今心有余悸的线上事故。我们的下单流程被拆成了好几个微服务:订单服务负责创建订单,库存服务负责扣减库存,账户服务负责扣款。一次正常下单,需要这三个服务依次配合完成。上线后大部分时间风平浪静,可某天财务对账时发现了一笔诡异的记录:用户的钱被扣了,库存也减了,订单却不存在。用户那边更是炸了锅——钱没了,东西没买到,客服电话被打爆。
我扒开日志一点点还原现场,过程是这样的:那次请求里,账户服务成功扣了款,库存服务也成功减了库存,可轮到最后写订单记录时,订单服务所在的机器恰好抖了一下,数据库连接超时,订单没写成功。而前面两步已经"木已成舟"——钱扣了、货减了,谁来还?在单体应用里,这三步本可以包在一个本地数据库事务里,要么全成,要么全滚,根本不会有这种半拉子状态。可一旦拆成跨进程、跨数据库的微服务,那个我习以为常的"事务"就凭空消失了。
这就是分布式系统里最经典、也最磨人的难题:分布式事务。多个服务各自操作自己的数据库,没有了那个天然的"要么全做、要么全不做"的保证,任何一步在中途失败,都会让整个系统陷入"钱货不一致"的尴尬境地。这篇文章,就从这次"钱扣了订单却没了"的事故出发,把分布式事务这个坑,以及业界趟出来的几条路,一次讲明白。
先摆几个关于分布式事务的想当然
动手之前,先把我自己曾经天真相信、后来被现实狠狠上课的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "加个数据库事务不就行了?" | 本地事务管不了别的服务、别的数据库,跨进程就失效 |
| "几个服务调用都成功了,数据就一致了" | 调用成功不等于全局一致,最后一步失败前面就回不去了 |
| "拆微服务是纯粹的进步,有利无弊" | 拆分换来了灵活,也亲手摧毁了单体里免费的强一致性 |
| "分布式事务,追求强一致就对了" | 强一致代价极高,多数业务该用的是"最终一致" |
| "重试一下失败的那步就能补救" | 不设计补偿和幂等,盲目重试只会制造重复扣款 |
这些念头的共同病根,是把单体时代那个"免费、可靠、理所当然"的本地事务,想当然地以为在微服务里依然存在。可事实是,当你把一个系统拆成多个独立部署、各拥数据库的服务时,你在收获弹性与解耦的同时,也亲手拆掉了那张兜底的安全网。要理解这次事故,得先看清这张网到底是怎么没的。
第一件事:为什么一拆微服务,事务就没了
先回顾一下单体时代的幸福生活。在单体应用里,下单扣款减库存这三步操作的是同一个数据库,你只需要把它们包在一个事务里: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 + 消费端幂等"的组合,是实现可靠最终一致的经典骨架:它把"业务"和"通知"绑成原子,再用重试兜住投递的不确定性,最终保证下游一定会收到、且只生效一次。到这儿,这次事故的系统性解法就齐了。我把决策思路收成一张图:
把这套体系建起来,"钱货不一致"这类事故就能被牢牢兜住。最后,拧成几条可直接照做的铁律:
- 别幻想跨服务还有本地事务,微服务一拆,强一致的免费午餐就没了,要在应用层主动设计。
- 先想清楚要强一致还是最终一致,多数互联网业务该选后者,别动辄上 2PC。
- 用 Saga 把大事务拆成可补偿的小步,为每个正向操作预备好反向补偿。
- 所有操作和补偿都必须幂等,靠唯一流水号去重,否则重试就是重复扣款。
- 补偿和通知用本地消息表 + MQ 兜底,保证它们不会因网络抖动而丢失。
- 一定要有对账/兜底任务,定期扫描不一致数据并修复,做最后一道防线。
- 能不用分布式事务就别用,合理的服务边界划分,常常比事后补救更治本。
一张分布式事务方案选型表
把几种主流方案的特点、适用场景汇成一张表,下次做技术选型时心里能有杆秤。
| 方案 | 一致性 | 特点 | 适用场景 |
|---|---|---|---|
| 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