Skip to content

Saga:长事务状态机、业务补偿与失败恢复

一、学完本页要真正会什么

Saga把一个跨服务长流程拆成多个可独立提交的本地事务。每一步一旦提交,后续失败时不是数据库物理回滚,而是执行具有业务语义的补偿动作。

学完本页,你应该能够:

  1. 区分正向事务、补偿事务、可重试步骤和Pivot步骤。
  2. 解释Saga为什么适合分钟、小时甚至更长流程,却会暴露中间状态。
  3. 比较编排式和协同式Saga的调用、恢复与可观测性。
  4. 设计Saga实例、步骤、尝试、结果和补偿日志。
  5. 解释业务步骤提交后、状态机未记成功时怎样恢复。
  6. 解释补偿响应丢失、重复补偿和补偿失败怎样处理。
  7. 说明并行步骤为什么不能简单按“最后调用先补偿”。
  8. 使用语义锁、状态机、版本号和业务唯一键降低隔离异常。
  9. 设计不可逆步骤的位置和人工接管流程。
  10. 根据sagaId、stepId、businessKey、状态和业务流水排查。

二、Saga到底解决什么

订单履约可能持续数小时:创建订单、锁库存、支付、仓库出库、物流揽收。若使用XA让数据库事务一直Prepared,连接和锁会长期占用,任何参与者故障都会拖住整个流程。

Saga允许每一步快速本地提交:

mermaid
flowchart TD
    A["T1 创建订单"] --> B["T2 预订库存"]
    B --> C["T3 创建支付"]
    C --> D["T4 创建物流单"]
    D --> E{"后续步骤是否失败"}
    E -- "否" --> F["Saga成功"]
    E -- "是" --> G["按依赖关系执行C3、C2、C1补偿"]

代价是T1、T2提交后,其他请求可能看到PROCESSINGSTOCK_RESERVED等中间状态。Saga追求的是可恢复最终一致,不是所有参与者同时不可见、同时提交。

三、补偿不是物理回滚

正向动作可能的补偿为什么不是物理回滚
创建订单将订单状态改为CANCELED原订单和审计仍保留
预订库存新增释放流水并恢复可用量预订流水不能删除
支付扣款发起退款原支付和退款是两笔业务事实
使用优惠券解锁或补发新券原券可能已经过期或规则变化
创建物流单请求取消物流已出库后可能无法完全取消
发送短信通常不可撤回只能再发更正通知

补偿必须根据“当前事实”执行,而不是盲目恢复旧快照。支付补偿前要确认渠道是否已扣款、是否已退款;物流补偿前要确认包裹是否已揽收。

四、Compensatable、Pivot与Retryable步骤

设计Saga时可以按语义分类:

  • Compensatable:在某个阶段前可以通过业务补偿撤销,例如预订库存。
  • Pivot:一旦成功,Saga跨过不可轻易返回的决策点,例如确认不可撤销的外部结算。它之后通常不再走前向步骤失败回滚,而要确保后续可重试完成。
  • Retryable:设计成最终可成功且可幂等重试的步骤,例如发送已持久化的内部事件。
mermaid
flowchart TD
    A["可补偿步骤:创建订单"] --> B["可补偿步骤:预订库存"]
    B --> C["Pivot:确认结算"]
    C --> D["可重试步骤:生成账单"]
    D --> E["可重试步骤:发送完成事件"]

不是所有业务都能清晰划分Pivot;该模型用于帮助把不可逆步骤后置。若Pivot后的步骤也可能永久失败,必须有人工完成或反向业务处理,不能只标记“重试中”。

五、编排式Saga全过程

编排器集中保存流程定义和状态,依次向参与者发命令:

mermaid
flowchart TD
    A["Saga编排器创建实例"] --> B["发送ReserveStock命令"]
    B --> C["库存服务幂等处理并返回结果"]
    C --> D["编排器持久化步骤结果"]
    D --> E["发送CreateShipment命令"]
    E --> F{"物流步骤结果"}
    F -- "成功" --> G["推进下一个状态"]
    F -- "失败" --> H["进入COMPENSATING"]
    H --> I["发送ReleaseStock补偿"]
    I --> J["补偿完成后标记COMPENSATED"]

优点:

  • 流程、超时、重试和补偿顺序集中可见。
  • 容易查询当前卡在哪一步。
  • 适合复杂分支、并行、人工节点。

风险:

  • 编排器成为重要基础设施,必须持久化和高可用。
  • 业务逻辑过度集中会形成“上帝编排器”。
  • 参与者契约和版本演进必须治理。

编排器负责流程决策,参与者仍负责自己的业务规则和本地事务。

六、协同式Saga全过程

协同式没有一个集中编排器,每个服务消费事件、完成本地事务并发布下一事件:

