分布式事务与一致性落地

2PC/3PC · TCC/Saga · 消息最终一致 · Seata

本主题的底层路线(万变不离其宗):
分布式事务的底层 = 协调者(Coordinator) + 补偿 / 回滚 + 幂等(+ 可选共识)。
要么走「强一致两阶段锁」路线(2PC / XA:先把所有参与者锁住,统一提交或回滚);要么走「最终一致靠补偿与消息」路线(TCC / Saga / 可靠消息:每个本地事务先提交,错了再反向补偿)。一句话记住 没有银弹,只有取舍——你想要"立刻全局一致"就得付"锁与阻塞"的代价,你想要"高可用高性能"就得接受"短暂不一致 + 自己写补偿"。
目录 0. 读前必读:分布式事务,就是把本地事务拆到多机
1. 背景:为什么分布式事务这么难
 1.1 一个订单-库存-账户的跨库故事
 1.2 三个永恒敌人:分区、崩溃、消息丢失
 1.3 ACID 在分布式的崩塌
 1.4 一致性谱系:强一致 vs 最终一致
2. 强一致路线:2PC 两阶段提交
 2.1 核心思想:先问"能不能",再"统一做"
 2.2 阶段一 Prepare(准备/投票)
 2.3 阶段二 Commit / Rollback(提交/回滚)
 2.4 协调者:唯一的"裁判"
 2.5 五大痛点:阻塞/单点/不一致/脑裂/超时
 2.6 图解 2PC 时序
3. 3PC:给 2PC 打的三处补丁(仍非完美)
 3.1 CanCommit / PreCommit / DoCommit
 3.2 超时推进与单点缓解
 3.3 为什么依然可能不一致
4. XA:数据库层的 2PC 标准接口
 4.1 XA 规范与 xid
 4.2 JTA:Java 的事务 API
 4.3 XA 的局限(锁定/跨语言/性能)
5. 最终一致路线之一:TCC(Try-Confirm-Cancel)
 5.1 三阶段语义:预留 → 确认 / 取消
 5.2 业务侵入:三个方法都得自己写
 5.3 空回滚(无 Try 先 Cancel)
 5.4 防悬挂(Try 晚到)
 5.5 幂等(Confirm / Cancel 重复)
 5.6 图解 TCC 三阶段与异常
 5.7 适用短事务
6. 最终一致路线之二:Saga(长事务编排)
 6.1 核心:一连串本地事务 + 对应补偿
 6.2 编排 Choreography(事件驱动)
 6.3 协同 Orchestration(中心协调器)
 6.4 隔离性缺失与对策
 6.5 图解 Saga 补偿(编排 vs 协同)
7. 最终一致路线之三:可靠消息驱动
 7.1 本地消息表(本地事务 + 消息表 + 投递 + 幂等)
 7.2 事务消息:RocketMQ 的 half + 回查
 7.3 最大努力通知(重试 + 对账)
 7.4 图解 本地消息表流程
8. 贯穿一切的基石:幂等设计
 8.1 为什么分布式事务离不开幂等
 8.2 唯一键 / 去重表
 8.3 Token 机制
 8.4 版本号 / 状态机
9. Seata:把底层机制封装成"开箱即用"
 9.1 角色:TC / TM / RM
 9.2 AT 模式:一阶段提交 + undo log + 全局锁
 9.3 AT 二阶段:自动回滚与脏写防护
 9.4 TCC / Saga / XA 模式
 9.5 图解 Seata AT 一/二阶段
10. 选型对比:没有银弹,只有取舍
 10.1 四个维度:一致性/侵入性/性能/复杂度
 10.2 横向对比表
 10.3 图解 选型定位
 10.4 决策清单
11. 一句话路线总结

0. 读前必读:分布式事务,就是把本地事务拆到多机

你写单机程序时,BEGIN; UPDATE 账户; UPDATE 库存; COMMIT; 一个事务就搞定了——数据库帮你保证"要么都成功、要么都回滚"。但当你做微服务、分库分表后,账户表和库存表不在同一个库,甚至不在同一台机器、同一个进程里。这时"原子的提交"这件事,数据库自己管不了了——因为没有一个进程能同时锁住两个库、同时决定提交还是回滚。

