Skip to content
Charles Shao
Go back

分布式事务深挖 · 2PC、TCC、Saga 与 Outbox

views

单机上一条 BEGIN ... COMMIT 就能把「扣钱」和「记账」绑成原子操作,数据库替你扛下了所有一致性。可一旦「扣广告预算」在计费服务、「记流水」在账务服务、「改订单」在订单服务——三个进程、三个数据库、三次网络调用——那条优雅的本地事务就碎了。分布式事务的全部难题,都来自「没有一个数据库能同时锁住这三样东西」。

分布式协调系列(5 篇):1. 分布式共识:Paxos 与 Raft 图解 · 2. ZooKeeper 与 etcd 深挖 · 3. 分布式锁深挖 · 4. 分布式事务深挖(本篇) · 5. 分布式 ID 深挖

一句话定位:分布式事务不是「把本地事务放大到多机」,而是「放弃强一致的幻想,用补偿 + 幂等 + 可靠消息把最终一致做扎实」。

TL;DR

Table of contents

Open Table of contents

1. 从单机 ACID 到分布式:为什么事务会「碎」

先看清单机事务到底靠什么成立,才知道到了分布式为什么就不成立。

1.1 单机 ACID 的底座:共享日志 + 单点锁

MySQL 里一条转账事务能做到原子(要么都成、要么都败)、隔离(并发看不到中间态),靠的是两样东西:

关键点是:这两样都要求「所有被操作的数据在同一个存储引擎里」。 一旦数据分散到多个库、多个服务,就既没有一份能覆盖全局的日志,也没有一个能一次锁住所有资源的锁管理器——ACID 的地基被抽走了。

1.2 分布式下丢的是什么

特性单机跨库 / 跨服务
原子性 AWAL redo/undo 保证没有全局日志,一个库提交了另一个库可能失败 → 需要补偿回滚
一致性 C约束 + 事务保证只能做到最终一致,中间有不一致窗口
隔离性 I锁 / MVCC 保证跨服务几乎无隔离,中间态对外可见(Saga 尤其明显)
持久性 D各库各自的 WAL单库仍有,但「全局提交」这件事没有持久化载体

所以分布式事务的所有方案,本质都是在用应用层的机制(协调者、补偿、消息、幂等)去补回数据库原本免费给你的那部分保证——而且几乎总要在一致性上打折。

1.3 CAP 与 BASE:先想清楚要哪种一致

互联网业务(尤其广告、电商)绝大多数选 AP + BASE:宁可短暂不一致、事后对账/补偿收敛,也不能因为一个下游抖动就让整条主链路不可用。这决定了后面 Saga / Outbox 这类「最终一致」方案才是主流,而 2PC 这类「强一致但阻塞」的方案只适合极小范围。

2. 2PC 与 3PC:阻塞式强一致

2PC(Two-Phase Commit)是最经典的强一致方案,也是理解一切分布式事务的起点。它引入一个事务协调者(TC / Transaction Coordinator),把提交拆成两个阶段。

2.1 两阶段:prepare 投票 + commit 提交

2PC 两阶段提交时序图,含协调者宕机导致的阻塞。三个泳道:事务协调者(Coordinator)、参与者 A(库存服务 RM)、参与者 B(计费服务 RM)。阶段一 prepare:协调者分别向 A、B 发送 prepare,要求各自预留资源、写好 undo/redo 日志并锁住相关行;A、B 各自返回投票 Yes(表示已就绪、资源已锁、等待最终指令)。中间蓝色说明:只有当所有参与者都投 Yes 才进入提交阶段,任一参与者投 No 或超时则全局 abort 回滚。阶段二 commit:协调者向 A、B 发送 commit,A、B 提交本地事务并释放锁,各自返回 ack。底部红色警示:阻塞点在于——若协调者在阶段二发出 commit 之前宕机,已投 Yes 的参与者既不能提交也不能回滚,只能锁住资源无限等待,形成同步阻塞与协调者单点故障两大死穴。

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 两个死穴:同步阻塞与协调者单点

2.3 3PC:加 timeout 与 pre-commit,仍不完美

3PC(Three-Phase Commit)想缓解 2PC 的阻塞,把提交拆成 CanCommit → PreCommit → DoCommit 三阶段,核心改动两点:

代价是多一轮网络 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 三个动作

以「扣预算」为例:

// 扣预算的 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 侵入性强,而且有三个必须处理的经典问题,否则一定出数据错误:

空回滚与悬挂的根因都是乱序 + 重试,而识别它们的唯一抓手是全局唯一的事务 ID(txId)+ 每个参与方记录 txId 的状态流水。这也预示了后面 war story 的核心教训:没有全局事务 ID,一切补偿都无从对齐。

4. Saga:长事务拆成本地事务 + 补偿

2PC 和 TCC 都要求参与方在事务期间「持有资源」(锁或冻结)。但很多业务流程很长(下单 → 扣库存 → 扣预算 → 记账 → 发券……),持有资源到全流程结束根本不现实。Saga 的思路更彻底:把长事务拆成一串独立的本地事务,每个本地事务提交后立即释放资源;若后续失败,就执行前面每一步的「补偿事务」把它撤销。