mermaid
flowchart TD
    A["订单服务发布OrderCreated"] --> B["库存服务预订并发布StockReserved"]
    B --> C["物流服务创建单并发布ShipmentCreated"]
    C --> D["订单服务推进完成"]
    C --> E{"物流失败事件"}
    E --> F["库存服务消费并释放库存"]
    F --> G["订单服务消费并取消订单"]

优点:服务自治、无集中同步调用、天然使用事件驱动。缺点:

  • 流程散落在多个消费者中,难看出全貌。
  • 事件环路和隐式依赖容易失控。
  • 补偿顺序和版本兼容更难治理。
  • 定位一个Saga需要聚合多服务事件。

协同式也需要某种全局可观测视图或流程投影,否则只能依赖人工搜索日志。复杂Saga通常更适合明确编排;简单线性事件链可采用协同式。

七、编排式与协同式对比

维度编排式协同式
流程控制编排器显式决定服务按事件自治
状态位置中央Saga实例/步骤表分散业务状态和事件日志
可观测性较集中需要事件追踪投影
耦合方式服务依赖命令契约服务依赖事件语义
复杂分支/并行较易表达容易形成事件网
单点风险编排器需高可用Broker和各消费者均关键
适合复杂履约、审批、长流程简单事件链、服务自治强

八、Saga实例和步骤状态机

全局状态示例:

text
RUNNING
COMPENSATING
SUCCEEDED
COMPENSATED
FAILED_MANUAL

步骤状态示例:

text
PENDING
EXECUTING
SUCCEEDED
FAILED_RETRYABLE
FAILED_FINAL
COMPENSATING
COMPENSATED
COMPENSATION_FAILED

状态只能按合法路径迁移。例如COMPENSATED不能再次回到EXECUTINGSUCCEEDED重复回调应复用结果,不重新执行副作用。

九、持久化表设计

Saga实例表:

sql
create table t_saga_instance (
    saga_id varchar(64) not null,
    saga_type varchar(64) not null,
    business_key varchar(128) not null,
    definition_version int not null,
    status varchar(32) not null,
    current_version bigint not null,
    next_retry_at datetime null,
    retry_count int not null,
    last_error_code varchar(64) null,
    last_error_message varchar(512) null,
    created_at datetime not null,
    updated_at datetime not null,
    primary key (saga_id),
    unique key uk_saga_business (saga_type, business_key),
    key idx_saga_retry (status, next_retry_at)
);

步骤表:

sql
create table t_saga_step (
    saga_id varchar(64) not null,
    step_name varchar(64) not null,
    step_order int not null,
    status varchar(32) not null,
    request_hash varchar(128) not null,
    result_payload text null,
    attempt_count int not null,
    compensation_count int not null,
    updated_at datetime not null,
    primary key (saga_id, step_name)
);

definition_version防止流程定义升级后,运行中的旧实例被新步骤顺序错误解释。business_key保证客户端重试不会创建两个Saga。

十、业务步骤提交与Saga状态记录的双写窗口

危险窗口:参与者本地事务成功,但编排器没收到响应或没记录成功。

mermaid
flowchart TD
    A["编排器发送ReserveStock stepId"] --> B["库存服务本地事务提交预订和幂等流水"]
    B --> C["响应丢失"]
    C --> D["编排器仍看到EXECUTING或超时"]
    D --> E["按同一stepId重试或查询事实"]
    E --> F["库存服务复用第一次结果,不再次预订"]

参与者必须把业务副作用、步骤幂等流水和结果放在同一本地事务。编排器不能仅凭HTTP超时判定步骤失败,更不能使用新stepId重试。

如果编排器和参与者通过消息交互,命令消费日志、业务写入和回复Outbox也应在参与者同一本地事务中。

十一、步骤契约需要包含什么

每个命令/事件至少包含:

  • sagaId。
  • stepId/stepName。
  • businessKey。
  • 流程定义版本。
  • 请求版本和请求摘要。
  • traceId作为一次尝试诊断字段。
  • 截止时间或剩余预算。

响应需要区分:

  • 明确成功及可持久化结果。
  • 永久业务失败,例如库存不足。
  • 可重试技术失败。
  • 结果未知,需要按stepId查询事实。

不要把所有异常都映射成false,否则编排器无法决定重试、补偿还是人工处理。

十二、补偿顺序不是永远简单逆序

线性依赖通常逆序补偿。但若步骤并行,补偿必须按依赖图,而不是按完成时间:

mermaid
flowchart TD
    A["创建订单"] --> B["并行预订库存"]
    A --> C["并行锁定优惠券"]
    B --> D["创建物流"]
    C --> E["生成营销权益"]
    D --> F["失败后先取消物流,再释放库存"]
    E --> G["先撤销权益,再解锁优惠券"]
    F --> H["两个分支补偿完成后取消订单"]
    G --> H