分布式事务要解决的,就是一句话:"多机、多库、多服务各自提交,但对外看起来像一个原子操作。"

本部分的认知顺序(底层优先):先理解"为什么难"(敌人是谁)→ 再看"强一致路线"怎么用协调者 + 两阶段锁硬刚(2PC/XA)→ 再看"最终一致路线"怎么用补偿 + 消息 + 幂等绕开(TCC/Saga/可靠消息)→ 最后看 Seata 如何把这些机制封装成开箱即用。读每一节都问自己:"这里谁是协调者?靠什么回滚?靠什么保证重复不炸?"
一句话记住 分布式事务 = "把本地事务的原子性,从单库扩展到多机";所有方案的本质区别,只是"谁、在什么时候、用什么手段"来兜底这个原子性。

1. 背景:为什么分布式事务这么难

1.1 一个订单-库存-账户的跨库故事

假设下单时要同时做三件事:

如果订单插入成功、库存扣减成功,但账户扣款时因为余额不足或网络断了失败——那前面两件"已经落库"的事怎么办?要么三个一起成功,要么一个都不留痕迹。这正是事务的原子性(Atomicity)要求。

单机事务靠数据库内部的 undo/redo 日志和行锁就能兜底;跨库后,没有一个中心能同时回滚两个库的已提交数据,于是"部分成功"的脏状态就成了必须专门解决的难题。

1.2 三个永恒敌人:分区、崩溃、消息丢失

分布式事务比本地事务难,根子在"物理世界的不可靠":

敌人表现对事务的含义
网络分区 / 延迟A 能连协调者,B 连不上;消息迟到、乱序你不知道对方到底是"已提交"还是"刚要提交",状态无法确定
节点崩溃协调者或参与者在"提交前最后一刻"宕机重启后它失忆了:该提交还是该回滚?这种中间态最致命
消息丢失 / 重复通知丢了一条;或同一条发了两遍收不到回滚指令会脏写;收到两遍会重复扣款 → 引出"幂等"
一句话记住 分布式事务的所有复杂性,都来自三个字:不可靠——网络会断、机器会挂、消息会丢或重。协议就是为"在不可靠上达成可靠"而设计。

1.3 ACID 在分布式的崩塌

本地事务的 ACID 四大性质,搬到分布式下各自受挫:

正因为这四大性质在分布式下很难同时满,业界才分化出两条路线:强一致(咬牙保住原子+隔离,付性能代价)最终一致(先放你各自的原子,靠补偿+幂等兜底,换高性能)

1.4 一致性谱系:强一致 vs 最终一致

这和你在本系列「分布式协调」里学的 CAP 直接衔接:

取舍 选哪条路线,本质是在问:"你的业务能不能容忍'短暂不一致'?" 银行转账(钱不能多不能少,哪怕一秒)偏向强一致;电商下单(库存暂时飘一下、稍后对齐)常走最终一致。

2. 强一致路线:2PC 两阶段提交

2PC(Two-Phase Commit)是分布式事务最经典、最"教科书"的强一致方案。它不神秘,核心就一句:先让所有参与者把活干一半并锁住(Prepare),确认大家都没问题后,再由协调者一声令下统一提交(Commit)。

2.1 核心思想:先问"能不能",再"统一做"

直觉类比:公司团建,老板(协调者)问三个同事(参与者):"下周六能去吗?"(Prepare)。三人都说"能"(Vote Yes),老板宣布"那就定了"(Commit);只要有一人说"去不了"(Vote No),老板宣布"取消"(Rollback)。关键点:在听到老板最终决定前,谁都不能擅自动——这就是"锁"。

2.2 阶段一 Prepare(准备/投票)

  1. 协调者给每个参与者发 Prepare(xid),xid 是全局事务 ID,用来把这次跨库操作串起来。
  2. 每个参与者:执行本地事务的所有 SQL,但不提交;把 undo(回滚用)和 redo(重放用)日志写好;对涉及的数据加锁(行锁/表锁),防止别人并发改。
  3. 然后向协调者回复 Vote Yes(我准备好了,可以提交)或 Vote No(我这边出错,干不了)。
