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 支付 | 调用支付网关 | 不可补偿:只能发起退款(冲正) |
三条关于补偿的硬规则:
- **补偿是语义逆操作,不是物理回滚。**短信发出去了收不回,只能补发一条道歉;钱到账了不能 “删”,只能反向记账。
- **补偿必须幂等。**补偿同样会超时重试,重复退款就是资损。
- **补偿可能失败,必须能重试 + 进死信 + 告警。**补偿失败不是 “回滚失败”,是 “需要人介入”。
⚠️ 逆序不是形式主义: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)
);
恢复逻辑只有三步:
- 启动时扫
status IN ('RUNNING','COMPENSATING')的实例; RUNNING从current_step继续向后执行;COMPENSATING继续逆序补偿;- 每一步执行前写
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 只能降低概率。