编排器需要保存依赖图和已完成节点。并行补偿还要考虑部分补偿失败,不能因为一个分支失败就重复补偿另一个已成功终态分支。

十三、补偿怎样做到幂等

补偿键应基于sagaId + stepId + compensationName,并保存请求摘要、状态和结果。

sql
update t_stock_reservation
set status = 'RELEASED', released_at = now()
where reservation_no = :reservationNo
  and status = 'RESERVED';
  • 更新1行:本次真正释放。
  • 更新0行且当前已RELEASED:幂等成功。
  • 更新0行且当前CONFIRMED:决议冲突,不能伪成功。
  • 记录不存在:先查询正向步骤是否真实成功,不能凭空增加库存。

十四、补偿失败怎样收口

补偿也会超时、重复、永久拒绝或结果未知。恢复策略:

  1. 分类网络瞬时错误和业务永久错误。
  2. 使用同一补偿键指数退避重试。
  3. 响应未知时先查补偿事实。
  4. 达到次数/时长阈值进入COMPENSATION_FAILED
  5. 暂停依赖该资源的冲突操作或设置语义锁。
  6. 告警并生成包含证据的人工任务。
  7. 人工处理也使用状态条件更新,防止与自动重试并发。

无限重试不是恢复策略:若参数错误、接口下线或资源不可逆,无限请求只会形成重试风暴。

十五、Saga的隔离问题

因为每步本地提交,其他事务会看到中间状态,可能产生:

  • 脏语义读:看到待补偿订单并当作最终订单。
  • 丢失更新:人工操作和补偿同时修改状态。
  • 不可重复读:同一查询在Saga推进时结果改变。
  • 业务超卖:另一个流程忽略RESERVED资源。

缓解手段:

  • 语义锁:订单标记PROCESSING,限制冲突操作。
  • 可用/冻结资源模型。
  • 条件状态更新和乐观锁version。
  • 业务唯一约束。
  • 读模型明确展示处理中而非伪装成功。
  • 关键步骤串行化或按业务键分区。

Saga没有自动提供数据库串行化隔离,隔离是业务模型的一部分。

十六、语义锁是什么

语义锁不是Redis互斥锁,而是业务状态表达“该资源正由一个长流程处理”。例如:

sql
update t_order
set status = 'CANCELING', version = version + 1
where order_no = :orderNo
  and status in ('WAIT_PAY', 'RESERVED');

其他支付、修改地址或再次取消操作看到CANCELING时按业务规则拒绝或等待。即使服务重启,状态仍在数据库,适合长流程;Redis短租约锁无法单独表达数小时业务状态。

十七、不可逆步骤怎样安排

设计原则:

  1. 可失败的校验和资源预留前置。
  2. 可补偿步骤放在Pivot之前。
  3. 不可逆或高成本动作尽量后置。
  4. Pivot之后步骤必须可幂等重试或由人工完成。
  5. 通知类动作最后执行,避免业务回滚后用户已收到成功短信。

如果外部系统本身提供撤销窗口,可把“确认”当Pivot;如果没有任何撤销能力,需要在执行前把所有必要条件确认到足够可靠。

十八、流程版本升级

运行中的Saga不能因发布新版本而突然改变步骤含义。常见策略:

  • 新实例使用definitionVersion=2,旧实例继续按v1恢复。
  • 步骤和事件向后兼容,字段新增提供默认值。
  • 不删除仍有运行实例依赖的补偿处理器。
  • 数据迁移脚本显式迁移状态,不由新代码猜测。
  • 灰度期间监控不同版本成功率和补偿率。

十九、商业场景:订单履约Saga

mermaid
flowchart TD
    A["订单进入FULFILLING"] --> B["仓库分配库存"]
    B --> C["创建拣货任务"]
    C --> D["创建物流运单"]
    D --> E["仓库确认出库"]
    E --> F["Pivot:交付承运商"]
    F --> G["重试同步轨迹和通知"]

若创建物流单失败:取消拣货、释放仓库库存、订单回到可取消状态。若交付承运商后通知失败,不能撤销发货,只能重试通知。流程状态必须反映真实阶段。

二十、生产排查Runbook

20.1 Saga长期RUNNING

  1. 按businessKey找到sagaId和definitionVersion。
  2. 列出所有步骤状态、尝试次数和最后更新时间。
  3. 找到第一个未终态步骤。
  4. 查询参与者业务流水,判断未执行、已成功响应丢失还是永久失败。
  5. 检查命令消息、消费者、Outbox和回复事件。
  6. 按同一步骤ID恢复,不能新建Saga重跑。

20.2 补偿长期失败

检查正向事实、补偿幂等表、当前业务状态、请求摘要、依赖接口和错误分类。若当前已进入不可逆状态,停止机械重试,转人工业务决策。