注意:Prepare 阶段数据已经改了,只是"挂起未提交"。锁一直握着,直到第二阶段结束。这正是性能瓶颈的源头——锁被占用的时间 = 整个 2PC 的耗时(包含多次网络往返)。

2.3 阶段二 Commit / Rollback(提交/回滚)

一句话记住 2PC 的"两阶段"=「先各自干一半并锁住、投票」+「听令统一提交或回滚」。它用"协调者这一个点"换来了"全局原子性"。

2.4 协调者:唯一的"裁判"

协调者(Coordinator / Transaction Manager)是 2PC 的灵魂,也是命门:

2.5 五大痛点:阻塞/单点/不一致/脑裂/超时

痛点解释
① 同步阻塞Prepare 到 Commit 之间,所有参与者都拿着锁干等。并发量大时锁冲突严重、吞吐低。
② 单点故障协调者挂了,整个事务卡死,参与者锁永远不释放(除非协调者恢复)。
③ 数据不一致协调者发完 Commit 给 A,自己宕机;B 没收到 Commit。结果:A 提交了、B 没提交 → 数据分裂。
④ 脑裂(双主)协调者宕机后选出新协调者,但旧协调者其实还活着(网络分区假死),两个"裁判"下不同指令,参与者无所适从。
⑤ 超时困境参与者收不到协调者最终指令时,它不知道该提交还是回滚(可能别人都提交了),只能干等或赌——这就是 2PC 无法在"分区时"保证一致的根本原因。
2PC 的致命伤:协调者在"做出决策后、通知完所有人前"这个窗口宕机,会造成部分提交的不一致,且无法自动修复。这是它"强一致"名义下的真实裂缝——它只在"协调者不在这个窗口挂"时强一致。

2.6 图解 2PC 时序

协调者 参与者 A 参与者 B 阶段一:Prepare(投票) Prepare(xid) 同时发给B Vote Yes(已改未提交+加锁) Vote Yes 阶段二:Commit(统一提交) Commit(xid) 同时发给B ACK(释放锁) ACK ⚠ 数据不一致窗口 协调者发完 Commit→A,自己宕机; B 未收到。结果:A 已提交、B 未提交 → 数据分裂,需人工/对账修复
图 1:2PC 时序。阶段一所有参与者执行并加锁但不提交;阶段二由协调者统一下令提交。右侧红框标注"协调者决策后、通知完前宕机"的不一致窗口——这是 2PC 的硬伤。

3. 3PC:给 2PC 打的三处补丁(仍非完美)

3PC(Three-Phase Commit)是想修补 2PC"参与者收不到最终指令就只能干等"的痛点。思路:把第二阶段再拆一刀,让参与者在超时时有更明确的"默认动作"。

3.1 CanCommit / PreCommit / DoCommit

  1. CanCommit(询问):协调者只问"你能参与吗?"(不涉及加锁,轻量探测)。参与者回 Yes/No。
  2. PreCommit(预提交):只有全票 Yes 才进入。协调者发 PreCommit,参与者执行本地事务、写日志、加锁但不提交(等于 2PC 的 Prepare)。参与者回 ACK。
  3. DoCommit(提交):协调者收齐 ACK 后发 DoCommit,参与者正式提交、释放锁。若协调者超时,参与者在 PreCommit 阶段已拿到"大家都准备好了"的信号,默认自行提交(这是和 2PC 最关键的差别)。

3.2 超时推进与单点缓解

3.3 为什么依然可能不一致

3PC 仍然不是完美的:如果参与者在 PreCommit 后、DoCommit 前,因为网络分区收不到 DoCommit,它"自行提交"了;但协调者其实是因为收到某个参与者的 No 才决定回滚的——于是这个自行提交的参与者,和回滚的参与者 again 出现不一致。3PC 只是把"不一致的概率和等待时长"降低了,没有根除。它需要"网络分区期间节点行为可控"的假设,现实难保证。
一句话记住 3PC = 2PC 加一个"预提交"中间态,让参与者超时后能"按默认动作"推进,减少阻塞;但网络分区下仍可能不一致,所以工程上 2PC/XA 远比 3PC 常用。

4. XA:数据库层的 2PC 标准接口

