Skip to content

分布式事务:两边不能一起提交时,怎样把账做完整 ​

创建订单成功,扣库存超时,支付又已经扣款。此时最危险的处理是把所有超时都当成失败,再重新执行一遍。一个跨服务操作可能已经完成一部分,还可能有一部分结果未知。分布式事务方案要规定这些中间状态如何保存、怎样恢复,以及谁负责把流程收束。

本文从原子提交讲到业务补偿和消息方案。它们不是同一种保证的不同品牌:2PC 尝试让参与者对提交或中止达成一致;Saga 接受某些步骤先完成,再用新的业务动作补偿;消息方案常把“业务变化”和“稍后要做的事”连起来。选型前先决定哪些业务不变量必须立即成立。

能避免跨边界,就先避免 ​

“能避免就避免”不是忽略一致性,而是先检查边界是否合理。若订单状态和库存预留本来就由同一服务、同一数据库拥有,一次本地事务往往比增加远程协调者更容易恢复。若所谓“微服务”只是把同一事务的两张表搬到两个接口后面,新增的网络步骤可能没有换来独立演进的收益。

业务要求优先考虑的方向必须接受的成本
同一数据所有者内维护约束本地事务、条件更新、合适隔离级别这个边界内存在耦合,要有容量与发布规划
多资源必须共同作出提交决定2PC 或由数据库提供的分布式事务准备态资源、协调与恢复成本;隔离另行核对
可以先保留资源,再确认最终使用TCC业务实现预留、确认、取消及异常状态机
长流程允许已完成步骤被业务补偿Saga中间状态可见、补偿失败、人工处理
下游可以稍后反映上游事实本地消息表/事务消息延迟窗口、重复、乱序、对账
外部系统通过通知得知结果有界重试通知 + 权威查询/对账不能仅靠“尽力”承诺必达

这里的方向不是无条件推荐。例如扣款是否能补偿,要看支付提供方和业务规则;发送实体商品之后,一条“取消发货”消息不一定能把包裹拿回来。

2PC:准备好以后,为什么不能自己反悔 ​

两阶段提交有协调者和资源参与者。第一阶段询问能否提交;参与者回复 prepared 前,要持久化必要状态并保留完成事务需要的资源。只有收到所有参与者的 prepared,协调者才可决定 commit;收到否决,或在作出 commit 决定之前等待投票超时,可以决定 abort。第二阶段把最终决定持久化,再通知参与者执行。[1]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant C as 协调者
    participant O as 订单资源
    participant I as 库存资源
    C->>O: Prepare
    C->>I: Prepare
    O-->>C: Prepared,必要状态已持久化
    I-->>C: Prepared,必要状态已持久化
    Note over C: 持久化 Commit 决定
    C->>O: Commit
    Note over C,I: 此时通信中断
    Note over I: 处于 prepared,无法凭超时决定 abort
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant C as 协调者
    participant O as 订单资源
    participant I as 库存资源
    C->>O: Prepare
    C->>I: Prepare
    O-->>C: Prepared,必要状态已持久化
    I-->>C: Prepared,必要状态已持久化
    Note over C: 持久化 Commit 决定
    C->>O: Commit
    Note over C,I: 此时通信中断
    Note over I: 处于 prepared,无法凭超时决定 abort

图中订单已经收到 Commit,库存却没收到。如果库存自己超时中止,会把同一次事务分成“订单已提交、库存已中止”。所以处于不确定状态的参与者需要找到可信的最终决定;找不到时,经典 2PC 可能阻塞。

“协调者挂了,所以所有节点永远阻塞”同样太粗。知道中止安全的状态可以中止;已有最终决定的节点可以按恢复协议帮助传播;协调者恢复后可以重放持久化决定。阻塞问题针对的是参与者已 prepared、又无法获知可靠决定的执行状态。[1]

PostgreSQL 的 PREPARE TRANSACTION 会把事务置于可由外部事务管理器完成的状态;准备后的事务仍可能持有锁和占用资源,官方文档警告不要长时间留着不处理。[2] 这说明选 2PC 不只是在请求链上多等两轮,还要经营恢复、清理、监控和容量。

协调者用 Raft 复制之后,还算 2PC 吗? ​

让协调者的决定日志通过共识复制,可以让另一副本在单机故障后恢复协调角色,减少单点造成的阻塞。但“原子提交”与“复制一份决定”仍是不同问题:要提交,参与者必须满足各自准备条件;如果某个资源不可访问,或者决策集群也失去所需 quorum,流程仍可能等下去。

