24.5 Saga (编排 / 协同 / 补偿)

一句话

Saga 把一个长事务拆成 N 个本地事务,每一步配一个补偿;失败时逆序补偿已完成的步骤。它买到的是 “不长持锁、可用性高”,付出的是隔离性。

为什么需要 Saga:长事务的锁成本

2PC 在 prepare 之后要一直持有资源锁,直到 commit。跨服务下单链路里,这个窗口 = 所有参与方的 RT + 网络抖动 + 重试,期间库存行、账户行全被锁住。链路越长、参与方越多,锁越久,吞吐越低。

Saga 1987 年由 Garcia-Molina 和 Salem 提出,针对的正是长活事务 (long-lived transaction,LLT):与其锁着等,不如先做、错了再补。

维度 2PC Saga
锁的范围 整个全局事务 每一步的本地事务(毫秒级)
失败处理 数据库回滚 逆序执行补偿
中间态 不可见 对外可见
长链路适应性 差(锁越久越容易超时、死锁) 好

🔑 Saga 的核心交易:用 “可补偿” 换 “不锁”。前提是每一步都能被补偿——做不到这一点的业务,Saga 不成立。

结构:N 个本地事务 + N 个补偿

T1 ──▶ T2 ──▶ T3 ──▶ T4
 │      │      │      ✗ 失败
 ◀──────◀──────◀──────  C3、C2、C1 逆序补偿
步骤 正常动作 补偿动作
T1 创建订单 订单落库,状态待支付 订单置为 CANCELLED(不是 DELETE)
T2 扣优惠券 券置为已使用 券退回可用,写一条退回流水
T3 扣库存 库存 −1 库存 +1,写一条回补流水
T4 支付 调用支付网关 不可补偿:只能发起退款(冲正)

三条关于补偿的硬规则:

  1. **补偿是语义逆操作,不是物理回滚。**短信发出去了收不回,只能补发一条道歉;钱到账了不能 “删”,只能反向记账。
  2. **补偿必须幂等。**补偿同样会超时重试,重复退款就是资损。
  3. **补偿可能失败,必须能重试 + 进死信 + 告警。**补偿失败不是 “回滚失败”,是 “需要人介入”。

⚠️ 逆序不是形式主义:T3 依赖 T2 的券、T2 依赖 T1 的订单。正序补偿会先去动还有下游引用的资源,制造新的不一致。只有互不依赖的步骤才能并行补偿。

两种恢复方向

方式 做法 前提 典型场景
向后恢复(backward) 逆序补偿已完成的步骤 每步都有补偿 下单、预订、开通资源
向前恢复(forward) 重试失败步骤直到成功 步骤幂等且最终会成功 支付、扣款、必须完成的动作

现实里通常是混合:先对失败步骤重试 2–3 次 (应对瞬时故障),仍失败再向后补偿。

💡 判断标准:这一步失败后,“重试能成功” 还是 “必须撤销”?能重试的别急着回滚——回滚也是有成本的 (退款手续费、用户感知)。

两种组织方式:编排 vs 协同

编排 (Orchestration)

一个中心协调器 (状态机) 按顺序调用各服务,并记录每一步的状态。

        ┌──────────────┐
        │ Orchestrator │  ← 状态机 + 持久化
        └──┬───┬───┬───┘
           ▼   ▼   ▼
         订单  库存  支付

典型实现:Temporal / Cadence、AWS Step Functions、Azure Durable Functions、Seata Saga、Netflix Conductor、Camunda。

协同 (Choreography)

没有中心,各服务监听事件、自己决定下一步,失败时发布补偿事件。

订单服务 ──OrderCreated──▶ 库存服务 ──StockReserved──▶ 支付服务
    ▲                          │                        │
    └────OrderCancelled◀──StockReleased◀──PaymentFailed─┘

典型实现:Kafka / RabbitMQ / Pulsar + 事件契约。

怎么选