2PC 是个"思想",XA 是它在数据库层的标准化落地——定义了数据库(资源管理器)和事务管理器之间该怎么对话。

4.1 XA 规范与 xid

XA(eXtended Architecture,由 X/Open 组织定义)规定了一套命令接口,让"事务管理器(TM)"能驱动多个"资源管理器(RM,即数据库)"走 2PC:

xa_start(xid)      -- 开启一个全局事务分支
... 业务 SQL ...
xa_end(xid)        -- 结束分支执行
xa_prepare(xid)    -- 第一阶段:准备(对应 2PC Prepare)
xa_commit(xid)     -- 第二阶段:提交
xa_rollback(xid)   -- 第二阶段:回滚
xa_recover()       -- 崩溃恢复:列出"已 prepare 但未决"的事务

xid 是全局事务 ID,把分布在多个库的同一笔业务串到一起。数据库底层用 undo 日志实现"prepare 后不提交、rollback 可回退"。

4.2 JTA:Java 的事务 API

在 Java 里,JTA(Java Transaction API)是 XA 的上层接口;具体落地靠JTS 或应用服务器/框架(如 Spring + Atomikos / Narayana)充当事务管理器(TM),去调用各数据库的 XA 驱动(RM)。

@Transactional  // Spring 在背后:开启 JTA 全局事务
public void placeOrder() {
    orderDao.insert(...);   // 分支1:order_db (XA)
    stockDao.deduct(...);   // 分支2:stock_db (XA)
    accountDao.pay(...);    // 分支3:account_db (XA)
} // 方法结束 → TM 自动走 2PC:prepare 全部分支 → commit
对开发者来说,XA/JTA 的侵入性极低——你写法和本地事务几乎一样,提交回滚的"两阶段"由 TM 在背后自动编排。这是它最大的吸引力。

4.3 XA 的局限(锁定/跨语言/性能)

局限说明
长事务锁资源Prepare 阶段就对数据加锁且不释放,直到全局提交。微服务调用链长 → 锁持有时间 = 整条链耗时 → 严重阻塞、易死锁。
强依赖数据库 XA 支持MySQL/PostgreSQL/Oracle 支持,但很多 NoSQL、消息队列、外部 API 不支持 XA → 无法纳入同一全局事务。
跨语言/跨协议难XA 是数据库协议级的,微服务里"调用一个远程 HTTP 服务"套不进 XA。
性能差多次网络往返 + 长锁。高并发场景吞吐量远低于最终一致方案。
一句话记住 XA = "数据库帮你跑 2PC",开发爽(几乎零侵入)、但代价是长锁 + 慢 + 只能框住支持 XA 的资源。它适合"短平快、同构数据库、强一致"的内部场景,不适合长链微服务。

5. 最终一致路线之一:TCC(Try-Confirm-Cancel)

当 XA 的长锁你忍不了,就换思路:不靠统一锁,而是让每个服务先"预留资源",都预留成功再"确认",任一失败就"取消预留"。这就是 TCC。

5.1 三阶段语义:预留 → 确认 / 取消

阶段语义类比
Try预留业务资源(不是真正完成)。如冻结库存、冻结金额,而非直接扣减。餐厅订位:先"锁定"座位,你还没来。
Confirm真正执行。使用 Try 阶段预留的资源,做真正的扣减/落库。必须成功(靠重试)。你到店入座,座位归你。
Cancel释放 Try 预留的资源。撤销冻结,回滚到之前状态。取消订位,座位释放给别人。

关键差异 vs 2PC:Try 阶段每个服务是"本地提交"的(冻结记录已落库),没有跨库长锁;Confirm/Cancel 各自独立提交,失败就无限重试——所以叫"最终一致"。

5.2 业务侵入:三个方法都得自己写

TCC 的最大代价是强业务侵入:框架(如 Seata-TCC、Hmily)只负责"编排调用 Try/Confirm/Cancel 并保证都走到",但三个方法的具体逻辑你必须自己写