Gray 与 Lamport 的 Paxos Commit 把参与者的决策信息交给共识处理,并分析它与经典 2PC 的关系。[1] 因此不应把“2PC 一定只有单机协调者”当作所有产品的结构,也不应宣传“用了 Raft,所以跨服务事务永不阻塞”。具体可用性取决于实际复制、恢复和故障假设。

原子提交也不自动决定隔离级别。两个事务各自在不同资源上读取并修改状态,是否允许写偏差、并发检查失效,需要锁、版本检查或数据库的分布式隔离实现来约束。2PC 解决“共同提交还是共同中止”,不是一张包办所有 ACID 属性的标签。

3PC:多一段状态,为超时决策提供更多信息 ​

经典三阶段提交在投票与最终提交之间增加 pre-commit 状态,常按 can-commit、pre-commit、do-commit 理解。目标是减少 2PC 中那种参与者既不能安全提交、也不能安全中止的不确定状态。[3]

状态参与者已经知道什么仍需小心什么
等待投票结果自己准备好,未必知道所有人是否准备好不能凭本地想法宣告全局成功
已进入 pre-commit协议已经推进到提交前的协调状态故障后需要按终止协议和系统模型决定下一步
收到最终决定该执行已作出提交/中止决定必须幂等执行并持久化恢复信息

3PC 的非阻塞结论依赖它使用的时序和故障假设。Skeen 原论文明确假定正常站点总能相互通信、网络本身不故障,且网络可以可靠地检测并报告站点故障。若想用超时实现这种可靠判断,需要相应的通信和处理时限等更强条件,不能在任意延迟模型中凭空得到。若现实中允许任意长消息延迟和网络分区,不同组参与者可能根据超时走向不相容的判断;不能把它视作通用异步网络里“加一轮就解决 2PC”。Skeen 原论文应连同系统模型和终止协议阅读。[3]

所以在普通后端选型里,先确认数据库或中间件实际支持什么恢复机制,比自行拼一个三阶段 RPC 流程更重要。本文没有实现或验证真实 3PC。

TCC:把数据库的“准备”变成业务上的“预留” ​

TCC 是 Try、Confirm、Cancel。Try 先检查条件并预留资源,Confirm 将预留变成最终效果,Cancel 释放预留。以库存为例,Try 可以把可售库存移入预留,而不是立刻发货;所有必要 Try 成功后,协调者推进 Confirm。Seata 的 TCC 文档将它描述为业务侵入式两阶段方案,三个操作由业务实现。[4]

操作一个库存实现可能做什么关键约束
Try以分支事务 ID 建预留单,扣减可售、增加预留同 ID 重试不能再扣一次;预留不能被别的订单使用
Confirm将对应预留标为已确认并推进履约多次确认结果一致;不能确认已取消分支
Cancel释放对应预留并标为已取消只释放自己的预留;重复取消不能多加库存

TCC 最麻烦的部分往往在正常路径之外:

  • 空回滚:Cancel 先到,Try 还没执行。仍要留下取消记录,不能简单“查不到就返回”后把状态遗忘。
  • 悬挂:迟到的 Try 在 Cancel 之后抵达。必须识别这个分支已取消,阻止它再创建预留。
  • 幂等:Confirm/Cancel 的响应丢失,协调者会重试。状态转换和资源变更必须一起受保护。

可以用持久化分支记录形成 不存在 → 已预留 → 已确认/已取消 的状态机;空回滚允许直接留下已取消墓碑。状态记录保留多久,要覆盖最大重试和迟到窗口,过早清理又会让旧请求复活。具体字段和拦截机制依实现而定,不能只把方法名补齐就宣称支持 TCC。

预留还会占用容量。Try 成功但最后未成交的请求越多,业务资源被占住的时间越长;预留超时与协调者恢复要共同设计,不能一边自动释放,一边允许迟到 Confirm 无条件成功。

Saga:补偿是一笔新业务,不是时间倒流 ​

Saga 把长事务拆成本地事务序列,每一步提交后都可能被外部观察。后续失败时,用补偿动作抵消可抵消的效果。原始 Saga 论文就把补偿与长事务分段执行联系起来。[5]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[创建待处理订单] --> B[预留库存] --> C[请求扣款] --> D[确认订单]
    C -. 已确认业务失败且允许补偿 .-> X[取消库存预留]
    C -. 超时或结果未知 .-> U[查询权威状态并对账]
    U -. 查明成功 .-> D
    U -. 查明确认失败 .-> X
    X --> Y[将订单标为取消]
    X -. 补偿失败 .-> R[持久化重试与人工处理]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[创建待处理订单] --> B[预留库存] --> C[请求扣款] --> D[确认订单]
    C -. 已确认业务失败且允许补偿 .-> X[取消库存预留]
    C -. 超时或结果未知 .-> U[查询权威状态并对账]
    U -. 查明成功 .-> D
    U -. 查明确认失败 .-> X
    X --> Y[将订单标为取消]
    X -. 补偿失败 .-> R[持久化重试与人工处理]

