下单要扣库存、扣余额、加积分,三个操作跨三个服务——要么全成功,要么全失败。这就是分布式事务要解决的问题。本文用完整可运行的 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 保证资金一致性,积分与通知走消息表异步达成。

七、常见误区

八、总结

本文的三套完整实现覆盖了分布式事务的全部主流技术路线:2PC 的代码让我们看清了协调者单点与阻塞问题的精确发生位置;TCC 的幂等键、空回滚与悬挂防护是生产可用的标准模式;Saga 补偿引擎则展示了放弃隔离性换取可用性的务实哲学。分布式事务没有银弹,只有「在哪个环节容忍多大程度的不一致」的业务权衡——理解每个方案在故障窗口里的真实行为,比记住协议步骤重要得多。

参考文献

  1. P. Helland. Life beyond Distributed Transactions: an Apostate's Opinion. CIDR, 2007.
  2. H. Garcia-Molina, K. Salem. Sagas. SIGMOD, 1987.
  3. D. Peng, F. Dabek. Large-scale Incremental Processing Using Distributed Transactions and Notifications (Percolator). OSDI, 2010.
  4. Seata 官方文档. Distributed Transaction Solution: AT/TCC/Saga/XA Modes.
← 返回文章列表