// 账户服务,要写三个接口
boolean tryPay(userId, amount);     // 冻结 amount,记录冻结流水
boolean confirmPay(xid, userId);    // 把冻结转为真实扣减(幂等)
boolean cancelPay(xid, userId);     // 解冻,删除冻结流水(幂等)
TCC 的"侵入"不是框架配置,而是你得重新设计数据模型:要加"冻结字段/冻结表",把"直接扣"改成"先冻结再确认"。这是它落地成本高的根。

5.3 空回滚(无 Try 先 Cancel)

场景:网络问题导致某个服务的 Try 没收到或没成功,但全局事务已经判定失败、开始发 Cancel。这时 Cancel 来了,可本地根本没 Try 过——这叫空回滚

处理:Cancel 方法里要能识别"我压根没 Try 过",于是记一条"空回滚标记"并返回成功,不能报错。否则框架会以为 Cancel 失败而一直重试。

5.4 防悬挂(Try 晚到)

场景:上面那个没收到 Try 的服务,它的 Try 后来迟到了(网络延迟送达)。如果它真的执行了 Try,就会留下一笔"永远没人 Confirm/Cancel 的冻结"——资源被永久挂起,叫悬挂

处理:Try 方法执行前,先查"有没有对应的空回滚标记"。如果有,说明全局已失败,Try 直接拒绝执行。这就是"防悬挂"。

5.5 幂等(Confirm / Cancel 重复)

网络会重发,Confirm/Cancel 可能被调用多次。方法必须幂等:用 xid + 分支id 做去重——执行前查"这笔录过没",录过就直接返回成功。TCC 三连坑(空回滚 / 防悬挂 / 幂等)是面试常考点,本质是"网络不可靠"在 TCC 上的三个具体表现。

一句话记住 TCC = 自己把"预留(Try)/确认(Confirm)/取消(Cancel)"三件套写好;空回滚(没Try别慌)、防悬挂(Try迟到要拒)、幂等(重复要扛)三坑都源于"网络会丢会重会慢"。

5.6 图解 TCC 三阶段与异常

正常路径 Try:冻结资源(本地提交) Confirm:真正扣减 完成 失败路径 Try:部分失败/超时 Cancel:释放冻结 回滚完成 TCC 三连坑(都因网络不可靠) ① 空回滚:Cancel 先到、Try 没执行 → 记"空回滚标记"并返回成功 ② 防悬挂:Try 迟到 → 先查空回滚标记,有则拒绝执行,否则永久挂起 ③ 幂等:Confirm/Cancel 被重发 → 用 xid+分支 去重,重复直接返回成功
图 2:TCC 正常走 Try→Confirm,失败走 Try→Cancel。下方红框是 TCC 必处理的三连坑:空回滚、防悬挂、幂等,根根都来自"网络会丢/会重/会慢"。

5.7 适用短事务

取舍 TCC 适合短事务、高并发、强一致诉求且资源可"预留"的场景(如秒杀扣库存、转账冻结)。不适合长流程业务(每个步骤都要写三套逻辑,成本爆炸),那该看 Saga。

6. 最终一致路线之二:Saga(长事务编排)

Saga 为长事务而生:一笔业务要跨很多步骤、跑很久(如"下单→扣库存→发货→通知→积分"),你不可能把它们都锁住。Saga 的思路:每一步都是独立的本地事务(立即提交),如果某步失败,就按相反顺序执行前面各步的"补偿"操作。

6.1 核心:一连串本地事务 + 对应补偿

把大事务拆成 T1 → T2 → T3 → ... → Tn,每个 Ti 是本地事务;为每个 Ti 配一个补偿 Ci(反向操作)。

例:下单(T1)→扣库存(T2)→扣款(T3);若扣款失败,则补偿:恢复库存(C2)→取消订单(C1)。

Saga 的"补偿"和"回滚"不同:回滚是数据库 undo,把没提交的数据抹掉;补偿是再发一笔反向业务(如"加回库存"),因为前面的 T 早已提交。所以补偿也要幂等。

6.2 编排 Choreography(事件驱动)

去中心、靠事件:每个服务完成本地事务后,发一个事件;下一个服务监听该事件,执行自己的事务,再发下一个事件。没有中心协调者。

6.3 协同 Orchestration(中心协调器)

有中心、靠命令:一个编排器(Orchestrator)按顺序调用各服务,记住执行到哪一步;某步失败,编排器负责按记录反向调用各补偿。