4.1 正向链 + 反向补偿链

Saga 正向本地事务链与反向补偿链示意图。上排正向链(绿色向右箭头,标注「本地事务提交」):T1 扣广告预算 → T2 记计费流水 → T3 更新订单状态;其中 T3 标为失败(红色,标注「T3 失败,本地回滚」)。每个正向事务下方用虚线向下连到它对应的补偿事务,表示一一配对。下排反向补偿链(红色向左箭头,标注「按相反顺序补偿」):从 T3 失败点触发,先执行 C2 补偿——冲正计费流水,再执行 C1 补偿——回补广告预算,把前面已提交的本地事务逐一撤销。底部黄色说明:Saga 把长事务拆成 N 个各自提交的本地事务,因此没有隔离性、中间态对外可见;任一步失败就按相反顺序执行补偿;补偿必须幂等,并要正确处理空补偿(被补偿的正向事务其实没执行)与网络重试导致的重复补偿。

Saga:正向 T1→T2→T3 每步都是独立提交的本地事务;T3 失败后按相反顺序执行补偿 C2→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);   // ② 发消息通知下游

这两步不在一个事务里,任意一种崩溃都出错:

只要 DB 和 MQ 是两个系统,就没有跨系统事务能把①②绑成原子——这和缓存一致性里「更新 DB + 删缓存」是同一个双写难题。

5.2 Outbox:把「待发消息」写进同一个本地事务

解法是本地消息表 / Outbox 模式:不直接发 MQ,而是把「要发的消息」作为一行,和业务数据写进同一个本地数据库事务——两者要么一起成功、要么一起失败,原子性由本地事务保证。再由一个独立的投递器把 outbox 里的消息可靠地投到 MQ。

Outbox + CDC 可靠事件投递架构图。左侧一个方框圈起「① 同一个本地事务」,里面包含两张表:业务表(订单 / 预算扣减,蓝色)和 Outbox 消息表(同库写入,存放待投递事件,黄色),二者在同一个本地事务里原子提交,要么都成功要么都失败。方框向右用箭头(标注「读 binlog」)连到 CDC 订阅组件(Debezium / Canal,紫色);CDC 再用箭头(标注「投递」)连到 Kafka Topic(可靠事件、有序,黄色);Kafka 再用箭头(标注「消费」)连到下游消费者(按事件 ID 幂等,粉色)。底部黄色说明:业务数据与待发消息在同一本地事务原子落库,杜绝「库更新了但消息没发 / 消息发了但库没更新」的双写不一致;CDC 订阅 binlog 把 outbox 行变成可靠事件投到 MQ,实现不漏、可重放、有序,消费端按事件 ID 幂等消费。

Outbox:业务表与消息表在同一本地事务里原子写入,双写不一致被消灭在源头;再由 CDC 订阅 binlog 把 outbox 行变成可靠事件投到 MQ。这正是 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 为什么分布式里「重复执行」是常态

在分布式系统里,「一个操作只执行一次」是奢望,重复来自四面八方:

只要有其中任意一条(现实中四条都有),同一个动作被执行多次就是必然。没有幂等,「扣预算」重试一次就多扣一次,「补偿」重放一次就多补一次。

6.2 幂等的三种常见做法

幂等的设计细节、去重表选型、幂等与最终一致的关系,是 幂等与一致性 的主题。这里只强调一个判断标准:你设计的每一个正向操作和每一个补偿操作,问自己「重复执行两次,结果一样吗?」——只要有一个答案是「不一样」,它就是一颗定时炸弹。 下面的 war story 就是这颗炸弹爆了。

7. 选型矩阵:一致性 × 性能 × 复杂度,以及何时别用分布式事务

没有银弹,选型就是在一致性强度、性能、复杂度、业务侵入四个维度上权衡。

7.1 五种方案对比

方案一致性性能侵入/复杂度适用场景
2PC / XA / Seata AT强一致差(同步阻塞、锁资源)低侵入(AT 自动生成回滚 SQL)参与方少、事务短、要强一致(如同一 DB 多表跨库)
TCC强一致(准)好(业务层冻结、无 DB 长锁)高(每接口拆三段 + 处理空回滚/悬挂)资金、库存等强一致核心链路
Saga最终一致好(各步独立提交)中(要写补偿 + 幂等 + 编排)长流程、多服务、能容忍中间态
Outbox + 可靠消息最终一致很好(异步)中(Outbox 表 + CDC/MQ + 消费幂等)事件驱动、可异步的最终一致
本地事务 + 幂等重试最终一致最好最低能把跨服务写改成「幂等的异步补写」时

7.2 何时干脆别做分布式事务

分布式事务是昂贵且易错的东西,很多时候最好的方案是避免它

一句话选型:能用本地事务就别跨服务;必须跨服务,能异步最终一致就别同步强一致;必须强一致,参与方少用 TCC/2PC、流程长用 Saga;无论哪种,先把幂等和全局事务 ID 打好地基。

参考


views
Share this post on:

Previous Post
分布式 ID 深挖 · 雪花算法及其变体
Next Post
分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd