“失败就把前面的步骤反向调用一遍”错在哪
用户下单后,编排器依次执行:创建订单、预占库存、扣支付账户、发放优惠券。第四步因为券服务超时失败,工程师写下“调用前面三步的 undo”:取消订单、释放库存、退款。
这个直觉在单库事务里成立,因为回滚是数据库提供的物理逆操作。跨服务后,每个 undo 都是一次新的业务请求:退款可能被支付渠道拒绝,释放库存可能因为版本冲突失败,取消订单可能撞上用户已经发货。更麻烦的是,补偿请求本身也会超时,重试又可能重复退款。Saga 把“回滚”换成了“补偿”,补偿不是撤销,而是另一个需要被正确设计和验证的状态迁移。
Saga 的原始论文把它描述为长活事务(long-lived transaction):每个步骤有对应的补偿动作,失败时按相反顺序执行。它提供原子性的结果——要么全部正向完成,要么通过补偿回到业务上可接受的状态——但不提供隔离性。在补偿完成之前,其他请求可以看到订单已创建、库存已预留、支付已扣款的中间态。这个可见性既是 Saga 的成本,也是本节要处理的第一个工程问题。
站内 Saga 分布式事务 已经介绍过编排与协同两种组织方式,这里不再重复定义,而是聚焦三件事:补偿必须满足什么条件、语义锁如何阻止并发误读、哪些步骤根本不能被补偿。
Saga 保证什么,不保证什么
用 ACID 去套 Saga 会得到错误结论。Saga 通常被概括为 ACD:
- Atomicity:所有正向步骤完成,或者所有已完成步骤都得到补偿。注意是“补偿”,不是“物理回滚”。
- Consistency:每个步骤自身把数据从一个合法状态带到另一个合法状态,补偿也一样。
- Durability:每个步骤和补偿都要落盘,编排器重启后能继续。
缺的是 Isolation。订单在“库存已预留、支付已扣款、券还没发”的窗口里,对查询接口是可见的。如果另一个请求看到库存预留就认为订单已成立,或者客服看到已扣款就手动发货,业务就会出现补偿也修不回来的副作用。解决隔离不能靠 Saga 协议本身,只能靠业务层的语义锁、状态机和查询约定。
另一个容易忽略的点是补偿的方向。正向步骤是“创建订单 → 预占库存 → 扣款 → 发券”,补偿必须逆序:“退券 → 退款 → 释放库存 → 取消订单”。顺序错了会违反依赖:先取消订单再退款,退款逻辑可能找不到订单;先释放库存再退款,库存可能被别的订单占用,退款却已经发生。编排器必须显式记录已完成步骤的有序列表,而不是依赖步骤名字排序。
补偿必须满足的四个条件
第一是幂等。补偿请求可能因为超时被重试,也可能在编排器重启后重放。退款接口必须用退款单号做幂等键,重复请求返回同一结果,而不是再退一次。
第二是可重试。补偿失败不能简单记为“失败结束”,否则已完成的正向步骤永远暴露在外。编排器要按退避策略重试,并在超过上限后进入 stuck 状态,交给人工或对账任务。
第三是语义正确。补偿不是把字段改回旧值,而是产生一笔新的业务记录:退款单、库存释放单、订单取消事件。审计和对账依赖这些记录,直接 UPDATE balance = balance + amount 会丢失“为什么加”的依据。
第四是可观测。每次补偿要有独立的事务号、状态和失败原因。否则线上只能看到“Saga 失败了”,不知道是退款没成功还是库存没释放,无法判断该重试还是该人工介入。
这四个条件里,幂等和可重试是协议要求,语义正确和可观测是业务要求。缺任何一条,Saga 都会从“最终一致”退化成“最终有窟窿”。
语义锁:让中间态不被别的请求误读
Saga 没有隔离性,但业务不能假装没有并发。语义锁(semantic lock)是在聚合上写一个“正在处理”的标记,让其他命令在读到中间态时做出正确选择:拒绝、排队,或者只读等待。
以订单为例,创建订单时写入 status = CREATING 和 saga_id。库存预留成功后,订单仍是 CREATING;支付扣款成功后,状态才推进到 PAID。在这期间:
- 用户再次提交同一笔订单,幂等键命中,返回“处理中”,不重复下单。
- 客服尝试取消订单,发现
CREATING,拒绝并提示等待,而不是直接取消导致 Saga 补偿与人工取消互相打架。 - 库存服务看到预占记录,知道它属于某个未完成的 Saga,超时后由对账任务决定释放还是继续等待。
语义锁的关键是锁的所有者与版本。释放时必须校验 saga_id,不能让一个已经超时的新 Saga 释放旧 Saga 的锁。进程内内存锁只能用于演示;生产环境要把锁写成带租约的持久记录,否则编排器一重启,锁就消失,中间态失去保护。下一章讲分布式锁时会展开租约与 fencing,本节只需记住:语义锁是业务状态的一部分,不是缓存里的临时键。
不可补偿步骤:识别、前移、改为可重试
真正的难点不是写补偿,而是承认有些步骤补偿不了。发短信、发邮件、调用外部结算、给用户账户加积分、打印小票,这些动作一旦发生,无法用另一个请求“撤销”。它们被称为不可补偿步骤,或者更准确地说,是不可逆步骤。
处理方式有三种,按优先级排列:
- 前移到所有可补偿步骤之后。先完成订单、库存、支付这些可补偿步骤,最后再发券、发短信。前面的步骤失败时可以补偿;最后一步失败时,前面的业务已经成立,只需要重试最后一步或进入人工处理,不需要撤销全局。
- 改为可重试且幂等。如果不可逆步骤必须存在,就让它能被安全重试:用业务唯一键去重,失败只记录待重试,不触发全局补偿。
- 设置 pivot 点。在不可逆步骤之前设置一个“业务承诺点”,pivot 之前失败可以整体补偿,pivot 之后失败只能向前修复。编排器要把 pivot 写进状态机,不能靠代码注释约定。
| 步骤 | 可补偿性 | 失败后的正确动作 | 放置建议 |
|---|---|---|---|
| 创建订单 | 可补偿(取消) | 逆序取消 | 任意位置 |
| 预占库存 | 可补偿(释放) | 释放预留 | pivot 之前 |
| 扣支付账户 | 可补偿(退款) | 生成退款单 | pivot 之前 |
| 发放优惠券 | 通常不可逆 | 重试或人工回收 | 最后 |
| 发送短信/邮件 | 不可逆 | 重试,不补偿 | 最后 |
| 外部渠道结算 | 不可逆 | 对账修复 | pivot 之后 |
校验规则可以写成代码:一旦出现不可补偿步骤,它后面不能再有可补偿步骤;不可补偿步骤本身必须标记为可重试。下面的实现把这条规则放进 Validate,并在运行失败时逆序补偿、记录哪些补偿成功、哪些把 Saga 留在了 stuck 状态。
可运行实现
Orchestrator 顺序执行步骤,可重试步骤在失败时按 Attempts 重试;任一步失败后,按已完成步骤的逆序调用补偿。Locks 提供按聚合 ID 的语义锁,释放时校验持有者。Validate 拒绝“不可补偿步骤之后还有可补偿步骤”和“不可补偿步骤不可重试”这两种结构。
// file: internal/platform/txn/saga/saga.go
// tier: L1
// verify: go test ./internal/platform/txn/saga/ -count=1
package saga
import (
"context"
"errors"
"fmt"
"sync"
)
// ErrLocked is returned when another transaction holds the aggregate lock.
var ErrLocked = errors.New("saga: aggregate locked")
// Action is one forward or compensation step.
type Action func(context.Context) error
// Step is one saga step. A nil Compensate marks an irreversible step.
type Step struct {
Name string
Do Action
Compensate Action
Retryable bool
}
// State is the terminal state of a saga run.
type State string
const (
StateCompleted State = "completed"
StateCompensated State = "compensated"
StateStuck State = "stuck"
)
// Result reports what happened to each step.
type Result struct {
State State
Completed []string
Compensated []string
FailedStep string
Err error
}
// Locks is an in-memory semantic lock table keyed by aggregate ID.
type Locks struct {
mu sync.Mutex
held map[string]string
}
// NewLocks creates an empty lock table.
func NewLocks() *Locks { return &Locks{held: make(map[string]string)} }
// Acquire marks the aggregate as being modified by txID.
func (l *Locks) Acquire(aggregateID, txID string) error {
l.mu.Lock()
defer l.mu.Unlock()
if owner, ok := l.held[aggregateID]; ok && owner != txID {
return fmt.Errorf("saga: acquire %s: %w", aggregateID, ErrLocked)
}
l.held[aggregateID] = txID
return nil
}
// Release removes the lock only if txID still owns it.
func (l *Locks) Release(aggregateID, txID string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.held[aggregateID] == txID {
delete(l.held, aggregateID)
}
}
// Held reports the owner of an aggregate lock.
func (l *Locks) Held(aggregateID string) (string, bool) {
l.mu.Lock()
defer l.mu.Unlock()
owner, ok := l.held[aggregateID]
return owner, ok
}
// Validate enforces the structural rule: after an irreversible step no
// compensable step may follow, and the irreversible step must be retryable
// because there is no undo to fall back on.
func Validate(steps []Step) error {
seen := make(map[string]bool, len(steps))
irreversible := false
for i, s := range steps {
if s.Name == "" {
return fmt.Errorf("saga: step %d has empty name", i)
}
if seen[s.Name] {
return fmt.Errorf("saga: duplicate step %q", s.Name)
}
seen[s.Name] = true
if s.Do == nil {
return fmt.Errorf("saga: step %q has no action", s.Name)
}
if s.Compensate == nil {
irreversible = true
if !s.Retryable {
return fmt.Errorf("saga: irreversible step %q must be retryable", s.Name)
}
} else if irreversible {
return fmt.Errorf("saga: compensable step %q follows irreversible step", s.Name)
}
}
return nil
}
// Orchestrator runs steps sequentially, retries retryable steps, and
// compensates completed steps in reverse order on failure.
type Orchestrator struct {
Steps []Step
Locks *Locks
Attempts int
}
// Run executes a saga for aggregateID using txID as the lock owner.
func (o *Orchestrator) Run(ctx context.Context, aggregateID, txID string) Result {
if o.Locks != nil {
if err := o.Locks.Acquire(aggregateID, txID); err != nil {
return Result{State: StateStuck, Err: err}
}
defer o.Locks.Release(aggregateID, txID)
}
res := Result{State: StateCompleted}
for _, s := range o.Steps {
if err := o.runStep(ctx, s); err != nil {
res.FailedStep = s.Name
res.Err = err
res.Compensated = o.compensate(ctx, res.Completed)
res.State = StateStuck
if len(res.Compensated) == len(res.Completed) {
res.State = StateCompensated
}
return res
}
res.Completed = append(res.Completed, s.Name)
}
return res
}
func (o *Orchestrator) runStep(ctx context.Context, s Step) error {
attempts := 1
if s.Retryable && o.Attempts > 1 {
attempts = o.Attempts
}
var err error
for i := 0; i < attempts; i++ {
if err = s.Do(ctx); err == nil {
return nil
}
}
return err
}
func (o *Orchestrator) compensate(ctx context.Context, completed []string) []string {
done := make([]string, 0, len(completed))
for i := len(completed) - 1; i >= 0; i-- {
name := completed[i]
for _, s := range o.Steps {
if s.Name != name || s.Compensate == nil {
continue
}
if err := s.Compensate(ctx); err == nil {
done = append(done, name)
}
break
}
}
return done
}
这段代码刻意没有引入消息队列或数据库:补偿和锁都在内存里,便于把状态迁移讲清楚。生产实现需要把 Result、步骤进度和锁租约持久化,否则编排器崩溃后无法从上次完成的步骤继续。
实测输出与读数
在本机 macOS darwin/arm64、Go 1.26.0 下执行:
$ go test ./internal/platform/txn/saga/ -count=1 -v
=== RUN TestSagaHappyPathReleasesLock
--- PASS: TestSagaHappyPathReleasesLock (0.00s)
=== RUN TestFailureCompensatesInReverseOrder
--- PASS: TestFailureCompensatesInReverseOrder (0.00s)
=== RUN TestFailedCompensationLeavesSagaStuck
--- PASS: TestFailedCompensationLeavesSagaStuck (0.00s)
=== RUN TestRetryableStepIsRetried
--- PASS: TestRetryableStepIsRetried (0.00s)
=== RUN TestSemanticLockRejectsConcurrentTransaction
--- PASS: TestSemanticLockRejectsConcurrentTransaction (0.00s)
=== RUN TestValidateRejectsUnsafeStepOrder
=== RUN TestValidateRejectsUnsafeStepOrder/compensable_then_irreversible
=== RUN TestValidateRejectsUnsafeStepOrder/compensable_after_irreversible
=== RUN TestValidateRejectsUnsafeStepOrder/irreversible_not_retryable
=== RUN TestValidateRejectsUnsafeStepOrder/duplicate_names
=== RUN TestValidateRejectsUnsafeStepOrder/empty_name
--- PASS: TestValidateRejectsUnsafeStepOrder (0.00s)
--- PASS: TestValidateRejectsUnsafeStepOrder/compensable_then_irreversible (0.00s)
--- PASS: TestValidateRejectsUnsafeStepOrder/compensable_after_irreversible (0.00s)
--- PASS: TestValidateRejectsUnsafeStepOrder/irreversible_not_retryable (0.00s)
--- PASS: TestValidateRejectsUnsafeStepOrder/duplicate_names (0.00s)
--- PASS: TestValidateRejectsUnsafeStepOrder/empty_name (0.00s)
PASS
ok archlab/internal/platform/txn/saga 1.491s
TestFailureCompensatesInReverseOrder 的记录顺序是 do-order、do-inventory、do-payment、undo-inventory、undo-order:支付失败后先释放库存,再取消订单,与正向顺序相反。TestFailedCompensationLeavesSagaStuck 让库存补偿失败,结果只补偿成功订单一步,状态是 stuck 而不是 compensated;这正是需要人工介入的边界,不能假装 Saga 已经回滚。TestSemanticLockRejectsConcurrentTransaction 验证同一订单的第二个事务在锁释放前被拒绝,释放后才能继续。
测试耗时只代表这些内存用例的执行时间,不代表生产补偿的延迟;真实退款、库存释放的耗时要看下游服务和对账窗口。
编排式与协同式怎么选
编排式(orchestration)由一个 Saga 编排器显式调用每一步和补偿,状态集中,排障时能回答“现在卡在哪一步、下一步该补偿谁”。代价是编排器知道所有参与方,容易变成新的耦合中心。
协同式(choreography)由各服务监听事件自行推进和补偿,耦合低,但没有单一状态视图。一个补偿事件丢了,可能只有对账任务能发现。订单这种步骤固定、补偿关系明确的流程,编排式通常更容易验证;跨团队、步骤经常增删的流程才考虑协同式。
无论哪种方式,补偿链都必须有唯一的事务号贯穿。否则重试时无法判断“这次退款是不是上一次的重复”,也无法把分散在各服务里的日志拼成一条完整链路。
常见错误与边界
- 把补偿写成物理回滚:直接改余额或删记录会丢失审计线索,也无法处理“已经对外发货”这类不可逆副作用。补偿要生成新的业务单据。
- 补偿不幂等:补偿请求超时后重试,第二次退款没有幂等键,就会多退一笔。所有补偿接口都要用补偿单号去重。
- 失败后只补偿到第一个错误就停:一个补偿失败不代表后面的补偿不该执行。正确做法是逆序尝试全部补偿,把失败的步骤标记为 stuck,而不是提前结束。
- 语义锁只放在内存:编排器重启后锁消失,另一个请求可能读到中间态并写入冲突数据。锁必须是持久化记录,并带租约和所有者校验。
- 把不可逆步骤放在流程中间:发券、发短信之后还有可补偿步骤,一旦后面失败,前面的不可逆动作无法撤销。结构校验应该在上线前拦住这种编排。
小结
Saga 用一系列补偿动作替代数据库回滚,换来跨服务长流程的可行性,代价是失去隔离性、补偿可能失败、不可逆步骤无法撤销。工程上要同时做三件事:补偿接口幂等可重试、用语义锁保护中间态、用结构校验把不可逆步骤放到最后并保证可重试。只要其中一件缺失,Saga 就会从“最终一致”变成“最终需要人工对账”。
下一节换一种资源控制方式:不再先做再补偿,而是在 Try 阶段显式预留,Confirm 才生效,Cancel 释放。TCC 能减少补偿的不可控性,但会引入空回滚、悬挂和中间态可见性三个新问题。
阅读导航:上一节:2PC 与 3PC:准备、提交与协调者故障 · 下一节:TCC、本地消息表与事务状态的可见性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。