取舍 服务少、追求松耦合用编排(事件);流程复杂、要强管控用协同(编排器)。Seata 的 Saga 模式就是中心协同式。

6.4 隔离性缺失与对策

Saga 的阿克琉斯之踵:因为每步都立即提交,Ti 和 Tj 之间不存在数据库隔离。比如 T2 扣了库存并提交,这时另一个事务读了"已扣但未最终完成"的库存,或者 T3 失败后 C2 要恢复库存,但这段时间别人又买了——就会出"脏读/丢失更新"。对策:

对策做法
语义锁数据上加"处理中"状态位,别人看到就跳过重算,避免并发改同一行。
交换式更新补偿用"加回确切数值"而非"设为原值",避免覆盖其间他人的修改。例:补偿写 stock = stock + 1 而非 stock = 100
重读校验更新前重读、比对版本/状态,发现已被别人动过就放弃或换路径。
业务兜底实在难保隔离,就靠定时对账 + 人工介入收尾。
一句话记住 Saga = "把大事务拆成有补偿的小事务链";编排靠事件(松耦合)、协同靠编排器(好管控);它的软肋是没有隔离,要用"语义锁/交换式更新/重读"自己补。

6.5 图解 Saga 补偿(编排 vs 协同)

编排 Choreography(事件) T1下单 T2库存 T3失败 补偿C2事件↑ 无中心,服务间事件接力 协同 Orchestration(编排器) 编排器 T1 T2 T3✗ 编排器按序反向调补偿
图 3:左为编排式(服务间事件接力,无中心);右为协同式(编排器统一指挥调用与补偿)。两者补偿链方向相反,都是"已提交步骤反向撤销"。

7. 最终一致路线之三:可靠消息驱动

还有一大类场景:"本地业务"和"要通知别人做的事"必须一起成功或一起失败,否则就丢消息。这就是"可靠消息最终一致"。

7.1 本地消息表(本地事务 + 消息表 + 投递 + 幂等)

最朴素也最可靠的方案,ebay 提出:

  1. 同一本地事务里:既执行业务 SQL,又往本地消息表插一条"待发送"消息(同库同事务,要么都成要么都败)。
  2. 写个定时任务扫描"待发送"消息,发给消息队列;发送成功就标"已发送"。
  3. 消费者拿到消息处理业务,处理完更新自己的去重/状态表,保证幂等。
  4. 若发送失败/没 ACK,定时任务重试投递,直到成功。
精髓:把"发消息"变成"本地落库一条记录",用数据库事务的原子性保证"业务做了就一定有消息记录",从而不丢消息。代价是多一张表 + 定时任务。

7.2 事务消息:RocketMQ 的 half + 回查

本地消息表要自己写定时任务,有点重。RocketMQ 把它做成了Broker 原生能力

  1. 生产者发半消息(Half Message)给 Broker:对消费者不可见,仅占位。
  2. 生产者本地事务执行(下单)。
  3. 执行成功 → 发 Commit,半消息变可见;失败 → 发 Rollback,半消息丢弃。
  4. 回查(Check):若 Broker 迟迟没收到 Commit/Rollback(生产者宕机),会主动回调生产者查"这笔本地事务到底成没成",据此决定提交还是回滚。这是容错关键。
一句话记住 RocketMQ 事务消息 = "先发半消息占位 → 跑本地事务 → 再决定提交/回滚";回查机制堵住了"生产者提交前宕机"的漏洞。

7.3 最大努力通知(重试 + 对账)

最宽松的一种:不保证一定送达,但尽最大努力。生产者发消息,消费者处理;失败就按退避策略多次重试;同时提供对账接口,让接收方主动来查"我有没有漏掉"。典型用于"支付结果通知""短信通知"等允许最终补齐的场景。

取舍 本地消息表/事务消息 = "一定不丢"(强可靠);最大努力通知 = "尽量送到+对账兜底"(轻量、接受极小概率漏)。按业务对"丢失"的容忍度选。

7.4 图解 本地消息表流程

