一个跨三个数据库分片的转账事务。协调者发出
PREPARE,两个参与者回复
VOTE_YES,第三个参与者的回复还在网络里飘。协调者宕机了。
两个已经投了 YES 的参与者不知道该提交还是回滚。它们持有的行锁不敢释放——万一协调者已经决定了 COMMIT 呢?但也不敢提交——万一第三个参与者投了 NO 呢?
这就是 2PC 的阻塞问题。锁被挂起,其他事务排队等待。如果协调者的恢复需要人工介入,这些锁可能被持有几分钟甚至几小时。在上一篇文章里,我们详细分析了这类真实失败模式。
Dale Skeen 在 1982 年提出了 3PC,试图在协调者宕机时让参与者自己做出正确决定。Hector Garcia-Molina 和 Kenneth Salem 在 1987 年走了另一条路:既然分布式事务的锁持有时间太长,那就别持锁——把大事务拆成小事务,失败了用补偿操作”撤回”,这就是 Saga。
这两个方案分别代表了分布式事务领域的两种思路:一种试图在协议层面解决一致性问题,另一种在应用层面绕过它。本文会证明前者为什么在工程上失败了,后者又付出了什么代价。
前置知识:建议先阅读 2PC 的真实失败模式,了解 2PC 的协议细节和阻塞场景。
一、3PC:Skeen 的论文到底在解决什么
1.1 精确的问题定义
Dale Skeen 在 1982 年的论文 “Nonblocking Commit Protocols” 中,给出了阻塞(blocking)的精确定义:如果一个提交协议中,存在某个全局状态 S,使得在 S 下某些进程故障后,剩余的正确进程无法仅凭本地状态和相互通信来决定提交或回滚,那么这个协议就是阻塞的。
2PC 为什么是阻塞的?考虑这个状态:协调者已经发出
COMMIT 决定,但只有参与者 P1
收到了。然后协调者和 P1 同时宕机。剩余参与者 P2、P3 处于
READY(已投
YES)状态,它们无法区分两种情况:
- 协调者决定了
COMMIT(因为所有人都投了 YES) - 协调者决定了
ABORT(因为某个参与者投了 NO)
P2 和 P3 不能提交(万一应该回滚),也不能回滚(万一应该提交)。唯一安全的选择是等待——阻塞。
Skeen 的核心洞察:2PC 的问题在于,从 READY
状态到 COMMITTED
状态只有一步。如果我们在中间加一个状态,让所有参与者在真正提交前先确认”大家都投了
YES”,那么当协调者宕机时,参与者就有足够的信息自行决定。
1.2 三个阶段的精确定义
3PC 在 2PC 的两个阶段之间插入了一个
PRE_COMMIT 阶段:
阶段一:CanCommit(投票阶段)
协调者 -> 所有参与者: VOTE_REQUEST
参与者 -> 协调者: VOTE_YES 或 VOTE_NO
如果协调者收到任意一个 VOTE_NO,或者超时未收到某个参与者的回复:
协调者决定 ABORT,进入阶段一的回滚路径
如果协调者收到所有参与者的 VOTE_YES:
进入阶段二
阶段二:PreCommit(预提交阶段)
协调者 -> 所有参与者: PRE_COMMIT
参与者收到 PRE_COMMIT:
参与者进入 PRE_COMMITTED 状态
参与者 -> 协调者: ACK
如果协调者超时未收到某个参与者的 ACK:
协调者决定 ABORT
如果协调者收到所有 ACK:
进入阶段三
阶段三:DoCommit(正式提交阶段)
协调者 -> 所有参与者: DO_COMMIT
参与者收到 DO_COMMIT:
参与者执行提交
参与者 -> 协调者: COMMITTED
1.3 协调者和参与者的状态机
协调者的状态转移:
INIT
|
+-- 发送 VOTE_REQUEST --> WAITING
| |
| +-- 收到全部 YES --> PRE_COMMITTING
| | |
| | +-- 收到全部 ACK --> COMMITTING
| | | |
| | | +-- 完成 --> COMMITTED
| | |
| | +-- 超时/失败 --> ABORTING
| |
| +-- 收到 NO / 超时 --> ABORTING
|
+-- ABORTING --> ABORTED
参与者的状态转移:
INIT
|
+-- 收到 VOTE_REQUEST
| |
| +-- 投 YES --> READY
| | |
| | +-- 收到 PRE_COMMIT --> PRE_COMMITTED
| | | |
| | | +-- 收到 DO_COMMIT --> COMMITTED
| | | |
| | | +-- 超时 --> (选举/提交)
| | |
| | +-- 收到 ABORT / 超时 --> ABORTED
| |
| +-- 投 NO --> ABORTED
|
+-- 超时未收到 VOTE_REQUEST --> ABORTED
以下状态图更直观地展示了参与者在 3PC 协议中的完整状态转移路径:
stateDiagram-v2
[*] --> Init
Init --> Aborted : 超时未收到 VOTE_REQUEST
Init --> Ready : 收到 VOTE_REQUEST / 投 YES
Init --> Aborted : 投 NO
Ready --> PreCommitted : 收到 PRE_COMMIT
Ready --> Aborted : 收到 ABORT
Ready --> Aborted : 超时(协调者无响应)
PreCommitted --> Committed : 收到 DO_COMMIT
PreCommitted --> Committed : 超时后选举,决定提交
PreCommitted --> Aborted : 超时后选举,决定回滚
Committed --> [*]
Aborted --> [*]
该状态图清晰地体现了 3PC 与 2PC 的关键区别:在 Ready 和 Committed 之间插入了 PreCommitted 这一缓冲状态。正是这个中间状态使得终止协议能够区分”协调者尚未做出决定”和”协调者已决定提交”两种情况。注意 PreCommitted 状态下的超时路径——参与者会发起选举,根据收集到的其他参与者状态共同决定提交或回滚,这正是 3PC 实现非阻塞的核心机制。
1.4 为什么 3PC 在崩溃故障下是非阻塞的
Skeen 的非阻塞证明依赖一个关键不变量(invariant):
不变量:在任何可达的全局状态中,不可能同时存在一个参与者处于
PRE_COMMITTED 状态而另一个参与者处于
INIT 状态。
这个不变量为什么成立?因为协调者只有在收到所有参与者的
VOTE_YES 之后才会发送
PRE_COMMIT。所以如果某个参与者处于
PRE_COMMITTED
状态,意味着所有参与者都已经离开了 INIT
状态(至少进入了 READY)。
有了这个不变量,当协调者宕机时,存活的参与者可以通过终止协议(Termination Protocol)自行决定:
终止协议(由存活参与者执行):
1. 互相通信,收集所有存活参与者的状态
2. 如果有任何参与者处于 COMMITTED 状态 -> 全部提交
3. 如果有任何参与者处于 ABORTED 状态 -> 全部回滚
4. 如果有任何参与者处于 INIT 状态 -> 全部回滚
(因为不变量保证此时没有人处于 PRE_COMMITTED,所以回滚安全)
5. 如果所有参与者都处于 READY 状态 -> 全部回滚
(没人收到 PRE_COMMIT,说明协调者还没决定提交)
6. 如果有参与者处于 PRE_COMMITTED 状态,且没有人处于 INIT 状态:
-> 全部提交
(所有人都投了 YES,协调者已经开始了预提交流程)
关键在规则 4 和规则 6 的对比。在 2PC 中,规则 4 和规则 6
会冲突——你无法区分”协调者还没决定”和”协调者已经决定了 COMMIT
但消息没到”。3PC 通过 PRE_COMMITTED
中间状态解决了这个歧义。
1.5 形式论证的核心
用反证法。假设 3PC 的终止协议导致了不一致:某些参与者决定提交,某些决定回滚。
- 决定提交的参与者,一定是因为发现了某个处于
PRE_COMMITTED状态的参与者(规则 6),且没有人处于INIT状态。 - 决定回滚的参与者,一定是因为发现了某个处于
INIT状态的参与者(规则 4)。
但这违反了不变量——PRE_COMMITTED 和
INIT
不能共存。所以假设不成立,终止协议不会产生不一致。
这个证明在崩溃-恢复(crash-recovery)故障模型下成立。节点要么正确运行,要么崩溃后停止。关键前提是:网络是可靠的——消息最终会被送达,不会无限丢失。
二、3PC 引入的新问题
2.1 网络分区下的致命缺陷
3PC 的非阻塞证明有一个前提:存活的参与者能够互相通信。当网络分区(Network Partition)发生时,这个前提被打破。
考虑以下精确场景。五个节点:协调者 C,参与者 P1-P4。
时间线:
T0: C 发送 VOTE_REQUEST 给 P1-P4
T1: P1-P4 都回复 VOTE_YES
T2: C 发送 PRE_COMMIT 给 P1, P2(成功送达)
T3: C 发送 PRE_COMMIT 给 P3, P4(消息在传输中)
T4: 网络分区!
分区 A: {P1, P2} -- 状态: PRE_COMMITTED
分区 B: {P3, P4} -- 状态: READY(PRE_COMMIT 未到达)
C 在分区边界上,两边都联系不上(或者 C 自己宕机了)
T5: 两个分区各自执行终止协议
分区 A 中的 P1、P2 执行终止协议:
P1 状态: PRE_COMMITTED
P2 状态: PRE_COMMITTED
没有人处于 INIT 状态
-> 根据规则 6:决定 COMMIT
分区 B 中的 P3、P4 执行终止协议:
P3 状态: READY
P4 状态: READY
没有人处于 PRE_COMMITTED 状态
-> 根据规则 5:决定 ABORT
结果:P1、P2 提交了,P3、P4 回滚了。数据不一致。
这不是极端边界情况。在真实的数据中心网络中,交换机故障、光纤中断、BGP 路由震荡都会造成部分节点互通而另一部分不通的分区状况。
2.2 超时恢复与分区的根本矛盾
3PC 的终止协议依赖超时(timeout)来判断节点是否宕机:如果某个参与者在超时时间内没有响应,就认为它已经崩溃。但网络分区和节点崩溃的外部表现完全相同——都是”发消息收不到回复”。
这就是根本矛盾:
- 超时恢复假设未响应的节点已经崩溃(不会再做任何决定)
- 网络分区的实际情况是未响应的节点还活着(可能在做相反的决定)
这不是 3PC 特有的问题,而是分布式系统的基本限制。FLP 不可能性定理(Fischer, Lynch, Paterson, 1985)告诉我们:在异步系统中,即使只有一个进程可能故障,也不存在确定性的共识算法。3PC 在同步模型(有已知的消息延迟上界)下是正确的,但真实网络是异步的。
更直接地说:你不可能同时拥有非阻塞(non-blocking)和分区安全(partition-safe)。3PC 选择了非阻塞,牺牲了分区安全。2PC 选择了分区安全(阻塞等待而不做错误决定),牺牲了非阻塞。
2.3 性能代价
即使忽略正确性问题,3PC 的性能也比 2PC 差:
| 指标 | 2PC | 3PC |
|---|---|---|
| 消息轮次(正常路径) | 2 轮 | 3 轮 |
| 协调者日志强制写入 | 2 次(PREPARE、COMMIT) | 3 次(PREPARE、PRE_COMMIT、COMMIT) |
| 参与者日志强制写入 | 2 次 | 3 次 |
| 总消息数(N 个参与者) | 4N | 6N |
每增加一轮消息,正常路径的延迟就增加一次网络往返时间(RTT)。每增加一次强制日志写入(fsync),就增加一次磁盘
I/O 延迟。在跨数据中心场景中,RTT 可能是
50-150ms,一次额外的轮次直接把事务延迟从 200ms 推到
350ms。
2.4 为什么几乎没人在生产中使用 3PC
原因不是单一的,而是多重打击:
- 正确性不足:网络分区场景下不一致,而生产环境中分区确实会发生。
- 性能更差:三轮消息 + 三次强制日志写入,比 2PC 慢 50% 左右。
- 复杂度更高:实现终止协议需要选举新协调者,增加了大量代码和测试负担。
- 有更好的替代方案:Paxos Commit(Lamport, 2004)在异步模型下解决了非阻塞提交问题,而且和 Paxos 共识协议结合紧密。现代系统(如 Spanner、CockroachDB)使用 Paxos/Raft 复制的 2PC,在协调者宕机时通过共识协议选出新协调者读取日志,既不阻塞也不牺牲分区安全。
2.5 3PC 的历史价值
3PC 在工程实践上是失败的,但在理论上极其重要。Skeen 的论文第一次精确地刻画了”阻塞”这个概念,并且证明了非阻塞提交协议存在的充分必要条件:每个参与者的状态转移图中,COMMIT 状态和 ABORT 状态之间必须有一个”缓冲”状态。
这个洞察直接影响了后来的 Paxos Commit 和 Raft-based 2PC 的设计——它们本质上是用共识协议来替代 3PC 的第二阶段,在不增加同步轮次的情况下实现非阻塞。
三、Saga:Garcia-Molina 和 Salem 的 1987 设计
3.1 原始论文的背景
Garcia-Molina 和 Salem 在 1987 年的论文 “Sagas” 中,关注的不是分布式系统,而是单机数据库中的长事务(Long-Lived Transaction,LLT)问题。
场景举例:一个银行的月末批处理任务,需要扫描所有账户并计算利息。这个事务持续 30 分钟。在这 30 分钟内,它持有的锁会阻塞所有涉及这些账户的在线交易。
传统方案是让这个事务独占运行(比如在维护窗口期),但这意味着系统有 30 分钟不可用。Garcia-Molina 的核心观察是:
长时间持有锁是不可接受的。如果我们愿意放弃事务的隔离性(Isolation),就可以把一个长事务拆成多个短事务,每个短事务立即提交并释放锁。
3.2 Saga 的形式定义
一个 Saga 是一个事务序列 T1, T2, …, Tn,其中每个 Ti 有一个对应的补偿事务(Compensating Transaction)Ci。
形式化定义:
Saga S = (T1, T2, ..., Tn)
对应补偿序列 = (C1, C2, ..., Cn-1)
注意:Tn 没有补偿事务(最后一步要么成功要么不执行)
执行保证:
成功路径:T1, T2, ..., Tn(全部成功,Saga 完成)
失败路径:T1, T2, ..., Ti, Ci, Ci-1, ..., C1(Ti 失败,反向补偿)
关键约束:每个 Ti 和 Ci 都是独立的数据库事务。Ti 提交后,它的效果立即对外可见。Ci 不是”撤销”Ti 的效果,而是在语义上”抵消”Ti 的效果。
3.3 两种恢复策略
反向恢复(Backward Recovery):当 Ti 失败时,依次执行 Ci-1, Ci-2, …, C1,将系统恢复到 Saga 开始之前的语义状态。这是最常见的策略。
执行序列:T1 -> T2 -> T3(FAIL) -> C2 -> C1
含义:T1、T2 的效果被 C1、C2 补偿掉
正向恢复(Forward Recovery):当 Ti 失败时,重试 Ti(可能带指数退避),直到成功,然后继续执行 Ti+1, …, Tn。这要求 Ti 的失败是暂时性的(如网络超时),而不是业务逻辑错误(如余额不足)。
执行序列:T1 -> T2 -> T3(FAIL) -> T3(RETRY) -> T3(OK) -> T4 -> ... -> Tn
含义:整个 Saga 最终完成
实际系统经常混合使用:某些步骤配置为可重试(正向恢复),到达某个关键步骤后切换为不可回退(只能正向),在此之前的步骤使用反向恢复。
下面的时序图展示了 Saga 反向恢复的完整执行过程:
sequenceDiagram
participant SEC as Saga 协调器
participant A as 服务 A(订单)
participant B as 服务 B(支付)
participant C as 服务 C(库存)
SEC->>A: T1 创建订单
A-->>SEC: T1 成功
SEC->>B: T2 扣减余额
B-->>SEC: T2 成功
SEC->>C: T3 预留库存
C-->>SEC: T3 失败(库存不足)
Note over SEC: 检测到 T3 失败,启动反向补偿
SEC->>B: C2 退款
B-->>SEC: C2 补偿完成
SEC->>A: C1 取消订单
A-->>SEC: C1 补偿完成
Note over SEC: Saga 补偿完成,最终状态:已回滚
该时序图展示了 Saga 反向恢复的核心机制:正向操作(T1、T2、T3)依次执行,当 T3 在服务 C 处失败后,协调器立即停止正向推进,转而按反向顺序(C2、C1)逐一调用已完成步骤的补偿操作。注意补偿的起点是最后一个成功完成的步骤(T2),而非失败的步骤(T3)——失败的步骤本身未产生需要补偿的副作用。
3.4 ACD 属性:没有隔离性
ACID 中的 I(Isolation)在 Saga 中是缺失的。Saga 提供的是 ACD 属性:
- A(Atomicity):通过补偿事务实现。要么 T1…Tn 全部成功,要么失败的步骤被补偿回滚。注意这是语义上的原子性,不是物理上的——中间状态对外可见。
- C(Consistency):每个子事务 Ti 独立地维护数据库一致性约束。
- D(Durability):每个 Ti 提交后,其效果是持久的。
隔离性的缺失带来三类具体问题:
脏读(Dirty Read):Saga S1 的 T1 扣了账户 A 100 元并提交。另一个事务读到了扣款后的余额。然后 S1 的 T2 失败,C1 把 100 元补回来。那个事务读到的是一个”从未真正存在过”的余额。
时间线:
S1.T1: A.balance = 1000 - 100 = 900 (提交)
TX2: 读 A.balance = 900 (脏读!)
S1.T2: 失败
S1.C1: A.balance = 900 + 100 = 1000 (补偿)
TX2 基于 900 做的决策是错误的
丢失更新(Lost Update):两个 Saga 并发修改同一条记录。S1.T1 把余额从 1000 改为 900(扣 100)并提交。S2.T1 读到 900,改为 800(扣 100)并提交。S1.T2 失败,S1.C1 把余额从 800 改回 900(加 100)。结果:应该是 900(只有 S2 的扣款生效),实际是 900。看起来对了?不对。如果 S2 也失败了,S2.C1 会把余额从 900 改为 1000(加 100)。但实际余额应该还是 1000——两个 Saga 都失败了。这里 S2.C1 的补偿是基于”当时读到的值”计算的,如果 S1.C1 先执行了,S2.C1 的计算基础就错了。
模糊读(Fuzzy Read):Saga 的 T1 读取了一行数据做决策,T3 在执行时该行数据已经被其他事务修改了。T1 和 T3 看到的是同一行数据的不同版本。
3.5 应对隔离性缺失的实践方案
论文和后续工程实践提出了几种缓解方案:
- 语义锁(Semantic
Lock):用业务字段标记”正在处理中”。例如在订单表中增加
status = PROCESSING字段,其他 Saga 看到这个状态就知道数据可能会被改回来。 - 交换律(Commutative)补偿:设计补偿操作为可交换的。“扣 100”的补偿不是”设回原值”,而是”加 100”。无论执行顺序如何,最终结果一致。
- 值重读(Reread Value):在补偿前重新读取当前值,避免基于过期数据做补偿。
- 业务层面的版本控制:使用乐观锁(版本号),补偿时检查版本号是否匹配。
这些方案都不完美,每一种都增加了应用复杂度。这就是 Saga 的核心权衡:用隔离性换取短锁持有时间和高可用性。
四、编排(Orchestration)vs 协同(Choreography)
Garcia-Molina 的原始论文没有讨论 Saga 在微服务架构中的实现方式。当 Saga 从单机数据库走向跨服务的分布式事务时,出现了两种实现模式:编排(Orchestration)和协同(Choreography)。
4.1 编排模式:中央 Saga 执行协调器
编排模式引入一个中央组件——Saga 执行协调器(Saga Execution Coordinator,SEC)。SEC 负责驱动 Saga 的整个生命周期:按顺序调用各服务的子事务,监控结果,失败时按反向顺序调用补偿事务。
以一个电商下单流程为例,SEC 的伪代码实现:
class OrderSagaOrchestrator:
def __init__(self, saga_id, order_data):
self.saga_id = saga_id
self.order_data = order_data
self.saga_log = SagaLog(saga_id)
def execute(self):
steps = [
SagaStep(
name="create_order",
action=lambda: order_service.create(self.order_data),
compensation=lambda: order_service.cancel(self.saga_id)
),
SagaStep(
name="deduct_payment",
action=lambda: payment_service.deduct(
self.order_data.user_id,
self.order_data.amount
),
compensation=lambda: payment_service.refund(
self.order_data.user_id,
self.order_data.amount
)
),
SagaStep(
name="reserve_inventory",
action=lambda: inventory_service.reserve(
self.order_data.items
),
compensation=lambda: inventory_service.release(
self.order_data.items
)
),
SagaStep(
name="arrange_shipping",
action=lambda: shipping_service.arrange(
self.saga_id,
self.order_data.address
),
compensation=None # 最后一步,无需补偿
),
]
completed_steps = []
for step in steps:
self.saga_log.record(step.name, "STARTED")
try:
step.action()
self.saga_log.record(step.name, "COMPLETED")
completed_steps.append(step)
except Exception as e:
self.saga_log.record(step.name, "FAILED", str(e))
self._compensate(completed_steps)
return SagaResult.COMPENSATED
self.saga_log.record("saga", "COMMITTED")
return SagaResult.COMMITTED
def _compensate(self, completed_steps):
# 反向执行补偿事务
for step in reversed(completed_steps):
if step.compensation is None:
continue
self.saga_log.record(step.name, "COMPENSATING")
try:
step.compensation()
self.saga_log.record(step.name, "COMPENSATED")
except Exception as e:
# 补偿失败:记录日志,人工介入
self.saga_log.record(
step.name, "COMPENSATION_FAILED", str(e)
)
alert_ops_team(self.saga_id, step.name, e)编排模式的优势:
- 流程清晰:整个 Saga 的执行逻辑在一个地方,可以画出明确的流程图。
- 可观测性好:SEC 持有完整的 Saga 状态,查询某个订单的 Saga 执行到哪一步、失败在哪里,直接查 SEC 的日志。
- 容易添加步骤:新增一个子事务只需在 SEC
中注册一个
SagaStep。 - 超时和重试集中管理:所有步骤的超时策略、重试策略由 SEC 统一配置。
编排模式的劣势:
- SEC 是单点:如果 SEC 自身宕机,所有正在进行的 Saga 都会暂停。这需要 SEC 自身做高可用(如基于数据库的 Saga 日志 + 恢复机制)。
- 服务耦合到 SEC:每个服务都需要暴露”正向操作”和”补偿操作”两个接口给 SEC 调用。SEC 需要知道每个服务的 API。
- SEC 可能成为瓶颈:高并发下所有 Saga 都经过 SEC,其吞吐量可能成为系统瓶颈。
4.2 协同模式:事件驱动的分散协调
协同模式没有中央协调器。每个服务监听事件,执行自己的子事务,然后发布新事件。Saga 的流程隐式地编码在事件的发布/订阅关系中。
# OrderService:监听下单请求
class OrderService:
@event_handler("OrderRequested")
def handle_order_requested(self, event):
order = self.create_order(event.order_data)
publish("OrderCreated", {
"saga_id": event.saga_id,
"order_id": order.id,
"user_id": event.order_data.user_id,
"amount": event.order_data.amount,
"items": event.order_data.items
})
@event_handler("PaymentRefunded")
def handle_payment_refunded(self, event):
# 收到退款完成事件,取消订单(补偿)
self.cancel_order(event.saga_id)
publish("OrderCancelled", {"saga_id": event.saga_id})
# PaymentService:监听订单创建事件
class PaymentService:
@event_handler("OrderCreated")
def handle_order_created(self, event):
try:
self.deduct(event.user_id, event.amount)
publish("PaymentDeducted", {
"saga_id": event.saga_id,
"order_id": event.order_id,
"items": event.items
})
except InsufficientBalanceError:
publish("PaymentFailed", {
"saga_id": event.saga_id,
"reason": "insufficient_balance"
})
@event_handler("InventoryReserveFailed")
def handle_inventory_failed(self, event):
# 库存预留失败,退款(补偿)
self.refund(event.user_id, event.amount)
publish("PaymentRefunded", {
"saga_id": event.saga_id,
"user_id": event.user_id,
"amount": event.amount
})
# InventoryService:监听扣款成功事件
class InventoryService:
@event_handler("PaymentDeducted")
def handle_payment_deducted(self, event):
try:
self.reserve(event.items)
publish("InventoryReserved", {
"saga_id": event.saga_id,
"order_id": event.order_id
})
except OutOfStockError:
publish("InventoryReserveFailed", {
"saga_id": event.saga_id,
"user_id": event.user_id,
"amount": event.amount,
"items": event.items
})事件驱动的正向流程:
OrderRequested
-> OrderService 创建订单 -> 发布 OrderCreated
-> PaymentService 扣款 -> 发布 PaymentDeducted
-> InventoryService 预留库存 -> 发布 InventoryReserved
-> ShippingService 安排发货 -> 发布 ShippingArranged
事件驱动的补偿流程(假设库存预留失败):
InventoryReserveFailed
-> PaymentService 退款 -> 发布 PaymentRefunded
-> OrderService 取消订单 -> 发布 OrderCancelled
协同模式的优势:
- 松耦合:服务之间通过事件通信,不需要知道彼此的 API。
- 无单点故障:没有中央协调器,每个服务独立运行。
- 易于扩展:新增一个服务只需订阅相关事件、发布自己的事件。
协同模式的劣势:
- 流程难以追踪:Saga 的整体流程分散在多个服务中,没有一个地方能看到全貌。调试一个失败的 Saga 需要跨多个服务拼接事件日志。
- 循环依赖风险:事件的发布/订阅关系可能形成环路。Service A 的事件触发 Service B,Service B 的事件又触发 Service A。这类问题在编译时发现不了,只在运行时暴露。
- 补偿顺序隐式化:补偿事件必须携带足够的上下文信息(如
user_id、amount),让下游服务能正确执行补偿。如果事件 payload 设计不当,补偿链会断裂。 - 全局状态不可见:没有一个地方记录”这个 Saga 执行到哪一步了”。实现全局可观测性需要额外的 Saga 状态追踪服务。
4.3 选择标准
在实际系统中,选择编排还是协同取决于几个因素:
| 因素 | 倾向编排 | 倾向协同 |
|---|---|---|
| Saga 步骤数 | > 4 步 | 2-3 步 |
| 参与服务数 | > 3 个 | 2-3 个 |
| 补偿逻辑复杂度 | 条件分支多 | 线性、简单 |
| 可观测性要求 | 高(需要追踪每一步) | 低 |
| 团队组织 | 一个团队负责流程 | 各团队独立 |
| 流程变更频率 | 频繁修改流程 | 流程稳定 |
实际工程中的选择:
- AWS Step Functions:典型的编排模式工具。Step Functions 用状态机定义 Saga 流程,每个状态对应一个 Lambda 函数调用。失败时按预定义路径执行补偿。
- Kafka + 事件驱动:典型的协同模式基础设施。各服务消费 Kafka topic 中的事件,处理后发布到新的 topic。
- 混合模式:有些系统在顶层使用编排(一个 SEC 协调几个子 Saga),每个子 Saga 内部使用协同。这种方式在大型系统中比较常见。
4.4 Saga 在网络分区下的行为分析
3PC 在网络分区下会产生不一致,那么 Saga 呢?Saga 虽然不依赖全局锁和投票协议,但网络分区同样会对编排模式和协同模式产生严重影响。
编排模式下的分区影响
当 Saga 协调器(SEC)与某个参与服务之间发生网络分区时,SEC 无法区分”服务宕机”和”网络不通”:
场景:SEC 已成功执行 T1(订单)、T2(支付),正在调用 T3(库存)
SEC ----X---- 库存服务
| |
可达:订单、支付 库存服务正常运行
SEC 视角:T3 调用超时,无法确定 T3 是否执行成功
库存服务视角:可能已收到请求并扣减了库存,但回复无法送达 SEC
此时 SEC 面临两难选择。如果 SEC 判定 T3 失败并触发补偿(C2、C1),但实际上库存服务已经成功扣减了库存,那么系统会出现”支付已退款、订单已取消,但库存仍被占用”的不一致状态。如果 SEC 选择等待,那么 Saga 会陷入长时间阻塞,违背了 Saga”避免长时间持锁”的设计初衷。
协同模式下的分区影响
协同模式依赖事件的可靠传递,网络分区会导致事件丢失或严重延迟:
场景:支付服务发布了 PaymentDeducted 事件
支付服务 --[PaymentDeducted]--> 消息队列 ----X---- 库存服务
库存服务长时间收不到事件:
1. Saga 停滞——没有下一步推进
2. 分区恢复后事件延迟到达——此时支付可能已超时被补偿
3. 事件乱序——补偿事件先于正向事件到达
更危险的是脑裂场景(Split-Brain):假设订单服务和支付服务在分区的一侧,库存服务和物流服务在另一侧。订单服务发起 Saga 并成功扣款,但库存服务收不到事件。分区的另一侧可能因超时触发了独立的补偿逻辑,导致两侧做出互相矛盾的决策。
缓解策略
网络分区无法完全避免,但可以通过以下策略将其影响降到可控范围:
1. 幂等性设计
每个子事务和补偿事务都必须是幂等的。
分区恢复后,SEC 可以安全地重试任何操作而不会产生重复效果。
使用全局唯一的 saga_id + step_id 作为幂等键。
2. 超时分级
对不同步骤设置不同的超时时间。
关键步骤(如支付)使用较长超时 + 主动查询确认。
非关键步骤使用较短超时 + 快速失败。
3. 死信队列(Dead Letter Queue)
在协同模式中,事件多次投递失败后进入死信队列。
运维团队定期检查死信队列,对滞留事件进行人工干预或自动重放。
4. 状态查询接口
每个参与服务暴露状态查询接口(如 GET /orders/{saga_id}/status)。
SEC 在超时后先查询目标服务的实际状态,再决定重试还是补偿。
这将"猜测"变为"确认",大幅降低误判概率。
5. 分区感知的超时策略
结合网络健康检查(如心跳探测)判断是网络分区还是服务宕机。
如果检测到分区,延长超时时间并暂停补偿决策,等待分区恢复。
核心原则是:在网络分区期间,宁可让 Saga 暂时停滞(牺牲可用性),也不要贸然做出可能导致不一致的补偿决策。这与 3PC 的教训一致——在不确定对方状态时做决定,几乎一定会出错。
五、补偿事务的设计陷阱
补偿事务(Compensating Transaction)看起来简单——“做的事情反过来做一遍”——但实际设计中充满了陷阱。
5.1 不可补偿操作
有些操作本质上不可补偿:
- 发送邮件/短信:邮件已经到了收件箱,你不能”撤回”。
- 调用第三方支付:信用卡扣款已经完成,“退款”不是”撤销扣款”——退款是一个新的交易,可能涉及手续费,到账时间不确定。
- 触发物理动作:仓库机器人已经开始拣货,你不能让时间倒流。
- 发布消息/通知:下游系统已经消费了消息并做出了反应。
处理不可补偿操作的策略:
- 推迟执行:把不可补偿的操作放在 Saga 的最后。在所有可补偿的步骤成功之后再执行不可撤销的操作。
- 预留 + 确认模式:不直接发邮件,而是先创建一个”待发送”记录。所有步骤成功后,批量处理待发送队列。
- 业务上的”尽力而为”补偿:信用卡扣款的”补偿”是发起退款申请。虽然不是完美的撤销,但在业务上是可接受的。
5.2 语义反转不等于真正撤销
“账户 A 扣了 100 元”的补偿是”账户 A 加 100 元”。但这两者在语义上不同:
- 扣 100 元后加 100 元:会产生两条交易记录、两笔银行流水、可能两次利息计算。
- 从未扣过 100 元:只有一条初始余额记录。
在金融系统中,这个区别非常重要。审计日志会显示”扣款 -> 退款”的完整历史,而不是”什么都没发生”。监管合规要求保留完整的交易轨迹。
这意味着补偿事务的设计必须考虑业务语义,而不仅仅是数据状态。“补偿”在 Saga 的语境中是”达到等效的业务结果”,而不是”恢复到之前的精确数据状态”。
5.3 补偿引发下游连锁反应
补偿事务的另一个隐蔽陷阱是:原始操作在执行后往往会触发一系列下游副作用——佣金计算、积分发放、税务记录、数据分析等。补偿操作不仅要”撤回”原始操作本身,还必须处理所有已触发的下游效应,否则系统会出现数据不一致。
场景一:退款引发的连锁清算
用户支付了一笔 500 元的订单。支付成功后,系统自动触发了以下下游逻辑:计算并发放了 5 元佣金给推荐人,增加了 50 积分到用户的忠诚度账户,生成了含税金额的税务记录,更新了商户的 T+1 结算批次。当需要补偿(退款)时,仅仅把 500 元退回用户账户是远远不够的:
class PaymentCompensationWithCascade:
def compensate_payment(self, saga_id, order_id, user_id, amount):
"""退款补偿:必须同时处理所有下游副作用"""
# 1. 退款本身
self.refund_service.refund(order_id, amount)
# 2. 回收佣金——但推荐人可能已经提现了
commission = self.commission_service.get_commission(order_id)
if commission and commission.status == "SETTLED":
# 佣金已结算,只能记负债,下次结算时扣除
self.commission_service.create_debit(
referrer_id=commission.referrer_id,
amount=commission.amount,
reason=f"订单 {order_id} 退款,回收佣金"
)
elif commission:
self.commission_service.revoke(commission.id)
# 3. 扣减积分——但用户可能已经用积分兑换了商品
points = self.loyalty_service.get_points_for_order(order_id)
if points:
current_balance = self.loyalty_service.get_balance(user_id)
if current_balance >= points.amount:
self.loyalty_service.deduct(user_id, points.amount)
else:
# 积分已被消费,记录欠款或降级处理
self.loyalty_service.create_points_debt(
user_id, points.amount - current_balance
)
# 4. 作废税务记录
self.tax_service.void_record(order_id)
# 5. 从结算批次中移除——如果批次已经提交给银行?
settlement = self.settlement_service.get_batch(order_id)
if settlement.status == "PENDING":
self.settlement_service.remove_from_batch(order_id)
elif settlement.status == "SUBMITTED":
# 批次已提交,只能在下一批次中做冲正
self.settlement_service.create_reversal(order_id, amount)场景二:取消订单触发的仓储与通知回滚
订单确认后,系统触发了仓库拣货、合作方通知和数据分析事件。取消订单的补偿远比删除一条数据库记录复杂——每个下游系统在补偿发生时可能处于完全不同的生命周期阶段:
class OrderCancellationCascade:
def compensate_order(self, saga_id, order_id):
"""取消订单的级联补偿"""
# 1. 仓库拣货状态检查
picking = self.warehouse_service.get_picking_status(order_id)
if picking.status == "NOT_STARTED":
self.warehouse_service.cancel_picking(order_id)
elif picking.status == "IN_PROGRESS":
# 拣货已开始,需要发送中断指令并等待确认
self.warehouse_service.abort_picking(order_id)
# 已拣出的商品需要重新上架
self.warehouse_service.schedule_restocking(
order_id, picking.picked_items
)
elif picking.status == "COMPLETED":
# 拣货完成,商品已打包,需要完整的退库流程
self.warehouse_service.initiate_return_to_shelf(order_id)
# 2. 撤回合作方通知
notifications = self.partner_service.get_notifications(order_id)
for notif in notifications:
if notif.type == "SUPPLIER_ORDER":
# 供应商可能已经开始备货
self.partner_service.send_cancellation(
notif.partner_id, order_id
)
elif notif.type == "LOGISTICS_BOOKING":
# 物流可能已经分配了运力
self.logistics_service.cancel_booking(notif.booking_id)
# 3. 数据分析事件修正——已发出的事件无法撤回
self.analytics_service.publish_correction(
event_type="ORDER_CANCELLED",
order_id=order_id,
original_event="ORDER_CONFIRMED"
)这两个场景揭示了一个关键问题:补偿的复杂度不取决于原始操作本身,而取决于原始操作触发的所有下游副作用的当前状态。每个下游系统在补偿发生时可能处于不同的生命周期阶段(未开始、进行中、已完成、已结算),补偿逻辑需要针对每种状态分别处理。这也是为什么在设计 Saga 时,应当尽量减少单个步骤触发的异步副作用数量,或者将副作用推迟到 Saga 完全提交之后再触发——把不可控的连锁反应挡在 Saga 边界之外。
5.4 补偿事务的幂等性
补偿事务必须是幂等的(Idempotent)。为什么?因为补偿可能被重复执行:
- SEC 发出补偿请求后宕机,恢复后不确定补偿是否执行成功,于是重新发送。
- 网络超时导致 SEC 重试补偿请求,但前一个请求实际上已经成功了。
实现幂等性的常见模式:
def compensate_payment(saga_id, user_id, amount):
# 使用 saga_id 作为幂等键
existing = db.query(
"SELECT * FROM compensation_log WHERE saga_id = %s AND step = 'payment'",
saga_id
)
if existing:
# 补偿已经执行过,直接返回
return CompensationResult.ALREADY_DONE
# 执行补偿
db.execute(
"UPDATE accounts SET balance = balance + %s WHERE user_id = %s",
amount, user_id
)
# 记录补偿日志(与更新在同一事务中)
db.execute(
"INSERT INTO compensation_log (saga_id, step, status) VALUES (%s, 'payment', 'done')",
saga_id
)
db.commit()
return CompensationResult.SUCCESS5.5 补偿与正向操作的交错
考虑这个场景:Saga 步骤 T2(扣款)正在执行中,同时 T3 已经发现了问题要触发补偿。如果 C1(取消订单)在 T2 完成之前就执行了,会发生什么?
时间线:
T=1: T2(扣款)开始执行
T=2: T3 检测到异常,触发补偿
T=3: C1(取消订单)执行成功
T=4: T2(扣款)执行成功
问题:T2 的扣款没有被补偿!
这就是为什么 SEC 必须等待当前步骤完成(成功或失败)后再开始补偿。补偿的起点是最后一个成功完成的步骤,而不是最后一个开始的步骤。
5.6 枢纽事务(Pivot Transaction)
在 Saga 的步骤序列中,存在一个关键的分界点——枢纽事务(Pivot Transaction)。枢纽事务之前的步骤都是可补偿的,枢纽事务之后的步骤都是可重试的(最终一定会成功),枢纽事务本身是成败的分界线。
T1 (可补偿) -> T2 (可补偿) -> T3 (枢纽) -> T4 (可重试) -> T5 (可重试)
^
成败分界线
失败 -> 反向补偿 C2, C1
成功 -> T4, T5 必须完成(通过重试)
在电商场景中:
- T1 创建订单:可补偿(取消订单)
- T2 扣减余额:可补偿(退款)
- T3 确认支付:枢纽事务——一旦支付确认发送给银行,就不能轻易撤销
- T4 扣减库存:可重试(如果暂时失败,重试即可)
- T5 通知物流:可重试
枢纽事务的设计原则:把所有可能失败(业务逻辑失败,如余额不足、库存不足)的步骤放在枢纽之前,把只会因基础设施问题(网络超时、服务暂时不可用)而失败的步骤放在枢纽之后。
5.7 预留-确认模式
为了避免补偿的复杂性,一种常见的设计模式是预留-确认(Reservation-Confirmation):
步骤 1: 预留资源(冻结库存、冻结余额)
步骤 2: 执行业务检查
步骤 3: 确认预留(扣减库存、扣减余额)
预留和确认之间,资源处于”冻结”状态——既不属于当前事务,也不会被其他事务使用。如果 Saga 失败,只需释放预留,而不需要执行复杂的补偿操作。
这实际上就是下一节要讲的 TCC 模式的核心思想。
六、Temporal 与 Cadence:Saga 的现代基础设施
6.1 手写 Saga 的痛苦
前面的代码示例展示了手写 Saga 编排器需要处理的问题:
- Saga 状态持久化(SEC 宕机后能从断点恢复)
- 步骤超时和重试逻辑
- 补偿事务的调度和执行
- 幂等性保证
- 死信队列和人工干预
- 并发 Saga 的相互影响
每个问题都需要大量的代码,而且容易出错。这些问题在每个使用 Saga 的系统中都会重复出现。
6.2 Temporal 的模型
Temporal(从 Uber 的 Cadence 分叉而来)是一个工作流引擎(Workflow Engine),它的核心模型恰好适合实现 Saga:
- Workflow(工作流) = Saga 本身
- Activity(活动) = Saga 的每个子事务 Ti 或补偿事务 Ci
- Workflow State(工作流状态) = Saga 的执行进度
Temporal 的关键能力是持久执行(Durable Execution):Workflow 函数的执行状态被持久化到数据库中。如果 Temporal Worker 宕机,新的 Worker 可以从事件历史(Event History)中重放(Replay)Workflow 函数,恢复到宕机前的执行点,继续执行。
这意味着开发者不需要手写 Saga 状态持久化、断点恢复、重试逻辑——Temporal 框架自动处理。
6.3 用 Go 实现 Saga:Temporal Workflow
以下是用 Temporal Go SDK 实现电商下单 Saga 的完整示例:
package workflows
import (
"fmt"
"time"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// OrderSagaInput 定义 Saga 的输入参数
type OrderSagaInput struct {
OrderID string
UserID string
Items []OrderItem
Amount float64
Address string
}
// OrderSaga 是 Saga 的 Workflow 函数
// Temporal 保证:即使 Worker 宕机,这个函数也会从断点恢复执行
func OrderSaga(ctx workflow.Context, input OrderSagaInput) error {
// Activity 选项:超时、重试策略
activityOpts := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 30 * time.Second,
MaximumAttempts: 3,
},
}
ctx = workflow.WithActivityOptions(ctx, activityOpts)
// 用于跟踪需要补偿的步骤
var compensations []func(workflow.Context) error
// -- 步骤 1:创建订单 --
err := workflow.ExecuteActivity(
ctx, "CreateOrder", input.OrderID, input.UserID, input.Items,
).Get(ctx, nil)
if err != nil {
return fmt.Errorf("create order failed: %w", err)
}
// 注册补偿:取消订单
compensations = append(compensations, func(ctx workflow.Context) error {
return workflow.ExecuteActivity(
ctx, "CancelOrder", input.OrderID,
).Get(ctx, nil)
})
// -- 步骤 2:扣减余额 --
err = workflow.ExecuteActivity(
ctx, "DeductPayment", input.UserID, input.Amount,
).Get(ctx, nil)
if err != nil {
// 扣款失败,补偿已完成的步骤
compensate(ctx, compensations)
return fmt.Errorf("deduct payment failed: %w", err)
}
compensations = append(compensations, func(ctx workflow.Context) error {
return workflow.ExecuteActivity(
ctx, "RefundPayment", input.UserID, input.Amount,
).Get(ctx, nil)
})
// -- 步骤 3:预留库存 --
err = workflow.ExecuteActivity(
ctx, "ReserveInventory", input.OrderID, input.Items,
).Get(ctx, nil)
if err != nil {
compensate(ctx, compensations)
return fmt.Errorf("reserve inventory failed: %w", err)
}
compensations = append(compensations, func(ctx workflow.Context) error {
return workflow.ExecuteActivity(
ctx, "ReleaseInventory", input.OrderID, input.Items,
).Get(ctx, nil)
})
// -- 步骤 4:安排发货(枢纽事务之后,使用更激进的重试策略)--
shippingOpts := workflow.ActivityOptions{
StartToCloseTimeout: 60 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 2 * time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 60 * time.Second,
MaximumAttempts: 10, // 更多重试次数
},
}
shippingCtx := workflow.WithActivityOptions(ctx, shippingOpts)
err = workflow.ExecuteActivity(
shippingCtx, "ArrangeShipping",
input.OrderID, input.Address,
).Get(ctx, nil)
if err != nil {
compensate(ctx, compensations)
return fmt.Errorf("arrange shipping failed: %w", err)
}
return nil // Saga 成功完成
}
// compensate 反向执行补偿事务
func compensate(ctx workflow.Context, compensations []func(workflow.Context) error) {
// 补偿使用更宽松的超时和重试(补偿必须尽力完成)
compensationOpts := workflow.ActivityOptions{
StartToCloseTimeout: 60 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: 2 * time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: 120 * time.Second,
MaximumAttempts: 20, // 补偿操作更多重试
},
}
compensationCtx := workflow.WithActivityOptions(ctx, compensationOpts)
// 反向执行
for i := len(compensations) - 1; i >= 0; i-- {
err := compensations[i](compensationCtx)
if err != nil {
// 补偿失败:记录,但继续补偿其他步骤
workflow.GetLogger(ctx).Error(
"compensation failed",
"step", i,
"error", err,
)
// 实际生产中这里应该触发告警和人工介入
}
}
}6.4 Temporal 的持久执行机制
上面代码中的 workflow.ExecuteActivity 调用在
Temporal 内部的执行过程:
1. Worker 执行到 ExecuteActivity("CreateOrder", ...)
2. Temporal SDK 向 Temporal Server 发送 ScheduleActivityTask 命令
3. Temporal Server 将事件 ActivityTaskScheduled 写入 Workflow 的事件历史
4. Temporal Server 将任务放入 Activity Task Queue
5. 一个 Activity Worker 从队列取出任务,执行 CreateOrder 逻辑
6. Activity Worker 将结果返回 Temporal Server
7. Temporal Server 记录 ActivityTaskCompleted 事件
8. Workflow Worker 从事件历史中读取结果,继续执行下一行代码
如果 Workflow Worker 在步骤 3 之后宕机:
- 新的 Workflow Worker 从 Temporal Server 获取事件历史
- SDK 重放(Replay)Workflow 函数:再次调用 ExecuteActivity("CreateOrder", ...)
- 但这次 SDK 不会真正执行 Activity,而是从事件历史中读取已有结果
- 重放完成后,Workflow 恢复到宕机前的执行点
- 继续执行下一个 ExecuteActivity
这种机制的前提是 Workflow
函数必须是确定性的(Deterministic)——给定相同的事件历史,重放必须产生相同的执行路径。这意味着
Workflow 函数中不能使用
time.Now()、随机数、直接 I/O
等非确定性操作——必须通过 Temporal 提供的
workflow.Now()、workflow.SideEffect()
等 API。
6.5 Temporal 的优势和限制
相比手写 Saga 编排器,Temporal 提供:
- 自动重试和超时:通过
RetryPolicy声明式配置,不需要手写重试循环。 - 持久执行:Worker 宕机自动恢复,开发者写的代码看起来像普通的顺序程序。
- 补偿调度:Saga 的补偿逻辑就是普通的 Go 代码,Temporal 保证即使补偿过程中 Worker 宕机也能恢复。
- 可观测性:Temporal UI 显示每个 Workflow 的执行历史、当前状态、待执行的 Activity。
- 版本管理:Workflow 代码更新后,已有的 Workflow 实例可以继续用旧版本执行完毕。
限制:
- 基础设施依赖:Temporal Server 本身需要 MySQL/PostgreSQL + Elasticsearch 作为存储后端,需要运维。Temporal 自身必须高可用——如果 Temporal 集群宕机,所有 Workflow 都会暂停。
- 事件历史膨胀:长时间运行的
Workflow(如每月结算)会积累大量事件历史。Temporal 提供了
ContinueAsNewAPI 来截断历史,但开发者需要主动使用。 - 学习曲线:确定性约束(不能在 Workflow 中使用随机数、当前时间、直接 HTTP 调用等)对习惯了命令式编程的开发者来说需要适应。
- 调试复杂度:Workflow 函数的重放机制意味着断点调试不直观——函数会被多次执行,前面几次是重放(从历史中读取结果),只有最后一次才是真正执行。
七、TCC:资源预留 vs 直接执行
7.1 TCC 的本质区别
TCC(Try-Confirm-Cancel)经常和 Saga 放在一起讨论,但它们是不同的模式。核心区别在一句话里:
Saga 先执行再补偿;TCC 先预留再确认。
Saga 的 T1 直接执行业务操作(扣款 100 元),如果后续步骤失败,用 C1 补偿(退款 100 元)。在 T1 完成和 C1 执行之间,账户余额已经变了——中间状态对外可见。
TCC 的 Try 阶段不执行最终操作,而是预留资源(冻结 100 元,余额从 1000 变成 可用 900 / 冻结 100)。只有当所有服务的 Try 都成功后,才进入 Confirm 阶段真正执行(可用 900 / 冻结 100 -> 可用 900 / 冻结 0,总余额变为 900)。如果任何 Try 失败,进入 Cancel 阶段释放预留(可用 900 / 冻结 100 -> 可用 1000 / 冻结 0)。
7.2 三个阶段的详细设计
Try(尝试):业务检查 + 资源预留。不改变业务数据的最终状态,只冻结/锁定资源。
class AccountService:
def try_deduct(self, account_id, amount, tx_id):
account = db.query(
"SELECT * FROM accounts WHERE id = %s FOR UPDATE",
account_id
)
if account.available_balance < amount:
raise InsufficientBalanceError()
# 预留:减少可用余额,增加冻结金额
db.execute("""
UPDATE accounts
SET available_balance = available_balance - %s,
frozen_amount = frozen_amount + %s
WHERE id = %s
""", amount, amount, account_id)
# 记录 TCC 事务
db.execute("""
INSERT INTO tcc_transactions (tx_id, account_id, amount, phase, status)
VALUES (%s, %s, %s, 'TRY', 'SUCCESS')
""", tx_id, account_id, amount)
db.commit()Confirm(确认):将预留的资源转为正式消耗。Confirm 只操作已冻结的资源,不再做业务检查。
def confirm_deduct(self, account_id, amount, tx_id):
# 幂等检查
existing = db.query(
"SELECT * FROM tcc_transactions WHERE tx_id = %s AND phase = 'CONFIRM'",
tx_id
)
if existing:
return # 已经 Confirm 过
# 确认:释放冻结金额(实际扣款完成)
db.execute("""
UPDATE accounts
SET frozen_amount = frozen_amount - %s
WHERE id = %s
""", amount, account_id)
db.execute("""
INSERT INTO tcc_transactions (tx_id, account_id, amount, phase, status)
VALUES (%s, %s, %s, 'CONFIRM', 'SUCCESS')
""", tx_id, account_id, amount)
db.commit()Cancel(取消):释放 Try 阶段预留的资源。
def cancel_deduct(self, account_id, amount, tx_id):
# 幂等检查
existing = db.query(
"SELECT * FROM tcc_transactions WHERE tx_id = %s AND phase = 'CANCEL'",
tx_id
)
if existing:
return # 已经 Cancel 过
# 取消:释放冻结金额,恢复可用余额
db.execute("""
UPDATE accounts
SET available_balance = available_balance + %s,
frozen_amount = frozen_amount - %s
WHERE id = %s
""", amount, amount, account_id)
db.execute("""
INSERT INTO tcc_transactions (tx_id, account_id, amount, phase, status)
VALUES (%s, %s, %s, 'CANCEL', 'SUCCESS')
""", tx_id, account_id, amount)
db.commit()7.3 Confirm 和 Cancel 的幂等性
Confirm 和 Cancel 必须是幂等的,原因和 Saga
的补偿事务一样——协调器可能因超时或宕机而重试。上面的代码通过
tx_id + phase 做幂等检查。
更严格的实现会使用状态机:
TCC 事务状态机:
INIT -> TRY_SUCCESS -> CONFIRMED
INIT -> TRY_SUCCESS -> CANCELLED
INIT -> TRY_FAILED
非法转移(拒绝):
TRY_FAILED -> CONFIRMED (Try 失败了不能 Confirm)
CONFIRMED -> CANCELLED (已确认不能取消)
CANCELLED -> CONFIRMED (已取消不能确认)
7.4 空回滚和悬挂问题
TCC 有两个 Saga 中不存在的问题:
空回滚(Empty Rollback):Cancel 请求到达时,对应的 Try 还没执行(或者 Try 的请求丢失了)。Cancel 需要处理”没有东西需要取消”的情况,而不是报错。
def cancel_deduct(self, account_id, amount, tx_id):
# 检查 Try 是否执行过
try_record = db.query(
"SELECT * FROM tcc_transactions WHERE tx_id = %s AND phase = 'TRY'",
tx_id
)
if not try_record:
# 空回滚:Try 没执行过,记录 Cancel 但不操作余额
db.execute("""
INSERT INTO tcc_transactions (tx_id, account_id, amount, phase, status)
VALUES (%s, %s, %s, 'CANCEL', 'EMPTY_ROLLBACK')
""", tx_id, account_id, amount)
db.commit()
return
# ... 正常的 Cancel 逻辑悬挂(Suspension):Cancel 在 Try 之前到达并执行了(空回滚)。之后 Try 请求才到达。如果 Try 正常执行,它冻结的资源永远不会被释放(因为 Cancel 已经执行过了,不会再执行第二次)。
def try_deduct(self, account_id, amount, tx_id):
# 防悬挂检查:如果 Cancel 已经执行过,拒绝 Try
cancel_record = db.query(
"SELECT * FROM tcc_transactions WHERE tx_id = %s AND phase = 'CANCEL'",
tx_id
)
if cancel_record:
# Cancel 已经执行,拒绝 Try(防悬挂)
raise SuspensionPreventedError(
f"tx {tx_id} already cancelled, rejecting Try"
)
# ... 正常的 Try 逻辑7.5 TCC vs Saga 对比
| 维度 | Saga | TCC |
|---|---|---|
| 资源隔离 | 无:Ti 直接修改最终状态,中间状态可见 | 有:Try 只冻结资源,最终状态在 Confirm 后才可见 |
| 业务侵入 | 中:需要实现补偿接口 | 高:需要实现 Try/Confirm/Cancel 三个接口 + 冻结字段 |
| 数据模型改动 | 无或少 | 需要增加冻结字段(frozen_amount 等) |
| 并发安全 | 差:依赖业务层面的语义锁 | 好:冻结机制天然防止资源超卖 |
| 实现复杂度 | 中 | 高(空回滚、悬挂等额外问题) |
| 适用场景 | 长流程、非金融、可容忍中间状态可见 | 短流程、金融交易、要求中间状态不可见 |
| 锁持有时间 | 极短(每个 Ti 独立提交) | 较短(Try 到 Confirm/Cancel 之间) |
| 一致性保证 | 最终一致(通过补偿) | 最终一致(通过 Confirm/Cancel) |
7.6 TCC 的适用场景
TCC 最适合的场景是金融交易,中间状态不可见是强需求:
- 转账:A 账户冻结 100 元(Try),B 账户预增 100 元(Try)。两边都成功后,A 确认扣款,B 确认入账(Confirm)。任何一方 Try 失败,全部 Cancel。在 Try 到 Confirm 之间,A 的余额看起来少了 100(冻结中),但实际可用余额是精确的——不会出现 A 扣了款但 B 没收到的中间状态。
- 库存预留:电商秒杀场景下,用 TCC 的 Try 冻结库存,避免超卖。比 Saga 的”先扣再补”安全得多——在 Saga 中,如果扣减库存后支付失败,在补偿库存之前,另一个订单可能已经看到库存不足而被拒绝了。
- 多账户操作:需要同时操作多个独立系统的金额(如结算系统、积分系统、优惠券系统),每个系统都支持 Try/Confirm/Cancel 接口。
7.7 何时不应使用 Saga
Saga 是一种务实的工程妥协,但它不是万能方案。以下场景中使用 Saga 会带来无法接受的风险或不可控的复杂度,应当选择其他方案。
场景一:严格 ACID 语义不可让步
银行核心账务系统的总账(General Ledger)要求每一笔记账都满足借贷平衡。如果使用 Saga,T1 记了一笔借方,T2 记了一笔贷方,中间出现故障需要补偿——补偿本身又会产生新的借贷记录,使得审计轨迹变得复杂。更关键的是,在 T1 提交到 T2 提交之间,总账是不平衡的,这违反了核心的会计恒等式。这类场景必须使用强一致性方案,如基于 Paxos/Raft 复制的 2PC,或单机数据库事务。
场景二:补偿在物理层面不可能
某些操作一旦执行,就无法通过任何软件手段撤回:
不可补偿操作示例:
- 工业控制:注塑机已注入原料,化学反应已开始
- 医疗系统:药物已注射,手术机器人已切割
- IoT 设备:智能门锁已开锁,访客已进入
- 数据泄露:敏感信息已发送至外部系统
当 Saga 的某个步骤涉及物理世界的不可逆操作时,补偿事务的基本前提——“可以通过反向操作达到等效业务结果”——不成立。这类步骤应当放在 Saga 的最后(枢纽事务之后),或者使用预留-确认模式(TCC),在确认阶段之前保持可撤销。
场景三:隔离性违反不可接受
Saga 的中间状态对外可见(ACD 属性,缺少 I)。在以下场景中,这种可见性会导致业务错误:
库存超卖场景:
T=0: Saga-1 的 T1 扣减库存 10 件(库存 100 -> 90),提交
T=1: Saga-2 读到库存 90,扣减 85 件(库存 90 -> 5),提交
T=2: Saga-1 的 T2 失败,C1 补偿库存(库存 5 -> 15)
结果:实际只售出 85 件,但库存从 100 变为 15,凭空消失了 85 件库存
如果 Saga-2 也需要补偿,库存计算会更加混乱
当业务逻辑对中间状态的读取敏感度极高(如金融风控实时额度计算、秒杀库存精确控制),Saga 的隔离性缺失会导致级联错误。这类场景应使用 TCC(通过冻结字段隔离中间状态)或传统的 2PC。
场景四:步骤数导致补偿爆炸
随着 Saga 步骤数的增长,补偿的复杂度呈非线性增长:
步骤数 补偿路径数 需要测试的场景数
3 3 ~9(3 个失败点 * 3 种补偿)
5 5 ~25
8 8 ~64
12 12 ~144
20 20 ~400+(含交错、重试、幂等组合)
当 Saga 超过 8-10 个步骤时,补偿逻辑的测试覆盖率很难保证。每新增一个步骤,不仅增加一条补偿路径,还增加了与所有已有步骤的交互组合。实践中,超过 10 步的 Saga 应当拆分为多个子 Saga(嵌套 Saga),或者重新审视业务流程是否可以简化。
替代方案选择指南
需求 推荐方案
强一致 + 短事务(< 100ms) 2PC with Paxos/Raft 复制
强一致 + 跨数据中心 Spanner 风格的同步复制 + 2PC
中间状态不可见 + 中等复杂度 TCC(Try-Confirm-Cancel)
长流程 + 步骤极多 + 需要人工审批 工作流引擎(Temporal/Cadence)
最终一致 + 无事务语义 异步消息 + 幂等消费者
选择 Saga 之前,应当明确回答三个问题:每个步骤是否都有可行的补偿方案?中间状态的可见性是否在业务上可接受?步骤数和补偿路径的测试覆盖率是否可控?如果任何一个问题的答案是否定的,Saga 就不是正确的选择。
八、结论
3PC 是理论上重要但工程上无用的协议。它第一次证明了非阻塞提交协议是可能的,但前提条件(无网络分区)在真实系统中不成立。现代系统通过 Paxos/Raft 复制的 2PC 解决了阻塞问题,完全绕过了 3PC。
Saga 是跨服务工作流的务实选择。它放弃隔离性,用补偿事务实现语义上的原子性。编排模式适合复杂流程,协同模式适合简单的事件链。Temporal 等工作流引擎解决了手写 Saga 的持久化、重试、恢复等工程难题。
TCC 适合资源预留语义必须的场景,尤其是金融交易。它通过冻结字段实现中间状态的隔离,代价是三倍的接口数量和空回滚、悬挂等额外问题。
没有银弹。每种模式都在隔离性、复杂度和故障处理之间做权衡:
| 需求 | 推荐方案 |
|---|---|
| 强一致性 + 短事务 | 2PC(Paxos/Raft 复制) |
| 跨服务长流程 + 可容忍中间状态 | Saga |
| 金融交易 + 资源预留 + 中间状态不可见 | TCC |
| 理论研究 | 3PC |
参考资料
论文
- Skeen, D. “Nonblocking Commit Protocols.” Proceedings of the 1981 ACM SIGMOD International Conference on Management of Data, 1981.
- Garcia-Molina, H., Salem, K. “Sagas.” Proceedings of the 1987 ACM SIGMOD International Conference on Management of Data, 1987.
- Fischer, M. J., Lynch, N. A., Paterson, M. S. “Impossibility of Distributed Consensus with One Faulty Process.” Journal of the ACM, 32(2):374-382, 1985.
- Lamport, L. “Paxos Commit: Non-Blocking Atomic Commit with Paxos.” ACM Transactions on Database Systems, 2006.
- Bernstein, P. A., Hadzilacos, V., Goodman, N. “Concurrency Control and Recovery in Database Systems.” Addison-Wesley, 1987. Chapter 7: Distributed Commit.
书籍
- Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. Chapter 9: Consistency and Consensus.
- Richardson, C. Microservices Patterns. Manning, 2018. Chapter 4: Managing Transactions with Sagas.
源码与文档
- Temporal Go SDK:
temporalio/sdk-go,workflow/目录。 - Temporal 官方文档:“What is a Saga?” https://docs.temporal.io/encyclopedia/sagas
- Seata 开源项目:TCC 模式实现,
seata/seata,tcc/模块。 - AWS Step Functions 文档:“Implement the Saga Pattern” https://docs.aws.amazon.com/prescriptive-guidance/latest/patterns/implement-the-saga-pattern.html
工具
- Temporal:https://temporal.io — 工作流引擎,Saga 编排的现代基础设施。
- Apache Kafka:https://kafka.apache.org — 事件驱动架构中协同模式的消息基础设施。
- Seata:https://seata.io — 开源分布式事务框架,支持 TCC、Saga、AT 模式。
导航
- 上一篇:2PC 的真实失败模式
- 下一篇:Percolator 模型
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统实战】分布式事务不是你以为的那个 2PC
「用 2PC 就行了」——说这话的人大概没在生产环境里被 Coordinator 挂掉后全员阻塞的锁堵过三小时。2PC 的真实失败模式、Percolator 的精妙设计、Saga 与 TCC 的工程取舍,分布式事务远比教科书复杂。
【分布式系统百科】2PC 的真实失败模式:阻塞、脑裂与恢复
2PC 在 Coordinator 崩溃时会阻塞所有参与者;本文精确分析三类故障窗口,拆解 Presumed Abort 优化原理,对比 Spanner/CockroachDB/TiDB 的现代方案。
【分布式系统百科】Percolator 模型:Google 的乐观事务方案
Percolator 在 Bigtable 之上用三列设计实现了跨行分布式事务,其核心思路是把事务协调状态编码进数据本身,从而消除了对专用协调者节点的依赖。本文拆解其两阶段提交流程、冲突检测与锁清理机制,并分析 TiDB 对该模型的工程改进。
【分布式系统百科】ZooKeeper 内核:从 ZAB 协议到分布式协调实践
深入拆解 ZooKeeper 的核心机制:ZAB 协议的三阶段流程、ZNode 数据模型、Watch 一次性通知、会话管理,以及分布式锁、Leader 选举、配置管理等典型用法。分析惊群效应等已知问题,并梳理 ZooKeeper 在 Kafka、HBase、Hadoop 生态中的角色。