单机上一条 BEGIN ... COMMIT 就能把「扣钱」和「记账」绑成原子操作,数据库替你扛下了所有一致性。可一旦「扣广告预算」在计费服务、「记流水」在账务服务、「改订单」在订单服务——三个进程、三个数据库、三次网络调用——那条优雅的本地事务就碎了。分布式事务的全部难题,都来自「没有一个数据库能同时锁住这三样东西」。
分布式协调系列(5 篇):1. 分布式共识:Paxos 与 Raft 图解 · 2. ZooKeeper 与 etcd 深挖 · 3. 分布式锁深挖 · 4. 分布式事务深挖(本篇) · 5. 分布式 ID 深挖
一句话定位:分布式事务不是「把本地事务放大到多机」,而是「放弃强一致的幻想,用补偿 + 幂等 + 可靠消息把最终一致做扎实」。
TL;DR
- 单机 ACID 靠的是「同一个存储引擎 + 同一份日志(WAL)」:跨库、跨服务后没有共享日志,也没有能一次锁住所有资源的锁管理器,本地事务的原子性与隔离性直接失效。
- CAP 逼你选边:网络分区不可避免(P 必选),强一致(CP)与高可用(AP)二选一;互联网业务多数选 AP + BASE + 最终一致——用「基本可用 + 软状态 + 最终一致」换吞吐与可用。
- 2PC 是「阻塞式强一致」:prepare 投票 + commit 提交两阶段,参与者投 Yes 后必须锁住资源等指令;协调者单点、同步阻塞是它的两大死穴,协调者在阶段二宕机会让参与者锁死。
- 3PC 加了 timeout 与 pre-commit 想缓解阻塞,代价是多一轮 RTT,且在网络分区下仍可能不一致——工程上很少直接用。
- TCC 把事务下沉到业务层:Try 预留资源 → Confirm 确认扣减 → Cancel 释放;性能比 2PC 好,但要自己处理空回滚、悬挂、幂等三大坑,且每个接口都得拆成三段,侵入性强。
- Saga 把长事务拆成一串本地事务 + 对应补偿:正向链
T1→T2→T3,任一步失败就按相反顺序执行补偿C2→C1。无隔离性(中间态对外可见)、补偿必须幂等,分**编排(orchestration)与协同(choreography)**两种风格。 - Outbox / 本地消息表 + CDC 解决「双写不一致」:业务表与消息表在同一个本地事务里原子落库,再用 CDC(binlog 订阅)把 outbox 行变成可靠事件投到 MQ——「不漏、不多、可重放」。
- 幂等是一切补偿与重试的地基:网络重试、MQ 至少一次投递、补偿重放都会让同一动作执行多次;没有幂等,Saga/TCC/Outbox 全是空中楼阁(详见 幂等与一致性)。
- 选型看一致性预算:强一致小范围可上 2PC/Seata AT;跨服务长流程用 Saga/TCC + Outbox;能异步的尽量用可靠消息做最终一致;很多时候最优解是根本别做分布式事务(合并服务 / 用幂等重试代替回滚)。
- AdTech 落地:广告计费扣预算 + 记流水跨服务,用 Saga 编排;补偿动作非幂等 + 没处理空补偿/悬挂,一次网络重试就重复扣预算、补偿又把正常流水冲平,对账对不上——修复靠补偿幂等 + 全局事务 ID + Outbox 可靠投递。
Table of contents
Open Table of contents
1. 从单机 ACID 到分布式:为什么事务会「碎」
先看清单机事务到底靠什么成立,才知道到了分布式为什么就不成立。
1.1 单机 ACID 的底座:共享日志 + 单点锁
MySQL 里一条转账事务能做到原子(要么都成、要么都败)、隔离(并发看不到中间态),靠的是两样东西:
- 一份共享的预写日志(WAL / redo/undo log):所有修改先写日志再改数据页,崩溃后靠日志 redo 已提交、undo 未提交,原子性与持久性都落在这一份日志上。
- 一个统一的锁管理器 / MVCC:同一个引擎能一次锁住这次事务涉及的所有行,隔离性由它保证。
关键点是:这两样都要求「所有被操作的数据在同一个存储引擎里」。 一旦数据分散到多个库、多个服务,就既没有一份能覆盖全局的日志,也没有一个能一次锁住所有资源的锁管理器——ACID 的地基被抽走了。
1.2 分布式下丢的是什么
| 特性 | 单机 | 跨库 / 跨服务 |
|---|---|---|
| 原子性 A | WAL redo/undo 保证 | 没有全局日志,一个库提交了另一个库可能失败 → 需要补偿回滚 |
| 一致性 C | 约束 + 事务保证 | 只能做到最终一致,中间有不一致窗口 |
| 隔离性 I | 锁 / MVCC 保证 | 跨服务几乎无隔离,中间态对外可见(Saga 尤其明显) |
| 持久性 D | 各库各自的 WAL | 单库仍有,但「全局提交」这件事没有持久化载体 |
所以分布式事务的所有方案,本质都是在用应用层的机制(协调者、补偿、消息、幂等)去补回数据库原本免费给你的那部分保证——而且几乎总要在一致性上打折。
1.3 CAP 与 BASE:先想清楚要哪种一致
- CAP:一个分布式系统在**网络分区(P)发生时,只能在一致性(C)与可用性(A)**之间二选一。分区不是「会不会」而是「一定会」,所以真正的选择是 CP(分区时拒绝服务保一致) vs AP(分区时继续服务、事后收敛)。
- BASE:AP 阵营的落地哲学——Basically Available(基本可用)、Soft state(软状态,允许中间态)、Eventually consistent(最终一致)。它是 ACID 的「对立面松弛版」。
互联网业务(尤其广告、电商)绝大多数选 AP + BASE:宁可短暂不一致、事后对账/补偿收敛,也不能因为一个下游抖动就让整条主链路不可用。这决定了后面 Saga / Outbox 这类「最终一致」方案才是主流,而 2PC 这类「强一致但阻塞」的方案只适合极小范围。
2. 2PC 与 3PC:阻塞式强一致
2PC(Two-Phase Commit)是最经典的强一致方案,也是理解一切分布式事务的起点。它引入一个事务协调者(TC / Transaction Coordinator),把提交拆成两个阶段。
2.1 两阶段:prepare 投票 + commit 提交
2PC 两阶段:prepare 让所有参与者「就绪并锁资源」,commit 才真正提交。协调者一旦在两阶段之间宕机,投了 Yes 的参与者就锁死等待——这就是 2PC 无法回避的阻塞。
伪代码视角:
// 协调者 Coordinator
phase1_prepare():
for p in participants:
vote[p] = p.prepare() // 参与者预留资源 + 写 undo/redo + 锁定
if all(vote == YES):
writeLog(COMMIT) // 关键:先把决议持久化,再通知
phase2_commit()
else:
phase2_abort()
phase2_commit():
for p in participants:
p.commit() // 提交本地事务、释放锁
// 若某个 commit 超时 → 无限重试(参与者必须最终提交)
// 参与者 Participant
prepare():
lock(resources); writeUndoRedo()
return YES // 一旦投 YES,就必须能 commit,中途不能反悔
2.2 两个死穴:同步阻塞与协调者单点
- 同步阻塞:参与者从投 Yes 到收到 commit/abort 之间,必须锁住资源(不能提交、也不能回滚,否则违反原子性)。这段时间内相关行被锁,其他事务只能等——高并发下这是致命的吞吐杀手。
- 协调者单点:协调者是全局决策者。若它在阶段一之后、阶段二之前宕机,参与者投了 Yes 却收不到最终指令,只能锁住资源无限等待——这就是 2PC 最被诟病的阻塞问题。即使协调者重启靠日志恢复,这期间资源仍被锁死。
- 数据不一致的残余:阶段二发送 commit 时,若部分参与者收到、部分没收到(网络分区),会出现「一半提交一半没提交」的短暂不一致,需靠超时重试收敛。
2.3 3PC:加 timeout 与 pre-commit,仍不完美
3PC(Three-Phase Commit)想缓解 2PC 的阻塞,把提交拆成 CanCommit → PreCommit → DoCommit 三阶段,核心改动两点:
- 给参与者也加超时:参与者在 PreCommit 后若长时间收不到 DoCommit,会自行提交(因为能到 PreCommit 说明大家都同意了),避免无限阻塞。
- 多一个 PreCommit 缓冲阶段:让参与者在真正提交前有一个「预提交」态,降低协调者宕机的影响面。
代价是多一轮网络 RTT(更慢),而且在网络分区下「参与者自行提交」反而可能造成不一致(一部分超时提交、一部分收到 abort)。所以 3PC 理论意义大于工程价值,生产里很少直接用——真要强一致,多数人转向基于共识(Raft/Paxos)的方案或数据库原生的 XA/Seata。
2PC 的现代化身:MySQL XA、Seata 的 AT 模式(自动生成反向 SQL 做回滚,本质是「2PC + 自动补偿」)。它们把 2PC 包装得好用了些,但阻塞与单点的本质约束没变,只适合一致性要求高、参与方少、事务短的场景。
3. TCC:把回滚下沉到业务层
2PC 依赖数据库的锁与 prepare 能力,粒度粗、阻塞重。**TCC(Try-Confirm-Cancel)**换个思路:不锁数据库,而是让业务自己实现「预留 / 确认 / 取消」三个动作,把事务语义下沉到业务层。
3.1 三个动作
以「扣预算」为例:
- Try:预留资源,做一切可回滚的检查与冻结。比如把预算从
可用余额挪到冻结余额,而不是真正扣减。 - Confirm:确认执行,真正扣减。只操作 Try 冻结的那部分,且必须幂等。全局所有参与方 Try 都成功后才 Confirm。
- Cancel:取消,释放 Try 冻结的资源(把冻结余额还回可用余额)。任一参与方 Try 失败,则所有参与方 Cancel。
// 扣预算的 TCC 三段
tryReserve(txId, campaignId, amount):
// 幂等:同一 txId 重复调用只冻结一次
if reserved(txId): return OK
if available(campaignId) < amount: return FAIL
move(campaignId, amount, from=available, to=frozen, txId)
confirm(txId):
if !reserved(txId): return OK // 空回滚/幂等防护,见 3.2
move(txId, from=frozen, to=spent) // 真正扣减,只动本 txId 冻结的额度
cancel(txId):
if !reserved(txId): markCancelled(txId); return OK // 空回滚
move(txId, from=frozen, to=available) // 释放冻结
对比 2PC:TCC 的「Try」相当于 prepare,但锁的是业务层的「冻结额度」而非数据库行锁,粒度细、不长时间占用数据库连接,性能好得多;协调只需记录全局事务状态,驱动 Confirm/Cancel。
3.2 三大坑:空回滚、悬挂、幂等
TCC 侵入性强,而且有三个必须处理的经典问题,否则一定出数据错误:
- 幂等:Confirm/Cancel 因网络重试可能被调用多次,必须保证多次执行结果一致(用
txId去重)。 - 空回滚(empty rollback):Try 因网络问题根本没执行(或超时未到),协调者却发起了 Cancel。此时 Cancel 找不到对应的 Try 记录,必须识别出「这是一次没有 Try 的 Cancel」并直接返回成功,而不是报错或误操作。做法:Cancel 时先查是否有 Try 记录,没有就记一条「已取消」标记直接返回。
- 悬挂(hanging):更隐蔽——Cancel 比 Try 先到(Try 的网络包被延迟)。若 Cancel 先执行了空回滚,随后延迟的 Try 才到达并冻结了资源,这笔冻结就再也没人来 Confirm/Cancel,永远悬在那里。防护:Try 执行前先检查「该 txId 是否已被 Cancel 过」,若已取消则 Try 直接放弃。
空回滚与悬挂的根因都是乱序 + 重试,而识别它们的唯一抓手是全局唯一的事务 ID(txId)+ 每个参与方记录 txId 的状态流水。这也预示了后面 war story 的核心教训:没有全局事务 ID,一切补偿都无从对齐。
4. Saga:长事务拆成本地事务 + 补偿
2PC 和 TCC 都要求参与方在事务期间「持有资源」(锁或冻结)。但很多业务流程很长(下单 → 扣库存 → 扣预算 → 记账 → 发券……),持有资源到全流程结束根本不现实。Saga 的思路更彻底:把长事务拆成一串独立的本地事务,每个本地事务提交后立即释放资源;若后续失败,就执行前面每一步的「补偿事务」把它撤销。
4.1 正向链 + 反向补偿链
Saga:正向 T1→T2→T3 每步都是独立提交的本地事务;T3 失败后按相反顺序执行补偿 C2→C1。补偿是「语义回滚」(冲正、退款),不是数据库 rollback——所以它必须幂等、可重放。
关键性质:
- 无隔离性:T1 提交后,它的结果(预算已扣)立刻对外可见,此时 T3 还没执行。若最终失败要补偿,别人可能已经读到过这个「中间态」。这是 Saga 最大的代价,需要业务能容忍(或用「预留态/待确认态」等业务手段遮盖)。
- 补偿是语义回滚:
C1不是ROLLBACK,而是一个新的正向操作——「扣了预算」的补偿是「加回预算并记一条冲正流水」。所以补偿本身也可能失败,需要重试到成功。 - 补偿必须幂等:因为补偿会被重试,重复执行不能重复加钱。
4.2 编排 orchestration vs 协同 choreography
Saga 有两种驱动方式:
| 维度 | 编排 Orchestration | 协同 Choreography |
|---|---|---|
| 驱动者 | 一个中央 Saga 协调器发指令,调 T1、T2、T3,失败时依次调补偿 | 无中心;每个服务完成后发事件,下游订阅事件自行推进 |
| 优点 | 流程集中、可视、易排查、易加超时/重试 | 松耦合、无单点、天然事件驱动 |
| 缺点 | 协调器是复杂度中心(但不是强单点,可高可用) | 流程散落在各服务的事件订阅里,全局视图丢失,难调试、易出「事件环」 |
| 适用 | 流程复杂、步骤多、要强可观测(推荐默认) | 步骤少、服务高度自治、追求极致解耦 |
经验法则:步骤 ≥ 3 或需要清晰的失败处理/超时策略时,优先编排;用 Kafka 之类做事件总线的纯协同 Saga,超过三四步后几乎必然变成「谁也说不清全局状态」的意大利面。
// 编排式 Saga 协调器(简化)
runSaga(order):
steps = [deductBudget, recordBilling, updateOrder]
comps = [refundBudget, reverseBilling, /* updateOrder 本地失败无需补偿 */]
done = []
for i, step in enumerate(steps):
ok = step.invoke(order.txId) // 带全局 txId,天然幂等
if ok: done.push(i)
else:
for j in reverse(done): // 按相反顺序补偿
comps[j].invoke(order.txId) // 补偿也带 txId,幂等 + 重试到成功
return FAILED
return OK
注意每一步都带 全局 txId:正向步骤靠它幂等、补偿步骤靠它对齐「补哪一笔」。这是 Saga 能正确运作的隐形前提。
5. Outbox / 本地消息表 + CDC:可靠事件投递
无论 Saga 编排还是协同,都绕不开一个底层问题:「更新数据库」和「发一条消息(给下游/给协调器)」这两件事,怎么做到原子? 这就是经典的双写问题。
5.1 双写为什么必然出错
朴素写法:
db.update(order); // ① 更新业务库
mq.send(orderCreatedEvent); // ② 发消息通知下游
这两步不在一个事务里,任意一种崩溃都出错:
- ①成功、②失败(发消息前进程崩溃 / MQ 抖动):库改了,事件没发出去 → 下游永远不知道,Saga 卡住、数据长期不一致。
- ②成功、①回滚(先发消息后提交,事务回滚):事件发出去了,但库其实没改成 → 下游按一条不存在的变更动作,凭空多扣/多发。
只要 DB 和 MQ 是两个系统,就没有跨系统事务能把①②绑成原子——这和缓存一致性里「更新 DB + 删缓存」是同一个双写难题。
5.2 Outbox:把「待发消息」写进同一个本地事务
解法是本地消息表 / Outbox 模式:不直接发 MQ,而是把「要发的消息」作为一行,和业务数据写进同一个本地数据库事务——两者要么一起成功、要么一起失败,原子性由本地事务保证。再由一个独立的投递器把 outbox 里的消息可靠地投到 MQ。
Outbox:业务表与消息表在同一本地事务里原子写入,双写不一致被消灭在源头;再由 CDC 订阅 binlog 把 outbox 行变成可靠事件投到 MQ。这正是 binlog 订阅 在事务场景的另一种用法。
投递器有两种实现:
- 轮询 Outbox 表:一个后台任务定期
SELECT ... WHERE status='PENDING',发到 MQ 后标记SENT。简单、无额外组件,但有轮询延迟与「查改」压力。 - CDC 订阅 binlog(推荐):用 Debezium/Canal 订阅数据库 binlog,outbox 表一有新行就被捕获、投到 Kafka。不漏(进了 binlog 必达)、可重放(消费位点可回退)、有序(单表按 binlog 顺序)、低侵入(业务只管写本地事务)。
5.3 「至少一次」+ 消费端幂等 = 可靠
Outbox + CDC/MQ 提供的是 at-least-once(至少一次) 投递:一条 outbox 消息可能因重试被投递多次。所以消费端必须幂等——用消息里的全局事件 ID / txId 去重:
public void onEvent(BudgetDeductedEvent e) {
// 幂等键:全局事件 ID,落库唯一约束防重复消费
if (!dedup.tryMark(e.eventId())) return; // 已处理过,直接跳过
billingService.record(e); // 真正的业务处理
}
Outbox 把「可靠地发出去」解决了,剩下的「不重复地处理」交给消费端幂等——这两块拼起来,才是 Saga/事件驱动架构能落地的完整闭环。想做到端到端 exactly-once(如流式计费),还要叠加事务性 sink 与 read-committed,见 Flink 端到端精确一次。
6. 幂等:一切补偿与重试的地基
前面每一节都在反复出现「幂等」二字,这里把它单独拎出来说清——因为幂等不是可选优化,而是分布式事务能否成立的前提。
6.1 为什么分布式里「重复执行」是常态
在分布式系统里,「一个操作只执行一次」是奢望,重复来自四面八方:
- 网络重试:调用超时后,客户端不知道服务端到底执行没执行,只能重试 → 可能执行两次。
- MQ 至少一次投递:Kafka/RabbitMQ 为了不丢消息,宁可重复投递 → 消费端会重复收到。
- 补偿重放:Saga 补偿、TCC 的 Confirm/Cancel 都会重试到成功 → 天然多次执行。
- 故障恢复重放:进程崩溃恢复后重新处理未确认的消息。
只要有其中任意一条(现实中四条都有),同一个动作被执行多次就是必然。没有幂等,「扣预算」重试一次就多扣一次,「补偿」重放一次就多补一次。
6.2 幂等的三种常见做法
- 唯一键 + 去重表:给每个操作一个全局唯一
txId/eventId,处理前先往去重表插一条(靠唯一约束),插入冲突就说明处理过、直接跳过。最通用。 - 状态机流转:操作只在特定状态下才生效(如「待支付 → 已支付」,已是「已支付」再来一次直接返回成功),用状态约束天然去重。
- 乐观锁 / 版本号:
UPDATE ... SET v=v+1 WHERE id=? AND v=?,靠版本号保证同一变更只应用一次(也顺带防乱序覆盖)。
幂等的设计细节、去重表选型、幂等与最终一致的关系,是 幂等与一致性 的主题。这里只强调一个判断标准:你设计的每一个正向操作和每一个补偿操作,问自己「重复执行两次,结果一样吗?」——只要有一个答案是「不一样」,它就是一颗定时炸弹。 下面的 war story 就是这颗炸弹爆了。
7. 选型矩阵:一致性 × 性能 × 复杂度,以及何时别用分布式事务
没有银弹,选型就是在一致性强度、性能、复杂度、业务侵入四个维度上权衡。
7.1 五种方案对比
| 方案 | 一致性 | 性能 | 侵入/复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC / XA / Seata AT | 强一致 | 差(同步阻塞、锁资源) | 低侵入(AT 自动生成回滚 SQL) | 参与方少、事务短、要强一致(如同一 DB 多表跨库) |
| TCC | 强一致(准) | 好(业务层冻结、无 DB 长锁) | 高(每接口拆三段 + 处理空回滚/悬挂) | 资金、库存等强一致核心链路 |
| Saga | 最终一致 | 好(各步独立提交) | 中(要写补偿 + 幂等 + 编排) | 长流程、多服务、能容忍中间态 |
| Outbox + 可靠消息 | 最终一致 | 很好(异步) | 中(Outbox 表 + CDC/MQ + 消费幂等) | 事件驱动、可异步的最终一致 |
| 本地事务 + 幂等重试 | 最终一致 | 最好 | 最低 | 能把跨服务写改成「幂等的异步补写」时 |
7.2 何时干脆别做分布式事务
分布式事务是昂贵且易错的东西,很多时候最好的方案是避免它:
- 合并服务 / 合并库:如果「扣预算」和「记流水」总是一起发生、又都属于计费域,那它们本就该在同一个服务、同一个库里用本地事务搞定——是过度拆分的微服务制造了分布式事务,而不是业务需要它。
- 改成异步最终一致 + 幂等重试:很多「跨服务写」不需要同步强一致。主操作本地事务提交后发一个事件,下游幂等地异步补写,失败就重试到成功——用 Outbox 就够了,根本不需要 Saga 的补偿链。
- 用对账兜底代替实时补偿:低频、金额可核对的场景,允许短暂不一致,用离线对账 + 差异修复兜底,比在线补偿简单得多、也更稳。
一句话选型:能用本地事务就别跨服务;必须跨服务,能异步最终一致就别同步强一致;必须强一致,参与方少用 TCC/2PC、流程长用 Saga;无论哪种,先把幂等和全局事务 ID 打好地基。
参考
- Hector Garcia-Molina, Kenneth Salem. Sagas(1987 原始论文):Saga 模式的源头,长事务拆本地事务 + 补偿的理论出处。
- Pat Helland. Life beyond Distributed Transactions: an Apostate’s Opinion:为什么大规模系统应尽量避免分布式事务、转向幂等与最终一致的经典檄文。
- Chris Richardson. Pattern: Saga 与 Pattern: Transactional outbox:microservices.io 上 Saga、Outbox、编排/协同的工程化说明。
- Apache Seata. Seata 官方文档(AT / TCC / Saga 模式):主流分布式事务框架对四种模式的实现与取舍。
- Debezium. Reliable Microservices Data Exchange With the Outbox Pattern:Outbox + CDC 可靠事件投递的权威实践。
- Martin Kleppmann. Designing Data-Intensive Applications(第 9 章 一致性与共识):2PC、线性一致、最终一致的系统化梳理。