生产者(同库同事务) ① 业务SQL + ② 插"待发送"消息 → 原子:都成或都败 定时任务 ③ 扫"待发送" → ④ 发MQ → 标已发 失败重试 消息队列 MQ 消费者 ⑤ 处理 + ⑤ 去重表 幂等兜底 不丢消息的根:消息是"本地落库"出来的,不是"发了才记"
图 4:本地消息表。业务与消息记录在同一本地事务里落库,定时任务负责投递并标状态,消费方靠去重表幂等。核心是不依赖"发消息"的可靠性,而依赖"本地事务的原子性"。

8. 贯穿一切的基石:幂等设计

你会发现,TCC 的 Confirm/Cancel、Saga 的补偿、消息的消费——都反复要求"幂等"。因为分布式系统下,任何一次调用都可能在"已成功但未收到 ACK"时被重试。幂等就是"同一个请求来 N 次,效果和第 1 次一样"。

8.1 为什么分布式事务离不开幂等

没有幂等,补偿会被重复执行(库存加回两次)、消息会被重复消费(扣款两次)。所以"补偿/消息 + 幂等"是绑定的——你选最终一致路线,就等于默认要写幂等。这是底层机制的一部分,不是可选项。

8.2 唯一键 / 去重表

最常用:用 唯一业务键(如 order_id)建唯一索引。重复插入直接抛"已存在"异常 → 捕获后当作成功。或建一张去重表,处理前先 INSERT 记录,唯一键冲突即说明处理过。

INSERT INTO dedupe(record_id, xid) VALUES (?, ?);  -- 唯一冲突=已处理,直接返回成功
-- 再做真正的业务

8.3 Token 机制

防"重复提交/重复下单":客户端先向服务端申请一个一次性 Token;提交时带上 Token,服务端用"Token + 业务"做唯一键消费一次后作废。重复带着同一 Token 来 → 直接拒绝。常见于表单防重、支付防重。

8.4 版本号 / 状态机

一句话记住 幂等四板斧:唯一键/去重表(拦重复插入)、Token(拦重复提交)、版本号(乐观锁拦并发)、状态机(按业务语义拦非法)。最终一致路线缺了它必出 bug。

9. Seata:把底层机制封装成"开箱即用"

Seata(阿里开源)的价值:把前面讲的底层机制(协调者、补偿、全局锁、undo log、幂等)打包成框架,让你少写大量样板代码。它定义了一套统一的角色模型和四种模式。

9.1 角色:TC / TM / RM

角色全称职责
TCTransaction Coordinator协调者。独立部署的服务,维护全局事务和分支状态,驱动提交/回滚。对应 2PC 里的协调者
TMTransaction Manager定义全局事务的边界(@GlobalTransactional 注解的方法),告诉 TC 开事务、决定最终提交还是回滚。
RMResource Manager各业务库侧的代理,管理分支事务,向 TC 注册分支、上报状态、执行 TC 下发的提交/回滚。
看到没?TC/TM/RM 就是 2PC 的"协调者 + 应用 + 资源管理器"的现代化重命名。Seata 的骨架就是 2PC,只是把协调者独立成一个高可用服务,并加了"自动补偿"等增强。

9.2 AT 模式:一阶段提交 + undo log + 全局锁

AT(Auto Transaction)是 Seata 的默认、最省心模式,目标是"像写本地事务一样写分布式事务"。原理:

9.3 AT 二阶段:自动回滚与脏写防护

一句话记住 Seata AT = "一阶段直接提交 + 偷偷留 undo log + 抢全局锁;二阶段提交就删日志、回滚就靠 undo log 反向补偿"。它用"留快照 + 抢全局锁"绕开了 XA 的长锁,代价是多了 undo log 表、且只拦 Seata 参与的写

9.4 TCC / Saga / XA 模式

Seata 不止 AT,还内置了另外三种,正好对应我们前面学的底层路线:

Seata 模式对应底层特点
AT自动补偿(undo log + 全局锁)零代码侵入,自动;适合大部分 CRUD 场景
TCCTCC 三阶段你要写 Try/Confirm/Cancel;适合高性能、需预留资源
Saga长事务补偿链中心编排器驱动;适合长流程、跨多服务
XA数据库层 2PC直接用数据库 XA;强一致但长锁,慎用于长链
框架即机制 这再次印证本系列铁律:框架只是底层机制的实例化与取舍。Seata 四种模式 = {自动补偿, TCC, Saga, XA} 四种底层机制,被同一个 TC/TM/RM 骨架串起来。

