《后端架构设计·分布式与数据一致性》7.2 Saga:补偿、语义锁与不可补偿步骤

订单 Saga 失败时,“把前面的步骤反向调用一遍”看似简单,实际补偿是新业务动作,可能失败、可能不幂等,也挡不住并发请求读到中间态。本文用库存预留、支付扣款与发券场景,给出带语义锁、逆序补偿和不可补偿步骤校验的可运行 Go 实现,并列出把不可逆动作前移或改为可重试的判据。

“失败就把前面的步骤反向调用一遍”错在哪

用户下单后,编排器依次执行:创建订单、预占库存、扣支付账户、发放优惠券。第四步因为券服务超时失败,工程师写下“调用前面三步的 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,本节只需记住:语义锁是业务状态的一部分,不是缓存里的临时键。

不可补偿步骤:识别、前移、改为可重试

真正的难点不是写补偿,而是承认有些步骤补偿不了。发短信、发邮件、调用外部结算、给用户账户加积分、打印小票,这些动作一旦发生,无法用另一个请求“撤销”。它们被称为不可补偿步骤,或者更准确地说,是不可逆步骤。

处理方式有三种,按优先级排列:

  1. 前移到所有可补偿步骤之后。先完成订单、库存、支付这些可补偿步骤,最后再发券、发短信。前面的步骤失败时可以补偿;最后一步失败时,前面的业务已经成立,只需要重试最后一步或进入人工处理,不需要撤销全局。
  2. 改为可重试且幂等。如果不可逆步骤必须存在,就让它能被安全重试:用业务唯一键去重,失败只记录待重试,不触发全局补偿。
  3. 设置 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、本地消息表与事务状态的可见性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查