分布式事务:2PC / TCC / Saga / 本地消息表
前面所有事务的例子,都建立在一个没说出口的前提上:数据在同一个库、同一个连接里。一旦订单、库存、账户被拆到三个库,BEGIN ... COMMIT 就管不到隔壁了——订单库提交成功,库存库的扣减却可能超时、可能失败、甚至请求根本没到。这一章讲的就是:拆开之后,"要么全成要么全撤"这件事怎么重新成立。先给个定心丸:2PC、TCC、Saga、本地消息表不是四个竞品,而是从"强一致但慢"到"最终一致但快"的一根光谱。选型本质上只回答一句话——你的业务能不能接受几百毫秒到几秒的短暂不一致。
8.1 先划清边界:本地事务只管一个库
论本地事务的原子性到底是谁在保证
不是你的代码,是数据库自己。InnoDB 靠 undo log(回滚)+ redo log(持久化)+ 行锁,在自己的存储引擎内部实现 ACID。它能管住的边界,就是"这一个实例、这一个连接提交的这一批写"。
跨库时,订单库的事务提交了,库存库根本不知道有这回事——没有任何一方能同时管住两边。这时候你要么引入一个"外部协调者"来管(2PC/TCC/Saga),要么干脆放弃同时管、改成"先落一半,另一半靠重试追上来"(本地消息表/事务消息)。
| 环节 | 单库(一个事务搞定) | 拆成三个服务三个库 |
|---|---|---|
| 写订单 | INSERT orders,在事务里 | 调订单服务,HTTP/RPC 一次 |
| 扣库存 | UPDATE stock,同一个事务、同一把锁 | 调库存服务,第二个独立的本地事务 |
| 减余额 | UPDATE account,同一个事务 | 调账户服务,第三个独立的本地事务 |
| 失败怎么撤 | ROLLBACK 一句话,数据库保证 | 没人能一句话撤:前两个已经各自提交进库了,你得自己写补偿逻辑把它们撤回来 |
最常见的错误写法:try { 下单(); 扣库存(); 减余额(); } catch (Exception e) { 回滚下单(); }。它有三处致命伤:
① "失败"不等于"没生效"。扣库存调用超时抛异常,但库存服务那边其实已经成功提交了——你一回滚订单,就出现"订单没了、库存减了"。
② 补偿动作自己也会失败。回滚下单() 同样是一次网络调用,它也可能超时;失败之后谁来重试?
③ 进程可能直接没了。服务在两步之间被 kill、机器断电、容器被驱逐,catch 块根本不执行,数据就永久停在中间态。
记住一句话:跨库场景下,"撤销"不是一个动作,而是一段需要幂等 + 重试 + 对账的流程。
8.2 2PC / XA:让数据库自己两阶段提交
2PC(Two-Phase Commit,两阶段提交)是最经典的做法:引入一个协调者(Coordinator),先让所有参与者"准备好但别提交",全部 OK 再统一下令提交。XA 就是数据库实现 2PC 的标准接口(MySQL、Oracle、PG 都支持)。
论两阶段到底在等什么
阶段一 Prepare(准备):协调者让每个参与者执行事务、写好日志、锁住资源,但不提交,然后回答"能提交吗"。这一步一旦回答 yes,参与者就失去了单方面反悔的权利,只能等你。
阶段二 Commit/Rollback(提交或回滚):全部 yes → 广播 Commit;有任何一个 no → 广播 Rollback。
它把"同时成功"变成了一件确定的事,代价是——参与者从 Prepare 到收到最终指令的这段时间里,资源一直被锁着。
MySQL 里的 XA 命令(注意:真的能在同一个连接里跨库)
① 同步阻塞。Prepare 之后资源被锁住,直到阶段二才释放。一个参与者慢,全局都慢;参与者越多,事务时间越长,锁冲突越严重——这是它吞吐上不去的根因。
② 协调者单点。参与者 Prepare 完却在等指令时协调者挂了,参与者会一直锁着资源(业界叫"悬挂事务")。MySQL 里能看到 XA RECOVER 里那一堆 xid,但要不要提交只能人工判断。
③ 可能出现数据不一致。Commit 阶段广播时网络抖一下,有的参与者收到 commit 提交了,有的没收到还锁着——数据就分叉了。所以它其实是"强一致(有前提)"而不是"永远一致"。
结论:2PC/XA 适合"参与者少(2~3 个)、事务短、并发不高、但对一致性要求极高"的场景(比如同一个机房内的两个数据库做一次对账落库)。高并发互联网业务基本不用它。
8.3 TCC:把"锁"搬到业务层
论TCC 为什么能避开数据库长事务
TCC = Try / Confirm / Cancel,业务层面的两阶段。核心技巧是把"锁资源"换成"预留资源":
Try:不改最终数据,只做预留。比如库存表上有 stock 和 frozen 两列,Try 只把 5 个库存从 stock 挪到 frozen(可售变冻结),本地事务立刻提交,不持有数据库锁。
Confirm:全局成功,把 frozen 真正扣掉。
Cancel:全局失败,把 frozen 还回 stock。
它没有"阶段一锁到阶段二"这个问题,所以性能远好于 XA;代价是三个接口得你自己写、自己保证幂等。
TCC 三接口骨架(Spring 生态里可由 Seata TCC 模式调度)
| 问题 | 怎么发生的 | 怎么防 |
|---|---|---|
| 空回滚 | Try 请求因为网络问题没到服务,但全局事务判定失败,Cancel 先到了。此时什么都没冻结,你却去"解冻",等于凭空加了库存 | Cancel 里先查事务日志:没有 Try 记录就直接返回,并写一条空回滚记录 |
| 幂等 | Confirm/Cancel 因为超时重试被调用多次,重复扣减或重复解冻 | 每次执行前查日志表(bizId + 动作 唯一键),执行过就跳过 |
| 悬挂 | Try 请求在网络里"卡了很久",Cancel 已经执行完了,这个迟到的 Try 才到达并成功预留资源——资源永远冻着没人解 | Try 执行前检查是否已有 Cancel/空回滚记录,有就拒绝执行 |
库存能冻结、余额能冻结,所以适合 TCC。但有些操作没法预留:发短信、发优惠券、调第三方支付接口。短信发出去是撤不回的——这类步骤只能靠"正向重试 + 对账补偿",别硬套 TCC。
另外提醒:TCC 的复杂度全在那三个问题 + 日志表上,没有日志表(或没用框架自带的)的 TCC 实现,几乎一定会在生产中出错。
8.4 Saga:长流程的正向执行 + 逆向补偿
如果流程很长(下单 → 锁库存 → 支付 → 开票 → 发货 → 积分),用 2PC 会把锁持有到天荒地老,用 TCC 要写十几对接口累死人。Saga 的思路朴素得多:一路往前跑,每一步都是本地事务、立刻提交;跑挂了就倒着走,对每个已经跑完的步骤调用一个补偿动作。
论Saga 不是"回滚",是"反向业务"
数据库的 ROLLBACK 是物理撤销,数据回到从未发生的样子。Saga 的补偿是"再做一个业务动作把它对冲掉":退款不等于没扣过钱,它是一条新的流水;释放库存不等于没锁过,库存流水里多了一条记录。所以 Saga 的业务表通常有"正向流水 + 补偿流水"两套痕迹——这是特性不是 bug,金融场景反而需要这种可追溯性。
Saga 有两种组织方式:协同式(Choreography)——每个服务干完就发事件,下一个服务听事件自己动起来,简单但流程藏在各处,链路一长就看不清;编排式(Orchestration)——有一个中心编排器按状态机驱动每一步,链路清晰、便于排查,是生产推荐的做法。
编排式 Saga 的核心结构(状态机 + 补偿栈)
① 补偿必须幂等。补偿自己也会超时重试,重复退款是事故。做法同 TCC:用 sagaId + step 唯一键记账。
② 补偿必须最终成功。Saga 允许中间态存在一段时间,但补偿不能"试一次就算了"。要么无限重试 + 告警,要么落到人工工单——绝不能静默失败。
③ 不是每步都能补偿。货已发出、发票已开出,业务上撤不回来。所以 Saga 要设计"不可逆点"(point of no return),过了这个点就只允许"继续往前"或人工处理,不要设计出一套"撤回已发货"的假补偿。
还有个常被忽略的点:补偿期间数据对用户可见吗?Saga 不支持隔离性,中间态是能被查到的。要么在前端展示"处理中",要么用状态字段把中间态对查询屏蔽掉。
8.5 本地消息表与事务消息:最实用的最终一致
如果业务其实不需要立刻一致——比如下单后 1 秒内给用户加积分、发通知、更新搜索索引——那最省事也最稳的方案是本地消息表:把"业务写"和"待发消息"塞进同一个本地事务,然后把发送交给后台任务重试。它把"跨库的原子性"这一个难题,转化成了"本地事务原子性(数据库保证)+ 消息重试(你自己保证)",复杂度一下子降了一个数量级。
落地第一步:消息表 DDL(关键在唯一键和重试字段)
落地第二步:业务写和消息落表,同一个本地事务
论事务消息:把上面的消息表搬进 MQ
RocketMQ 的事务消息就是"本地消息表"的框架化版本,省掉了你自己建表和扫表。它的机制是三步:
① 发半消息:消息先发到 Broker,但对消费者不可见。
② 执行本地事务:半消息发成功后,才执行你的本地业务(写订单)。
③ 提交或回滚半消息:本地事务成功 → 让 Broker 把消息变为可见;失败 → 让 Broker 丢掉半消息。如果 Broker 迟迟等不到你的答复(比如你的进程挂了),它会反过来回调你的"事务状态反查接口"——这就是为什么你要提供一个"这笔订单到底存不存在"的查询方法。
注意:事务消息只保证"本地事务成功 ⇔ 消息一定投递至少一次",不保证消费端只收到一次。所以消费端幂等这一步,永远躲不掉。
① 消息表不在同一个事务里 = 全白干。常见错误是"先 insert order 提交,再 insert message"——中间崩了消息就丢了,业务却以为会有人去处理。必须在同一个 @Transactional 里,或同一个数据库实例。
② 只写"已发送"不写"已确认"。MQ 只是收下了消息,不代表下游消费成功。要留 status=2 已确认(由下游回执),否则你永远不知道有哪些消息需要补偿。
③ 扫表任务没加"跳过已锁记录"。多个实例同时跑定时任务会重复投递。要么用 FOR UPDATE SKIP LOCKED,要么用分布式锁,要么按 id 分片处理。重复投递本身不可怕(幂等兜住),但会让下游压力翻倍。
8.6 工程上真正难的部分:幂等、对账、可观测
所有最终一致方案的最后一道防线都是幂等——因为"至少一次"投递是常态,"恰好一次"是幻想。幂等不是加个 if 就完事,它有明确的三种做法,各有适用场景。
| 方式 | 做法 | 适合 | 注意 |
|---|---|---|---|
| 唯一键去重 | 给业务表加 UNIQUE(biz_id),或建一张 idempotent_record(request_id PRIMARY KEY) 表,插入冲突就说明处理过了 | 创建型操作:下单、支付、发券 | 最简单最可靠,优先考虑;要注意冲突时返回什么(应返回首次结果而非报错) |
| 状态机 | UPDATE ... SET status='PAID' WHERE id=? AND status='UNPAID',用受影响行数判断本次是否生效 | 状态流转:支付、发货、退款 | 条件必须带上"期望的旧状态",只写 WHERE id=? 就没有幂等性 |
| 去重表 / 幂等号 | 上游生成全局唯一 requestId,下游处理前先 INSERT 去重表,成功才继续 | 跨服务调用、第三方回调 | 去重表要有 TTL 清理,否则无限膨胀 |
用受影响行数实现状态机幂等(最常用的一招)
论对账:最终一致的"最后一道保险"
再好的重试也可能漏。生产上一定要有一个定时对账任务,用"业务侧的真相"去校验"下游的状态":
① 增量对账:每 5 分钟取最近 10 分钟内的业务单,逐笔查下游状态,不一致就补发/补偿。窗口重叠一点没关系,因为幂等。
② 全量对账:每天凌晨对指定日期做一次全量比对,输出差异报告,人工兜底重大差异。
③ 关键指标要能看见:待发送消息积压量、重试次数分布、补偿成功/失败数、对账差异数——这些曲线忽然抬头,就是事故的前兆。没有监控的最终一致方案等于没有最终一致。
① 强一致往往不是业务需求。用户下单后积分晚 2 秒到账,没人会发现;为了这 2 秒上一套 XA,换来的却是吞吐下降和运维复杂度,这买卖很亏。
② 不要在一个业务里混用多种方案。有的链路用 Seata AT、有的用消息表、有的用 TCC,出问题时你连"这笔单到底走到哪了"都说不清。一个系统定一种主方案。
③ 分布式事务只能保证"数据最终对上",不保证"你不用写补偿代码"。所有方案的复杂度,最后都沉淀在业务代码里——这是本质,不是某个框架不够好。
④ 能不分布式就别分布式。把强关联的表放在同一个库、用同一个本地事务,是最可靠的"分布式事务方案"。很多所谓的分布式事务需求,最后是被"合并库表"这种最土的方案解决的。
8.7 怎么选:一张表 + 三个判断
| 方案 | 一致性 | 性能 | 复杂度 | 什么时候用它 |
|---|---|---|---|---|
| 2PC / XA | 强一致(有前提) | 差(同步阻塞) | 低(框架封装好) | 参与者少(2~3)、事务短、并发低、且必须强一致的场景,例如同机房两个库做落库。高并发业务慎用。 |
| TCC | 最终一致 | 好 | 高(手写三接口 + 日志表) | 资源可预留、对资金/库存准确性要求极高的核心链路。写之前先确认"能不能预留"。 |
| Saga | 最终一致 | 好 | 中(编排 + 补偿) | 跨 4 个以上服务的长流程,如订单履约、审批流、出行行程。需要设计"不可逆点"。 |
| 本地消息表 | 最终一致 | 好(异步) | 低(一张表 + 定时任务) | 性价比最高的默认选择:通知、加积分、刷新缓存/索引、发券等异步副作用。 |
| 事务消息 (RocketMQ) | 最终一致 | 好(异步) | 低(不用自己建表) | 同上,但团队已经用了 RocketMQ 且不愿维护消息表。注意仍需消费端幂等 + 状态反查接口。 |
选三个问题定方案
问题一:失败时,业务能"撤"吗?能撤(库存、余额)→ TCC 或 Saga;撤不了(已发短信、已发货)→ 只能本地消息表 + 人工兜底,别幻想强一致。
问题二:用户能接受"晚一点对上"吗?能(绝大多数场景)→ 直接上本地消息表,最省事;不能(比如支付扣款与账务流水必须同时可见)→ 考虑 TCC 或 2PC。
问题三:这段逻辑有几个参与方、多长?2~3 个、短 → 2PC 可接受;5 个以上、含人工环节 → Saga 编排式;只有一个"发消息"的副作用 → 本地消息表。
① 本地事务管不到跨库,跨库的一致性必须由你(或框架)显式设计。"try/catch 里手动回滚"不是方案。
② 2PC/XA 强一致但有同步阻塞、协调者单点、可能不一致三硬伤;TCC 用"预留"换性能,但要处理空回滚/幂等/悬挂;Saga 用"正向 + 逆向补偿"适合长流程;本地消息表用"同一本地事务 + 重试"换来了最低的复杂度。
③ 幂等是所有人的底座:唯一键去重、状态机(带上期望旧状态)、去重表三选一。没有幂等,任何最终一致方案都会在重试面前崩掉。
④ 对账和监控是最后一道保险,别省略。对不上要能发现,而不是等用户投诉。
⑤ 最后一句话:能用本地事务解决的,绝不引入分布式事务。