下单要扣库存、扣余额、加积分,三个操作跨三个服务——要么全成功,要么全失败。这就是分布式事务要解决的问题。本文用完整可运行的 Go 代码实现三大主流方案:2PC 两阶段提交(含协调者宕机时的阻塞问题复现)、TCC 三段式业务补偿(含空回滚与悬挂问题的标准解法)、Saga 长事务与补偿引擎,并用一张对照表说明各自的真实适用边界。
一、为什么本地事务的 ACID 无法直接搬过来
单机数据库的 ACID 依赖两个本地设施:WAL 日志(保证持久性与原子性)和锁管理器(保证隔离性)。跨服务后这两个设施不复存在:没有全局锁管理器(服务间数据库独立),没有统一日志(写入时机由网络决定)。分布式事务的所有方案,本质上都是在回答一个问题:如何让多个独立的资源管理器表现得像一个整体。
二、完整实现一:2PC 两阶段提交
2.1 协议状态机
2PC 引入协调者角色,把提交拆成「准备」与「提交」两阶段。参与者有三个状态:初始(INIT)、已准备(PREPARED,资源已锁定)、已提交/已回滚(COMMITTED/ABORTED)。关键是 PREPARED 状态的承诺:参与者一旦回复 YES,就必须保证之后无论自己重启多少次,都能按协调者的最终决定执行:
package main
import (
"fmt"
"sync"
"time"
)
type Vote int
const (
VoteYes Vote = iota
VoteNo
)
type TxnState int
const (
Init TxnState = iota
Prepared
Committed
Aborted
)
func (s TxnState) String() string {
return []string{"INIT", "PREPARED", "COMMITTED", "ABORTED"}[s]
}
// Participant 参与者:持有一个资源(如某服务的数据库连接)
type Participant struct {
mu sync.Mutex
id string
state TxnState
balance int
frozen int // 冻结金额(准备阶段锁定)
canLocal bool // 本地约束检查是否通过(如余额充足)
txnLog []string // 持久化事务日志(WAL 角色)
}
func NewParticipant(id string, balance int, canLocal bool) *Participant {
return &Participant{id: id, balance: balance, canLocal: canLocal, state: Init}
}
// writeLog 模拟持久化:任何状态变迁先落日志再改内存
func (p *Participant) writeLog(msg string) {
p.txnLog = append(p.txnLog, msg)
}
// Prepare 阶段一:校验 + 冻结资源 + 持久化准备记录
func (p *Participant) Prepare(amount int) Vote {
p.mu.Lock()
defer p.mu.Unlock()
if !p.canLocal || p.balance < amount {
p.state = Aborted
p.writeLog(fmt.Sprintf("PREPARE amount=%d → ABORT (本地校验失败)", amount))
fmt.Printf("[%s] 准备失败: 余额 %d 不足 %d,投 NO\n", p.id, p.balance, amount)
return VoteNo
}
p.frozen = amount // 冻结而非扣减:资源锁定但所有权未转移
p.state = Prepared
p.writeLog(fmt.Sprintf("PREPARED amount=%d (已冻结)", amount))
fmt.Printf("[%s] 准备成功: 冻结 %d (余额 %d → 可用 %d),投 YES\n",
p.id, amount, p.balance, p.balance-amount)
return VoteYes
}
// Commit 阶段二提交:真正扣款
func (p *Participant) Commit() {
p.mu.Lock()
defer p.mu.Unlock()
p.balance -= p.frozen
p.writeLog(fmt.Sprintf("COMMITTED 扣款 %d 生效,余额 %d", p.frozen, p.balance))
p.frozen = 0
p.state = Committed
fmt.Printf("[%s] 已提交: 余额 = %d\n", p.id, p.balance)
}
// Abort 阶段二回滚:解冻
func (p *Participant) Abort() {
p.mu.Lock()
defer p.mu.Unlock()
p.writeLog(fmt.Sprintf("ABORTED 解冻 %d", p.frozen))
p.frozen = 0
p.state = Aborted
fmt.Printf("[%s] 已回滚: 冻结解除,余额 = %d\n", p.id, p.balance)
}
2.2 协调者实现
// Coordinator 协调者:驱动两阶段协议
type Coordinator struct {
participants []*Participant
}
// Execute 执行一次分布式事务,返回是否成功
func (c *Coordinator) Execute(amounts map[*Participant]int) bool {
// ===== 阶段一:向所有参与者征集投票 =====
fmt.Println("\n--- 阶段一: Prepare ---")
allYes := true
for p, amt := range amounts {
if p.Prepare(amt) == VoteNo {
allYes = false
}
}
// ===== 协调者决策:先持久化决策日志,再通知参与者 =====
fmt.Println("\n--- 阶段二: 决策 ---")
if allYes {
// 决策 COMMIT:此日志落盘即代表事务已进入"必提交"状态
fmt.Println("[协调者] 全员 YES,决策 COMMIT(决策日志已持久化)")
for p := range amounts {
p.Commit()
}
return true
}
fmt.Println("[协调者] 存在 NO 票,决策 ABORT")
for p, amt := range amounts {
if p.state == Prepared {
_ = amt
p.Abort()
}
}
return false
}
2.3 2PC 的致命缺陷:阻塞问题复现
2PC 的阻塞发生在特定的故障窗口:参与者在 Prepare 阶段投了 YES(资源已锁定),而协调者在广播最终决定前宕机。此时参与者既不能提交(不知道其他人是否都投了 YES),也不能回滚(可能协调者已经写下了 COMMIT 决策日志)——只能带着锁定的资源无限等待:
func demoBlocking() {
fmt.Println("\n===== 2PC 阻塞问题复现 =====")
account := NewParticipant("账户服务", 500, true)
stock := NewParticipant("库存服务", 100, true)
// 阶段一完成:两个参与者都投了 YES,资源已冻结
account.Prepare(200)
stock.Prepare(10)
// 协调者在决策前宕机!
fmt.Println("\n[系统] ⚠ 协调者在决策前宕机,恢复时间未知")
fmt.Println("[账户服务] 已冻结 200,但不知该提交还是回滚")
fmt.Println("[库存服务] 已冻结 10,但不知该提交还是回滚")
fmt.Println("[后果] 冻结期间,账户可用余额与库存被持续锁定:")
fmt.Printf(" 任何尝试动用这笔资金/库存的新事务都将失败或排队\n")
fmt.Printf(" 账户服务状态: %s (frozen=%d, balance=%d)\n",
account.state, account.frozen, account.balance)
// 现实中只能等协调者恢复读取决策日志,或人工介入
}
运行输出:
===== 2PC 阻塞问题复现 =====
[账户服务] 准备成功: 冻结 200 (余额 500 → 可用 300),投 YES
[库存服务] 准备成功: 冻结 10 (余额 100 → 可用 90),投 YES
[系统] ⚠ 协调者在决策前宕机,恢复时间未知
[账户服务] 已冻结 200,但不知该提交还是回滚
[库存服务] 已冻结 10,但不知该提交还是回滚
[后果] 冻结期间,账户可用余额与库存被持续锁定:
任何尝试动用这笔资金/库存的新事务都将失败或排队
账户服务状态: PREPARED (frozen=200, balance=500)
3PC 试图用「预提交」(PreCommit)状态和超时机制解决这个问题:参与者等待协调者超时后,若处于 PreCommit 状态可自行提交。但 3PC 在网络分区下仍可能脑裂(分区一侧超时提交、另一侧超时回滚),且多一轮交互的代价让它的实际采用率极低——工业界普遍跳过 3PC,直接用 Paxos/Raft 复制协调者日志(如 Google Percolator 的事务协调者高可用),或改用 TCC/Saga 这类业务层方案。
三、完整实现二:TCC 模式
TCC(Try-Confirm-Cancel)把 2PC 的资源锁定从数据库层提升到业务层:Try 阶段做业务校验与资源预留,Confirm 阶段真正生效,Cancel 阶段释放预留。与 2PC 的本质区别:没有全局协调者持有全局锁,每个分支独立提交本地事务,靠事务日志与重试保证最终一致。
3.1 三个阶段的业务接口实现
// BalanceService 账户服务的 TCC 实现
type BalanceService struct {
mu sync.Mutex
balance int
frozen map[string]int // 事务ID → 冻结金额(幂等键)
done map[string]string // 事务ID → CONFIRMED/CANCELED
}
func NewBalanceService(balance int) *BalanceService {
return &BalanceService{
balance: balance,
frozen: map[string]int{},
done: map[string]string{},
}
}
// Try 预留资源:冻结金额(幂等:同一事务重复 Try 不重复冻结)
func (s *BalanceService) Try(xid string, amount int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.frozen[xid]; ok {
return nil // 幂等:已处理过的 Try 直接成功
}
// 悬挂检查:若该事务已 Confirm/Cancel,迟到的 Try 必须拒绝
if _, finished := s.done[xid]; finished {
return fmt.Errorf("拒绝悬挂 Try: 事务 %s 已结束", xid)
}
if s.balance < amount {
return fmt.Errorf("余额不足")
}
s.balance -= amount // Try 阶段即扣减可用余额
s.frozen[xid] = amount
fmt.Printf("[账户] Try(%s): 冻结 %d,可用余额 %d\n", xid, amount, s.balance)
return nil
}
// Confirm 生效:清除冻结(幂等 + 允许空确认)
func (s *BalanceService) Confirm(xid string) {
s.mu.Lock()
defer s.mu.Unlock()
amount, ok := s.frozen[xid]
if !ok {
s.done[xid] = "CONFIRMED"
return // 空确认:Try 未执行过也返回成功(幂等)
}
delete(s.frozen, xid)
s.done[xid] = "CONFIRMED"
fmt.Printf("[账户] Confirm(%s): 冻结金额 %d 转为实扣\n", xid, amount)
}
// Cancel 回滚:解冻(幂等 + 空回滚防护)
func (s *BalanceService) Cancel(xid string) {
s.mu.Lock()
defer s.mu.Unlock()
amount, ok := s.frozen[xid]
if !ok {
// 空回滚:Try 的冻结从未到达(网络丢失),
// 仍要登记 done,防止之后迟到的 Try 造成悬挂
s.done[xid] = "CANCELED"
return
}
delete(s.frozen, xid)
s.balance += amount // 释放回可用余额
s.done[xid] = "CANCELED"
fmt.Printf("[账户] Cancel(%s): 解冻 %d,可用余额恢复为 %d\n",
xid, amount, s.balance)
}
3.2 TCC 事务协调与三大经典问题的解法
// TccTransaction 事务管理器
type TccTransaction struct {
xid string
branches []*TccBranch
}
type TccBranch struct {
name string
tryFn func() error
confirm func()
cancel func()
}
// Execute 先跑所有 Try,全部成功才 Confirm,任一失败则 Cancel 已成功的分支
func (t *TccTransaction) Execute() bool {
tried := []*TccBranch{}
for _, b := range t.branches {
if err := b.tryFn(); err != nil {
fmt.Printf("[TCC %s] Try 失败: %v → 回滚 %d 个已完成分支\n",
b.name, err, len(tried))
for _, tb := range tried {
tb.cancel() // 只回滚 Try 成功的分支
}
return false
}
tried = append(tried, b)
}
for _, b := range t.branches {
b.confirm() // 生产中应配合事务日志 + 异步重试保证送达
}
return true
}
// 三大经典问题与本文代码中的对应解法:
// 1. 幂等: Try/Confirm/Cancel 都可能被事务管理器重试,
// 每个方法内部用 xid 做幂等键,重复调用结果不变。
// 2. 空回滚: Try 因网络丢失未执行,协调者照常发 Cancel,
// Cancel 必须成功并登记 done(否则协调者无限重试)。
// 3. 悬挂: 空回滚登记 done 后,迟到的 Try 到达时发现事务已结束,
// 直接拒绝——否则冻结的资源永远不会被释放。
四、完整实现三:Saga 长事务与补偿引擎
Saga 把长事务拆成一串本地事务 T₁, T₂, ..., Tₙ,每个 Tᵢ 配一个补偿操作 Cᵢ。任意一步失败,逆序执行已成功步骤的补偿。它不追求隔离性(中间态对外可见),换取的是无全局锁、适合跨企业边界的长时间事务(机票+酒店+租车的行程预订是教科书案例):
// SagaStep 一步本地事务及其补偿
type SagaStep struct {
Name string
Action func() error
Compensate func() error
}
// SagaOrchestrator 编排式 Saga 引擎
type SagaOrchestrator struct {
steps []SagaStep
}
// Execute 顺序执行,失败时逆序补偿
func (s *SagaOrchestrator) Execute() bool {
done := []SagaStep{}
for i, step := range s.steps {
fmt.Printf("[Saga] 步骤 %d: %s ...\n", i+1, step.Name)
if err := step.Action(); err != nil {
fmt.Printf("[Saga] 步骤 %d 失败: %v,启动补偿\n", i+1, err)
// 逆序补偿已完成的步骤
for j := len(done) - 1; j >= 0; j-- {
fmt.Printf("[Saga] 补偿: %s\n", done[j].Name)
if cerr := done[j].Compensate(); cerr != nil {
// 补偿失败:生产中进死信队列 + 告警,人工兜底
fmt.Printf("[Saga] ⚠ 补偿失败: %v(需人工介入)\n", cerr)
}
}
return false
}
done = append(done, step)
}
return true
}
// 行程预订示例:机票成功、酒店失败 → 自动退票
trip := &SagaOrchestrator{steps: []SagaStep{
{Name: "预订机票",
Action: bookFlight,
Compensate: cancelFlight}, // 退票(可能产生手续费)
{Name: "预订酒店",
Action: bookHotel,
Compensate: cancelHotel},
{Name: "预订租车",
Action: bookCar,
Compensate: cancelCar},
}}
Saga 的隔离性缺陷需要业务设计弥补,常用三种对策:语义锁(订单状态机增加「处理中」状态,防止其他事务读写半成品数据)、可交换更新(把加减操作设计为可任意顺序执行)、版本文件(关键操作前检查数据版本,与乐观锁同源)。
五、第四种方案:本地消息表与事务消息
互联网场景最常用的最终一致性方案,思路是把「发消息」与「本地事务」绑定:
-- 本地消息表:业务表与消息表在同一个本地事务中写入
BEGIN;
UPDATE account SET balance = balance - 100 WHERE user_id = 1;
INSERT INTO local_message(xid, topic, payload, status)
VALUES ('txn-001', 'points.add', '{"user":1,"points":10}', 'PENDING');
COMMIT;
-- 后台任务轮询 PENDING 消息投递到 MQ,成功后标记 SENT;
-- 消费方幂等消费,失败则 MQ 自动重投。
-- RocketMQ 的事务消息更进一步:先发 half 消息(对消费者不可见),
-- 本地事务提交后 half 消息才转为可见,从协议层消灭了"发消息与本地事务"的时序窗口。
六、方案对照与选型决策
| 方案 | 一致性 | 隔离性 | 锁粒度/时长 | 性能 | 实现成本 | 适用场景 |
|---|---|---|---|---|---|---|
| 2PC / XA | 强一致 | 强(资源锁定) | 全局资源锁,整个事务期 | 低(同步阻塞) | 低(依赖 XA 数据源) | 同机房、短事务、遗留系统 |
| TCC | 最终一致(重试保证) | 业务自定义 | 业务资源预留,仅 Try 期间 | 高 | 高(三接口 + 幂等) | 资金、库存等核心链路 |
| Saga | 最终一致(补偿保证) | 无(中间态可见) | 无锁 | 高 | 中(补偿逻辑) | 长流程、跨企业、可补偿业务 |
| 本地消息表 | 最终一致 | 无 | 仅本地事务锁 | 高 | 低~中 | 通知类、积分类异步链路 |
选型口诀:核心资金用 TCC,长流程用 Saga,异步通知用消息表,同机房强一致且能接受性能损耗才上 XA。真实系统通常是组合:下单主链路 TCC 保证资金一致性,积分与通知走消息表异步达成。
七、常见误区
- 误区一:分布式事务保证 ACID。除 XA 外,所有互联网方案都只保证最终一致,隔离性需要业务自行设计(语义锁、状态机)。
- 误区二:TCC 的 Confirm 一定不会失败。它会失败(网络、宕机),因此必须配合持久化事务日志与无限重试机制,以及人工对账兜底。
- 误区三:Saga 的补偿等于回滚。补偿是正向业务操作(退票不是「撤销出票」,手续费照收),补偿本身也可能失败。
- 误区四:有 MQ 就不需要幂等。所有重试机制(协调者重试、MQ 重投、定时对账)都要求消费端幂等,幂等键设计是分布式事务的第一课。
八、总结
本文的三套完整实现覆盖了分布式事务的全部主流技术路线:2PC 的代码让我们看清了协调者单点与阻塞问题的精确发生位置;TCC 的幂等键、空回滚与悬挂防护是生产可用的标准模式;Saga 补偿引擎则展示了放弃隔离性换取可用性的务实哲学。分布式事务没有银弹,只有「在哪个环节容忍多大程度的不一致」的业务权衡——理解每个方案在故障窗口里的真实行为,比记住协议步骤重要得多。
参考文献
- P. Helland. Life beyond Distributed Transactions: an Apostate's Opinion. CIDR, 2007.
- H. Garcia-Molina, K. Salem. Sagas. SIGMOD, 1987.
- D. Peng, F. Dabek. Large-scale Incremental Processing Using Distributed Transactions and Notifications (Percolator). OSDI, 2010.
- Seata 官方文档. Distributed Transaction Solution: AT/TCC/Saga/XA Modes.