维度 编排 协同
流程定义在哪 一处(状态机 / 代码) 分散在各服务的订阅关系里
服务间耦合 低(服务不认识彼此,只认识协调器) 高(发布方要知道谁会听)
可观测性 强:一个 saga_id 串起全部步骤 弱:要靠 trace 拼出隐式流程
流程变更 改状态机一处 改多个服务 + 事件契约
单点风险 协调器挂了流程停(需高可用 + 持久化) 无单点,但排障难
适合 ≥ 4 步、有分支 / 超时 / 补偿 2–3 步、团队自治、事件天然广播

🔑 **默认选编排。**协同省下的那点耦合,会在第一次排障时连本带利还回去——“这笔单为什么卡住了” 在纯事件链里没有单一答案。

缺隔离性:Saga 的硬伤

Saga 没有 2PC 的隔离性,中间状态对外可见。用户可能看到 “已扣款、未发货”,另一个请求可能基于中间态做出错误判断 (重复下单、超卖)。

业界归纳了五种缓解手段:

手段 做法 代价
语义锁 记录 status=PENDING,别人读到就等待或拒绝 业务要支持中间状态
交换式更新 把操作设计成可交换(增减而非覆盖),顺序无关 对建模要求高
悲观视图 把不可逆 / 高风险的步骤排到最后 流程变长,前段白做
重读值 写之前重读并校验余额 / 版本 多一次读,仍不是强隔离
版本文件 记录操作版本,事后批量检测并修复 只能事后补救

⚠️ 这五种都是降低概率,不是消除。需要强隔离的场景 (账户内部记账、实时库存扣减判定),不要用 Saga,回到单库事务或 TCC。

状态机必须持久化

Saga 的协调器不能在内存里维护进度——进程一崩,事务就丢了。两张表是最小配置:

CREATE TABLE saga_instance (
  saga_id      VARCHAR(64) PRIMARY KEY,
  saga_type    VARCHAR(64) NOT NULL,
  status       VARCHAR(16) NOT NULL,  -- RUNNING / COMPENSATING / DONE / DEAD
  current_step INT         NOT NULL,
  payload      JSON        NOT NULL,  -- 恢复时重放所需的入参
  updated_at   DATETIME    NOT NULL
);

CREATE TABLE saga_step_log (
  saga_id    VARCHAR(64) NOT NULL,
  step_name  VARCHAR(64) NOT NULL,
  status     VARCHAR(16) NOT NULL,    -- STARTED / DONE / COMPENSATED / FAILED
  retries    INT         NOT NULL DEFAULT 0,
  updated_at DATETIME    NOT NULL,
  PRIMARY KEY (saga_id, step_name)
);

恢复逻辑只有三步:

  1. 启动时扫 status IN ('RUNNING','COMPENSATING') 的实例;
  2. RUNNING 从 current_step 继续向后执行;COMPENSATING 继续逆序补偿;
  3. 每一步执行前写 STARTED、执行后写 DONE。中间崩溃,重启后按 STARTED 重放该步——所以每步必须幂等 (幂等键用 saga_id + step_name)。

可运行的最小编排器

顺序执行、逆序补偿、补偿带重试、支持向前恢复 (Go 1.21+,go run 直接跑):

package main

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

// Step 是一步本地事务 + 它的补偿。补偿是语义逆操作,不是数据库回滚。
type Step interface {
	Name() string
	Execute() error
	Compensate() error
}

type step struct {
	name     string
	attempts int // 允许的执行重试次数:向前恢复
	do       func() error
	undo     func() error
}

func (s *step) Name() string { return s.name }

func (s *step) Execute() error {
	var err error
	for i := 1; i <= s.attempts; i++ {
		if err = s.do(); err == nil {
			return nil
		}
		fmt.Printf("   ↻ %s 第 %d 次执行失败:%v\n", s.name, i, err)
	}
	return err
}

func (s *step) Compensate() error { return s.undo() }

// Orchestrator 是最小可用的编排器:顺序执行、逆序补偿、补偿带重试。
// 真实实现要把 current_step 与已完成列表持久化,进程崩溃后从断点恢复。
type Orchestrator struct {
	steps []Step
}

func (o *Orchestrator) Run() error {
	done := make([]Step, 0, len(o.steps))
	for _, st := range o.steps {
		if err := st.Execute(); err != nil {
			fmt.Printf("✗ %s 失败:%v\n", st.Name(), err)
			fmt.Printf("→ 向后恢复:逆序补偿 %d 步\n", len(done))
			o.backward(done)
			return fmt.Errorf("saga 已回滚:%w", err)
		}
		done = append(done, st)
		fmt.Printf("✓ %s\n", st.Name())
	}
	return nil
}