20.3 同一订单出现两个Saga

检查saga_type + business_key唯一约束、客户端幂等键和创建Saga事务。合并前先确认两个实例各自已完成的副作用,不能直接删除其中一条。

20.4 新版本发布后旧Saga无法恢复

检查definitionVersion路由、旧处理器是否被删除、事件Schema兼容和结果反序列化。回滚应用或恢复旧处理器优先,不要批量把旧实例改成新版本。

20.5 补偿与人工处理冲突

人工接管要原子把Saga或步骤从COMPENSATION_FAILED改为MANUAL_PROCESSING并记录owner token;自动任务只处理允许的状态,防止同时退款两次。

二十一、监控与对账

指标说明
saga_started/succeeded/compensated流程结果分布
saga_running_age_p95/max长时间运行实例
step_retry_total不稳定步骤
compensation_retry_total补偿健康度
manual_intervention_total自动恢复边界
duplicate_command_total重投与幂等命中
unknown_result_total响应不确定规模
definition_version_result版本质量对比

对账应从业务事实源验证订单状态、库存预留、支付/退款和物流状态,而不是只看Saga表标记成功。

二十二、JDK 8 Demo:步骤响应丢失后的结果复用

java
import java.util.LinkedHashMap;
import java.util.Map;

public class SagaStepRecoveryDemo {
    static final class Participant {
        private final Map<String, String> results =
                new LinkedHashMap<String, String>();
        private int executions;

        String execute(String stepId, boolean loseResponse) {
            String result = results.get(stepId);
            if (result == null) {
                executions++;
                result = "RESERVED-" + executions;
                results.put(stepId, result);
            }
            if (loseResponse) {
                throw new RuntimeException("response lost after local commit");
            }
            return result;
        }
    }

    public static void main(String[] args) {
        Participant participant = new Participant();
        String stepId = "SAGA-1:RESERVE_STOCK";
        try {
            participant.execute(stepId, true);
        } catch (RuntimeException unknown) {
            System.out.println("first=UNKNOWN");
        }
        String recovered = participant.execute(stepId, false);
        System.out.println("recovered=" + recovered);
        System.out.println("businessExecutions=" + participant.executions);
    }
}

输出:

text
first=UNKNOWN
recovered=RESERVED-1
businessExecutions=1

真实参与者应把业务副作用、步骤记录和结果放在数据库同一本地事务,Demo内存Map只解释稳定stepId的结果复用。

二十三、常见错误与后果

错误后果正确方向
补偿等于恢复旧字段值覆盖后续合法业务查询当前事实,执行有语义补偿
HTTP超时直接判步骤失败已成功步骤被重复执行或错误补偿同stepId查询/重试并复用结果
Saga状态只在内存进程重启后不知道完成到哪持久化实例、步骤、结果和决议
并行步骤按完成时间逆序补偿违反依赖关系按DAG依赖补偿
不保存流程版本发布后旧实例无法恢复definitionVersion路由旧处理器
无限重试补偿永久错误形成重试风暴分类、退避、上限、人工接管
人工处理不抢占状态与自动恢复并发重复退款owner token和条件状态更新
不可逆通知提前执行用户收到成功后业务又补偿不可逆动作后置
只看Saga表不做业务对账状态标成功但资源不一致以业务流水和外部事实为准

二十四、面试标准回答

24.1 Saga是什么

Saga把长事务拆成多个本地事务,每一步提交后持久化流程状态;后续失败时,对已完成的可补偿步骤按依赖关系执行幂等业务补偿。它不长期持有数据库锁,适合履约、审批、物流,但会暴露中间状态并要求补偿、重试、对账和人工接管。

24.2 编排式和协同式有什么区别

编排式由中央状态机显式发送命令、保存步骤和安排补偿,复杂流程和排查更清晰,但编排器需高可用;协同式由服务消费事件并发布下一事件,自治性强,但流程分散,事件环路、补偿顺序和可观测性更难治理。

24.3 Saga补偿为什么不是数据库回滚

正向步骤已经本地提交,补偿是新的业务动作。例如退款会新增退款流水,原支付仍存在;取消物流可能因已揽收而失败。补偿必须依据当前事实、幂等执行,并允许失败后重试或人工处理。

24.4 步骤超时后为什么不能直接补偿

超时可能是参与者本地事务已提交但响应丢失。编排器应使用同一stepId查询或重试,参与者复用第一次结果;只有确认正向结果和全局决议后才执行补偿。

二十五、关联知识点

本章小结

Saga的生产价值来自可恢复状态机,不只是“失败后调用几个反向接口”。每个步骤、结果、补偿和流程版本都要持久化;超时先确认事实,补偿按依赖和当前业务语义执行,自动恢复失败后必须能安全人工接管。