“退款”不同于数据库回滚:扣款已经发生,退款可能有手续费、到账延迟、额度限制,也可能被拒绝。发送出去的邮件无法从收件人的记忆里撤回。把某字段写回旧值也未必是正确补偿,因为期间可能已有其他事务修改它。

因此补偿通常针对自己的操作记录做条件变更。例如取消某个 reservation ID,而不是把“库存总量”设回之前读到的数字。补偿也需要幂等键、状态检查、重试和对账。出现不可逆步骤时,要调整流程:尽量在不可逆动作前完成检查和可撤销预留,并明确跨过这一步后是继续向前修复,还是进入人工处置。

Saga 有两种常见组织方式。编排式由持久化流程管理者发起下一步,较容易看清全局状态,代价是协调逻辑集中;事件协作式由各服务响应事件推进,降低直接调用耦合,但流程分散后更难追踪、版本升级和定位循环触发。两者都不能靠一个活着的 goroutine 保存唯一进度。

Saga 也不默认提供传统事务隔离。另一个订单可能看到“已扣款但未发货”或“取消处理中”;接口应暴露真实业务状态,并约束在这些状态下允许做什么。

本地消息表:先确保“业务事实”和“待发送事件”一起留下 ​

订单数据库提交成功后再发消息,进程可能在中间退出,形成“有订单、没消息”。先发消息再提交数据库,则可能出现“消费者以为有订单,数据库却回滚”。本地消息表,也叫 transactional outbox,把业务数据和待发事件写进同一本地事务,然后由独立 relay 发送。[6][7]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant S as 订单服务
    participant DB as 同一本地数据库
    participant R as Relay
    participant Q as 消息系统
    participant C as 消费者
    S->>DB: 一个事务:订单 + outbox 事件
    DB-->>S: 提交成功
    R->>DB: 读取未发送记录或捕获提交变更
    R->>Q: 发送 event_id=o-1
    Q->>C: 投递
    Note over R,DB: 发送成功但标记前崩溃,可导致再次发送
    R->>Q: 恢复后重发同一事件
    Q->>C: 再次投递
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant S as 订单服务
    participant DB as 同一本地数据库
    participant R as Relay
    participant Q as 消息系统
    participant C as 消费者
    S->>DB: 一个事务:订单 + outbox 事件
    DB-->>S: 提交成功
    R->>DB: 读取未发送记录或捕获提交变更
    R->>Q: 发送 event_id=o-1
    Q->>C: 投递
    Note over R,DB: 发送成功但标记前崩溃,可导致再次发送
    R->>Q: 恢复后重发同一事件
    Q->>C: 再次投递

轮询 outbox 与 CDC 都能驱动 relay,前者易理解但需要扫描和抢任务,后者依赖数据库变更日志、连接器与位点管理。Debezium 的 Outbox Event Router 按 outbox 字段转换事件,并提供事件 ID、aggregate ID 等信息;它不替消费端实现任意业务副作用的去重。[6]

模型用一把锁模拟本地原子操作,重复创建同一订单不会增加第二条事件:

text
orders: 1 outbox: 1

这不是持久化事务测试。它只是把“订单和 outbox 在一个不可分的本地步骤中变化”写成可检查的模型。真实数据库必须用同一次事务提交,还要处理提交结果未知、唯一约束、事务隔离和崩溃恢复。

出站可靠,不等于副作用只发生一次 ​

relay 如果先标记发送成功再发消息,崩溃会丢消息;先发后标记,则崩溃可能重发。因此消费端通常要把重复当作正常输入。

go
first, e := sink.Apply(s.outbox[0])
must(e)
again, e := sink.Apply(s.outbox[0])
must(e)
fmt.Println("delivery 1:", first, "delivery 2:", again,
    "effect total:", sink.Total)
_, e = sink.Apply(Event{"o-1", 99})
fmt.Println("same id different payload:", e)

实际输出:

text
delivery 1: true delivery 2: false effect total: 10
same id different payload: id conflict