9.5 图解 Seata AT 一/二阶段

TC 协调者 RM(业务库侧) ① 拦截SQL,记 undo log ② 执行业务SQL ③ 抢全局锁(TC) ④ 本地事务直接提交 —— 一阶段结束,锁释放快 —— ⑤ 全局提交→异步删undo ⑤' 全局回滚→undo反向补偿 全局锁防脏写 别的非Seata写改同一行 → 拿不到锁→被拒(防覆盖) 相比 XA:AT 一阶段就提交,不长期占本地锁;靠 undo log + 全局锁兜底回滚与隔离
图 5:Seata AT。RM 拦截 SQL 留存 undo log、抢全局锁后本地直接提交(一阶段);二阶段提交仅删日志、回滚则靠 undo log 反向补偿。全局锁专门防"别人在回滚前改了这行"的脏写。

10. 选型对比:没有银弹,只有取舍

学了这么多,最后落到"我该用哪个"。记住 route 区的核心:要强一致就付锁/性能的代价,要高性能就接受最终一致 + 自己写补偿。

10.1 四个维度:一致性/侵入性/性能/复杂度

10.2 横向对比表

方案一致性侵入性性能隔离性典型场景
2PC / XA强一致差(长锁)好(锁住)同构DB、短事务、强一致内部系统
Seata-AT最终/读已提交极低较好中(全局锁防脏写)通用微服务 CRUD,想少写代码
TCC最终(业务可见一致)高(三方法)弱(需自己设计)高并发、可预留资源(秒杀/转账)
Saga最终一致中高(补偿)弱(无隔离)长流程业务(下单到履约全链)
可靠消息最终一致中(消息表/事务消息)解耦通知、异步补齐(支付通知)

10.3 图解 选型定位

一致性强度 → 业务侵入性 / 实现复杂度 → ↑ 强一致 + 低吞吐 | ↓ 最终一致 + 高吞吐 2PC/XA Seata-AT TCC Saga 可靠消息 左上=强一致低侵入(但慢);右下=最终一致高侵入(但快)
图 6:选型定位四象限。横轴业务侵入/复杂度,纵轴一致性强度(上强下弱)。没有方案在"右下强一致低侵入"——强一致必付代价,这正是"没有银弹"的几何表达。

10.4 决策清单

选型决策清单(自顶向下问):
  1. 能接受"短暂不一致"吗?不能(钱、核心账)→ 2PC/XA 或 Seata-XA,接受慢。
  2. 能接受,且想少写代码 → Seata-AT(通用 CRUD 首选)。
  3. 高并发、资源可预留(库存/额度)→ TCC。
  4. 流程长、跨多服务 → Saga(协同式好管控)。
  5. 只是异步通知/解耦补齐 → 可靠消息(本地消息表 / RocketMQ 事务消息)。
  6. 无论选哪条最终一致路线,幂等都是必答题。

11. 一句话路线总结

一句话记住 分布式事务的底层只有一条路被切成两端:一端是"协调者 + 两阶段锁"(2PC/XA,强一致但慢),一端是"补偿 + 消息 + 幂等"(TCC/Saga/可靠消息,快但需自己兜底)。Seata 是把这两条路都修成了"框架高速公路"。先想透协调者/补偿/幂等这三个词,所有框架你都能一眼看穿它用的是哪块积木。
回到底层路线:下次看到任何分布式事务框架,先问三句——① 谁是协调者?(TC / 中心编排器 / 消息 Broker)② 靠什么回滚?(undo log / 补偿链 / 反向业务)③ 怎么保幂等?(去重表 / 状态机 / 全局锁)。答得上来,框架就不再是黑盒。
本部分属于《分布式与微服务底层学习路线》系列 Part 07。底层优先:先讲清机制(协调者/补偿/幂等/全局锁/共识),再映射到框架(Seata 等)。
配套 Markdown 版本见同名 .md 文件。图示建议结合 HTML 版查看(MD 版以 ASCII/文字替代)。