func (o *Orchestrator) backward(done []Step) {
	for i := len(done) - 1; i >= 0; i-- {
		st := done[i]
		if err := retry(3, st.Compensate); err != nil {
			fmt.Printf("⚠ %s 补偿最终失败:%v → 写死信 + 告警\n", st.Name(), err)
			continue
		}
		fmt.Printf("↩ %s 已补偿\n", st.Name())
	}
}

func retry(n int, f func() error) error {
	var err error
	for i := 1; i <= n; i++ {
		if err = f(); err == nil {
			return nil
		}
		fmt.Printf("   ↳ 补偿第 %d 次失败:%v\n", i, err)
	}
	return err
}

// booking 模拟下游资源,补偿做成幂等:重复释放不会报错。
type booking struct {
	mu     sync.Mutex
	orders []string
}

func (b *booking) reserve(item string) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.orders = append(b.orders, item)
	return nil
}

func (b *booking) release(item string) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	for i := len(b.orders) - 1; i >= 0; i-- {
		if b.orders[i] == item {
			b.orders = append(b.orders[:i], b.orders[i+1:]...)
			return nil
		}
	}
	return nil // 已经补偿过,幂等
}

func (b *booking) snapshot() []string {
	b.mu.Lock()
	defer b.mu.Unlock()
	out := make([]string, len(b.orders))
	copy(out, b.orders)
	return out
}

func main() {
	b := &booking{}

	// 场景 1:第 3 步失败 → 逆序补偿前两步
	s1 := &Orchestrator{steps: []Step{
		&step{name: "预订机票", attempts: 2,
			do:   func() error { return b.reserve("flight") },
			undo: func() error { return b.release("flight") }},
		&step{name: "预订酒店", attempts: 2,
			do:   func() error { return b.reserve("hotel") },
			undo: func() error { return b.release("hotel") }},
		&step{name: "租车", attempts: 1,
			do:   func() error { return errors.New("车辆已租完") },
			undo: func() error { return nil }},
	}}
	fmt.Println("场景 1:第 3 步失败,逆序补偿")
	fmt.Println("结果:", s1.Run())
	fmt.Println("剩余预订:", b.snapshot())

	// 场景 2:补偿自己也失败,靠重试兜底
	attempt := 0
	s2 := &Orchestrator{steps: []Step{
		&step{name: "创建订单", attempts: 1,
			do: func() error { return nil },
			undo: func() error {
				attempt++
				if attempt <= 2 {
					return errors.New("订单库主从延迟")
				}
				return nil
			}},
		&step{name: "核销优惠券", attempts: 1,
			do:   func() error { return errors.New("券已被核销") },
			undo: func() error { return nil }},
	}}
	fmt.Println("\n场景 2:补偿失败要重试")
	fmt.Println("结果:", s2.Run())

	// 场景 3:瞬时失败,向前恢复(重试执行)而不是回滚
	try := 0
	s3 := &Orchestrator{steps: []Step{
		&step{name: "支付", attempts: 3,
			do: func() error {
				try++
				if try < 3 {
					return errors.New("支付网关 503")
				}
				return nil
			},
			undo: func() error { return nil }},
	}}
	fmt.Println("\n场景 3:向前恢复")
	fmt.Println("结果:", s3.Run())
}

输出:

场景 1:第 3 步失败,逆序补偿
✓ 预订机票
✓ 预订酒店
   ↻ 租车 第 1 次执行失败:车辆已租完
✗ 租车 失败:车辆已租完
→ 向后恢复:逆序补偿 2 步
↩ 预订酒店 已补偿
↩ 预订机票 已补偿
结果: saga 已回滚:车辆已租完
剩余预订: []

场景 2:补偿失败要重试
✓ 创建订单
   ↻ 核销优惠券 第 1 次执行失败:券已被核销
✗ 核销优惠券 失败:券已被核销
→ 向后恢复:逆序补偿 1 步
   ↳ 补偿第 1 次失败:订单库主从延迟
   ↳ 补偿第 2 次失败:订单库主从延迟