模型的 Apply 在同一临界区中检查事件 ID、比较 payload、更新效果总量并记下去重结果。若把“已处理”先写入,再单独执行效果,中间崩溃,就会留下已处理标记但没产生效果。完整程序显式构造这个坏状态,再重放事件:

text
split dedupe/effect after crash: false total: 0

这条输出解释了为什么“加个 Redis 去重键”未必够。若业务效果在另一个数据库或外部支付系统,去重键和效果仍然跨边界;需要目标方提供可靠幂等能力、共同事务范围,或状态查询与对账机制。消息系统的投递保证不能自动延伸到一封邮件或一次外部扣款。

事件有了唯一 ID,也没有自动有顺序。同一订单的“创建”和“取消”如果被不同 relay 并发发送,消费者可能先收到取消。可以按 aggregate 路由、携带业务版本并做条件应用;跨 aggregate 全局排序则需要额外成本。重放、积压清理和去重记录保留时间必须协同设计。

事务消息:消息系统帮忙协调哪一段? ​

以 RocketMQ 的事务消息为例,生产者先发送半消息;半消息对消费者不可见。随后执行本地事务,再向 broker 提交 commit 或 rollback;若最终状态未知,broker 回查生产者,由生产者依据可查询的本地事务状态回答。[8]

这解决的是本地事务与消息最终可见性之间的协调。回查不能仅凭进程内布尔变量判断;生产者重启后仍要能确定该业务事务是否提交。消息被消费者收到以后,其处理失败、重复投递、外部副作用等问题还在。

对比项本地消息表RocketMQ 式事务消息
待处理事实在哪里本地业务库的 outboxbroker 半消息 + 生产者可回查的本地状态
谁推动恢复relay/CDC 和业务监控broker 回查与生产者响应,另加业务监控
应用主要工作原子写 outbox、发送、标记/位点、消费幂等本地事务监听、可靠状态查询、消费幂等
不自动解决的部分消费副作用原子性、全局隔离同样不自动解决

这个机制针对 RocketMQ 官方描述,不能把所有产品叫“事务消息”的功能都视为同一个协议。例如 broker 内部的多条消息事务,并不必然覆盖用户自己的 SQL 事务。

最大努力通知:给有限重试配一个可靠的查证入口 ​

付款平台通知商户结果,是典型的异步通知场景。“最大努力”在不同实现里可能代表不同策略;本文将它限定为:保存通知任务,按计划重试到次数或时间上限,接收方可以查询权威状态,双方通过对账修复遗漏。它不承诺任意故障下通知必达。

通知接收方应验证来源和完整性,按业务事件标识幂等处理,区分“收到 HTTP 请求”和“业务状态已持久化”。发送方要记录尝试次数、下一次时间、响应和最终失败状态;重试预算耗尽必须转入可见的待处理列表。通知里给出的状态与本地状态冲突时,应按业务协议查询权威来源,不能盲目覆盖。

用户页面也不应仅凭浏览器跳回成功页就确认付款。浏览器重定向可以丢失或被伪造,服务器需要自己的可验证结果链。具体签名、查询及重试规则必须遵守相应提供方文档;本文没有接入支付服务,也没有验证任何提供方的通知时效。

评审方案时,拿一笔卡住的业务走完恢复路径 ​

给设计者一个具体状态:“本地已提交,下游结果未知,执行进程已经重启。”然后问:

  1. 在哪里找回这笔业务的唯一身份和已完成步骤?
  2. 重试会重复扣减吗?相同 ID 配不同内容怎样处理?
  3. 谁判定继续、取消、补偿或人工介入?判断所需事实在哪里?
  4. 中间状态给用户显示什么?还允许哪些后续操作?
  5. 多久未收束会告警?对账能找出哪些遗漏,找不出的又有哪些?

能回答这些,才算拥有一个可恢复的方案。只画一条正常路径并写“失败重试、最终一致”,还没有说明失败以后系统如何变回可理解的状态。

依据与验证范围 ​

  1. Gray、Lamport:Consensus on Transaction Commit,§2–4:2PC 的阻塞、Paxos Commit 与故障容忍。
  2. PostgreSQL 17 PREPARE TRANSACTION:prepared 事务及资源占用警告。
  3. Skeen:Nonblocking Commit Protocols:三阶段提交及其系统假设。
  4. Apache Seata TCC mode:Try/Confirm/Cancel 与业务侵入性;本文异常状态机是据此需求给出的设计解释。
  5. Garcia-Molina、Salem:Sagas:长事务分段、补偿及调度。
  6. Debezium Outbox Event Router:outbox 结构、事件 ID 与 aggregate 路由。
  7. Chris Richardson:Transactional outbox:模式作者对双写缺口、relay 和重复发布的说明。
  8. Apache RocketMQ 事务消息:半消息、本地事务、二次确认及回查。滚动产品文档查阅日为 2026-10-13,落地应固定版本。