↩ 创建订单 已补偿
结果: saga 已回滚:券已被核销

场景 3:向前恢复
   ↻ 支付 第 1 次执行失败:支付网关 503
   ↻ 支付 第 2 次执行失败:支付网关 503
✓ 支付
结果: <nil>

看三处:场景 1 补偿顺序是酒店 → 机票 (逆序);场景 2 第三次重试才成功,前两次失败不影响最终收敛;场景 3 走的是向前恢复,没有回滚。

成本,可以数出来

  • 步骤数:n 步的 Saga,最坏要执行 n−1 次补偿,加上失败那步本身,总调用次数 2n−1。
  • 持久化:每步至少 2 次写 (STARTED / DONE),n 步就是 2n 次;这些写必须和业务操作同库,否则恢复时对不上。
  • 延迟:串行 Saga 的总耗时 = Σ 每步 RT。把无依赖步骤并行化能降低总耗时,但补偿仍受依赖约束。
  • 重试预算:补偿重试 3–5 次、指数退避 (1s/2s/4s…),超过进死信。不要无限重试——死信 + 告警比悄悄卡住好。

与其它方案的定位

维度 2PC / XA TCC Saga
隔离性 强 中(预留,中间态可见) 无(中间态可见且可能被读到)
业务侵入 无 高(每分支 3 个接口) 中(每步一个补偿)
资源锁定 数据库锁,全程 冻结窗口内不可用 仅本地事务瞬间
失败处理 数据库回滚 Cancel 释放预留 逆序补偿 / 重试
外部系统 要求支持 XA 要求能改造 能包住不可改造的第三方
适合 同库短事务 资金、库存等强约束 长流程、跨服务、含外部调用

反模式

反模式 后果
补偿写成 DELETE / 物理回滚 丢审计、丢流水;下游已消费的数据收不回
补偿不幂等 重试导致重复退款、重复加库存
状态机只放内存 进程崩溃后事务丢失,资源永久占用
对有依赖的步骤并行补偿 补偿顺序错,制造新的不一致
用 Saga 承载需要强隔离的场景 读到中间态,重复下单 / 超卖
补偿「已放款、已开票」等不可逆操作 无法真正回滚,只能人工冲正
失败步骤不做重试直接回滚 瞬时 503 触发无谓退款,成本与体验双输
没有死信与告警 补偿卡住无人知,账目长期不平

自检

Q1 有人说 “Saga 的补偿就是回滚”。错在哪?举一个补偿不可逆的例子,并说明工程上怎么处理。

▸ 答案:补偿是语义逆操作,不是数据库回滚:它产生新的业务记录 (退款、退回券、取消单),不改写历史。发短信、已放款、已开票这类动作没有逆操作——短信只能补发道歉,放款只能走退款流程 (可能收费、可能失败),开票只能红冲。工程上把这类步骤排到最后 (悲观视图),或明确标注 “不可补偿”,由对账 + 人工兜底,而不是假装它能回滚。

Q2 编排和协同,给出两个能直接落地的判断依据。

▸ 答案:① 步骤数与分支:≥ 4 步、有条件分支 / 超时 / 补偿策略差异,选编排——流程要有一处可读、可改的定义;② 排障要求:需要 “一个 saga_id 查到全部步骤状态” 就选编排;纯事件协同要靠 trace 拼隐式流程,出事后没人能说清卡在哪。反过来,2–3 步、团队强自治、事件天然一对多广播,协同更轻。默认答案是编排。

Q3 用户在下单后 2 秒看到 “已扣款、待发货”,随后又点了一次下单。这暴露了 Saga 的哪个缺陷?给三种缓解。

▸ 答案:缺隔离性——Saga 的中间态对其他事务可见。缓解:① 语义锁:订单置 PENDING 并让重复下单请求等待或拒绝;② 重读值:创建订单前重读账户状态与幂等键,避免同一用户重复提交;③ 悲观视图:把 “扣款” 排到 “创建订单 + 占用库存” 之后,先锁住业务资源再动钱。要彻底消除只能换 TCC 或单库事务,Saga 只能降低概率。