Go 1.27.2、Linux/amd64,运行、普通测试、race 和 vet 全部通过。实际检查本地内存原子步骤、相同请求重放、payload 冲突、100 个并发重复投递只产生一次模型效果,以及“去重记录已写但效果未写”的反例。未使用数据库、broker 或真实进程崩溃;模型不验证持久化原子性、2PC/3PC/TCC/Saga 协议、外部支付或跨服务 exactly-once。

完整可复现实验 ​

把以下三个文件放在同一空目录。只依赖标准库,不需要外部服务。

sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...

go.mod ​

mod
module example.com/distributed/transactions

go 1.27.2

main.go ​

代码块 · 80 行 · 点击收起 / 展开
go
package main

import (
	"errors"
	"fmt"
	"sync"
)

type Event struct {
	ID     string
	Amount int
}
type Store struct {
	mu     sync.Mutex
	orders map[string]int
	outbox []Event
}

func (s *Store) Create(id string, amount int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if amount <= 0 {
		return errors.New("invalid amount")
	}
	if old, ok := s.orders[id]; ok {
		if old != amount {
			return errors.New("id conflict")
		}
		return nil
	}
	// One memory critical section models a local atomic transaction, not durability.
	s.orders[id] = amount
	s.outbox = append(s.outbox, Event{id, amount})
	return nil
}

type Sink struct {
	mu    sync.Mutex
	seen  map[string]int
	Total int
}

func (s *Sink) Apply(e Event) (bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if old, ok := s.seen[e.ID]; ok {
		if old != e.Amount {
			return false, errors.New("id conflict")
		}
		return false, nil
	}
	// Deduplication record and business effect are changed atomically in this model.
	s.Total += e.Amount
	s.seen[e.ID] = e.Amount
	return true, nil
}
func main() {
	s := Store{orders: map[string]int{}}
	must(s.Create("o-1", 10))
	must(s.Create("o-1", 10))
	fmt.Println("orders:", len(s.orders), "outbox:", len(s.outbox))
	sink := Sink{seen: map[string]int{}}
	// Relay crashes after first delivery and before marking the row sent.
	first, e := sink.Apply(s.outbox[0])
	must(e)
	again, e := sink.Apply(s.outbox[0])
	must(e)
	fmt.Println("delivery 1:", first, "delivery 2:", again, "effect total:", sink.Total)
	_, e = sink.Apply(Event{"o-1", 99})
	fmt.Println("same id different payload:", e)
	broken := Sink{seen: map[string]int{"o-1": 10}} // crashed after marking seen, before effect
	applied, e := broken.Apply(Event{"o-1", 10})
	must(e)
	fmt.Println("split dedupe/effect after crash:", applied, "total:", broken.Total)
}
func must(e error) {
	if e != nil {
		panic(e)
	}
}

main_test.go ​

代码块 · 44 行 · 点击收起 / 展开
go
package main

import (
	"sync"
	"testing"
)

func TestAtomicOutbox(t *testing.T) {
	s := Store{orders: map[string]int{}}
	if s.Create("x", 0) == nil || len(s.outbox) != 0 {
		t.Fatal("failed transaction published")
	}
	if s.Create("x", 4) != nil || s.Create("x", 4) != nil || len(s.outbox) != 1 {
		t.Fatal("duplicate create")
	}
	if s.Create("x", 5) == nil {
		t.Fatal("conflicting payload")
	}
}
func TestConcurrentReplay(t *testing.T) {
	s := Sink{seen: map[string]int{}}
	var wg sync.WaitGroup
	for range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, e := s.Apply(Event{"x", 7})
			if e != nil {
				t.Error(e)
			}
		}()
	}
	wg.Wait()
	if s.Total != 7 {
		t.Fatal(s.Total)
	}
}
func TestBrokenDedupLosesEffect(t *testing.T) {
	s := Sink{seen: map[string]int{"x": 7}}
	ok, e := s.Apply(Event{"x", 7})
	if e != nil || ok || s.Total != 0 {
		t.Fatal("negative fixture changed")
	}
}

保留各书来源与许可 · 示例运行条件以正文为准