Saga:长事务状态机、业务补偿与失败恢复
一、学完本页要真正会什么
Saga把一个跨服务长流程拆成多个可独立提交的本地事务。每一步一旦提交,后续失败时不是数据库物理回滚,而是执行具有业务语义的补偿动作。
学完本页,你应该能够:
- 区分正向事务、补偿事务、可重试步骤和Pivot步骤。
- 解释Saga为什么适合分钟、小时甚至更长流程,却会暴露中间状态。
- 比较编排式和协同式Saga的调用、恢复与可观测性。
- 设计Saga实例、步骤、尝试、结果和补偿日志。
- 解释业务步骤提交后、状态机未记成功时怎样恢复。
- 解释补偿响应丢失、重复补偿和补偿失败怎样处理。
- 说明并行步骤为什么不能简单按“最后调用先补偿”。
- 使用语义锁、状态机、版本号和业务唯一键降低隔离异常。
- 设计不可逆步骤的位置和人工接管流程。
- 根据sagaId、stepId、businessKey、状态和业务流水排查。
二、Saga到底解决什么
订单履约可能持续数小时:创建订单、锁库存、支付、仓库出库、物流揽收。若使用XA让数据库事务一直Prepared,连接和锁会长期占用,任何参与者故障都会拖住整个流程。
Saga允许每一步快速本地提交:
flowchart TD
A["T1 创建订单"] --> B["T2 预订库存"]
B --> C["T3 创建支付"]
C --> D["T4 创建物流单"]
D --> E{"后续步骤是否失败"}
E -- "否" --> F["Saga成功"]
E -- "是" --> G["按依赖关系执行C3、C2、C1补偿"]代价是T1、T2提交后,其他请求可能看到PROCESSING、STOCK_RESERVED等中间状态。Saga追求的是可恢复最终一致,不是所有参与者同时不可见、同时提交。
三、补偿不是物理回滚
| 正向动作 | 可能的补偿 | 为什么不是物理回滚 |
|---|---|---|
| 创建订单 | 将订单状态改为CANCELED | 原订单和审计仍保留 |
| 预订库存 | 新增释放流水并恢复可用量 | 预订流水不能删除 |
| 支付扣款 | 发起退款 | 原支付和退款是两笔业务事实 |
| 使用优惠券 | 解锁或补发新券 | 原券可能已经过期或规则变化 |
| 创建物流单 | 请求取消物流 | 已出库后可能无法完全取消 |
| 发送短信 | 通常不可撤回 | 只能再发更正通知 |
补偿必须根据“当前事实”执行,而不是盲目恢复旧快照。支付补偿前要确认渠道是否已扣款、是否已退款;物流补偿前要确认包裹是否已揽收。
四、Compensatable、Pivot与Retryable步骤
设计Saga时可以按语义分类:
- Compensatable:在某个阶段前可以通过业务补偿撤销,例如预订库存。
- Pivot:一旦成功,Saga跨过不可轻易返回的决策点,例如确认不可撤销的外部结算。它之后通常不再走前向步骤失败回滚,而要确保后续可重试完成。
- Retryable:设计成最终可成功且可幂等重试的步骤,例如发送已持久化的内部事件。
flowchart TD
A["可补偿步骤:创建订单"] --> B["可补偿步骤:预订库存"]
B --> C["Pivot:确认结算"]
C --> D["可重试步骤:生成账单"]
D --> E["可重试步骤:发送完成事件"]不是所有业务都能清晰划分Pivot;该模型用于帮助把不可逆步骤后置。若Pivot后的步骤也可能永久失败,必须有人工完成或反向业务处理,不能只标记“重试中”。
五、编排式Saga全过程
编排器集中保存流程定义和状态,依次向参与者发命令:
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全过程
协同式没有一个集中编排器,每个服务消费事件、完成本地事务并发布下一事件:
flowchart TD
A["订单服务发布OrderCreated"] --> B["库存服务预订并发布StockReserved"]
B --> C["物流服务创建单并发布ShipmentCreated"]
C --> D["订单服务推进完成"]
C --> E{"物流失败事件"}
E --> F["库存服务消费并释放库存"]
F --> G["订单服务消费并取消订单"]优点:服务自治、无集中同步调用、天然使用事件驱动。缺点:
- 流程散落在多个消费者中,难看出全貌。
- 事件环路和隐式依赖容易失控。
- 补偿顺序和版本兼容更难治理。
- 定位一个Saga需要聚合多服务事件。
协同式也需要某种全局可观测视图或流程投影,否则只能依赖人工搜索日志。复杂Saga通常更适合明确编排;简单线性事件链可采用协同式。
七、编排式与协同式对比
| 维度 | 编排式 | 协同式 |
|---|---|---|
| 流程控制 | 编排器显式决定 | 服务按事件自治 |
| 状态位置 | 中央Saga实例/步骤表 | 分散业务状态和事件日志 |
| 可观测性 | 较集中 | 需要事件追踪投影 |
| 耦合方式 | 服务依赖命令契约 | 服务依赖事件语义 |
| 复杂分支/并行 | 较易表达 | 容易形成事件网 |
| 单点风险 | 编排器需高可用 | Broker和各消费者均关键 |
| 适合 | 复杂履约、审批、长流程 | 简单事件链、服务自治强 |
八、Saga实例和步骤状态机
全局状态示例:
RUNNING
COMPENSATING
SUCCEEDED
COMPENSATED
FAILED_MANUAL步骤状态示例:
PENDING
EXECUTING
SUCCEEDED
FAILED_RETRYABLE
FAILED_FINAL
COMPENSATING
COMPENSATED
COMPENSATION_FAILED状态只能按合法路径迁移。例如COMPENSATED不能再次回到EXECUTING;SUCCEEDED重复回调应复用结果,不重新执行副作用。
九、持久化表设计
Saga实例表:
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)
);步骤表:
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状态记录的双写窗口
危险窗口:参与者本地事务成功,但编排器没收到响应或没记录成功。
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,否则编排器无法决定重试、补偿还是人工处理。
十二、补偿顺序不是永远简单逆序
线性依赖通常逆序补偿。但若步骤并行,补偿必须按依赖图,而不是按完成时间:
flowchart TD
A["创建订单"] --> B["并行预订库存"]
A --> C["并行锁定优惠券"]
B --> D["创建物流"]
C --> E["生成营销权益"]
D --> F["失败后先取消物流,再释放库存"]
E --> G["先撤销权益,再解锁优惠券"]
F --> H["两个分支补偿完成后取消订单"]
G --> H编排器需要保存依赖图和已完成节点。并行补偿还要考虑部分补偿失败,不能因为一个分支失败就重复补偿另一个已成功终态分支。
十三、补偿怎样做到幂等
补偿键应基于sagaId + stepId + compensationName,并保存请求摘要、状态和结果。
update t_stock_reservation
set status = 'RELEASED', released_at = now()
where reservation_no = :reservationNo
and status = 'RESERVED';- 更新1行:本次真正释放。
- 更新0行且当前已RELEASED:幂等成功。
- 更新0行且当前CONFIRMED:决议冲突,不能伪成功。
- 记录不存在:先查询正向步骤是否真实成功,不能凭空增加库存。
十四、补偿失败怎样收口
补偿也会超时、重复、永久拒绝或结果未知。恢复策略:
- 分类网络瞬时错误和业务永久错误。
- 使用同一补偿键指数退避重试。
- 响应未知时先查补偿事实。
- 达到次数/时长阈值进入
COMPENSATION_FAILED。 - 暂停依赖该资源的冲突操作或设置语义锁。
- 告警并生成包含证据的人工任务。
- 人工处理也使用状态条件更新,防止与自动重试并发。
无限重试不是恢复策略:若参数错误、接口下线或资源不可逆,无限请求只会形成重试风暴。
十五、Saga的隔离问题
因为每步本地提交,其他事务会看到中间状态,可能产生:
- 脏语义读:看到待补偿订单并当作最终订单。
- 丢失更新:人工操作和补偿同时修改状态。
- 不可重复读:同一查询在Saga推进时结果改变。
- 业务超卖:另一个流程忽略RESERVED资源。
缓解手段:
- 语义锁:订单标记
PROCESSING,限制冲突操作。 - 可用/冻结资源模型。
- 条件状态更新和乐观锁version。
- 业务唯一约束。
- 读模型明确展示处理中而非伪装成功。
- 关键步骤串行化或按业务键分区。
Saga没有自动提供数据库串行化隔离,隔离是业务模型的一部分。
十六、语义锁是什么
语义锁不是Redis互斥锁,而是业务状态表达“该资源正由一个长流程处理”。例如:
update t_order
set status = 'CANCELING', version = version + 1
where order_no = :orderNo
and status in ('WAIT_PAY', 'RESERVED');其他支付、修改地址或再次取消操作看到CANCELING时按业务规则拒绝或等待。即使服务重启,状态仍在数据库,适合长流程;Redis短租约锁无法单独表达数小时业务状态。
十七、不可逆步骤怎样安排
设计原则:
- 可失败的校验和资源预留前置。
- 可补偿步骤放在Pivot之前。
- 不可逆或高成本动作尽量后置。
- Pivot之后步骤必须可幂等重试或由人工完成。
- 通知类动作最后执行,避免业务回滚后用户已收到成功短信。
如果外部系统本身提供撤销窗口,可把“确认”当Pivot;如果没有任何撤销能力,需要在执行前把所有必要条件确认到足够可靠。
十八、流程版本升级
运行中的Saga不能因发布新版本而突然改变步骤含义。常见策略:
- 新实例使用definitionVersion=2,旧实例继续按v1恢复。
- 步骤和事件向后兼容,字段新增提供默认值。
- 不删除仍有运行实例依赖的补偿处理器。
- 数据迁移脚本显式迁移状态,不由新代码猜测。
- 灰度期间监控不同版本成功率和补偿率。
十九、商业场景:订单履约Saga
flowchart TD
A["订单进入FULFILLING"] --> B["仓库分配库存"]
B --> C["创建拣货任务"]
C --> D["创建物流运单"]
D --> E["仓库确认出库"]
E --> F["Pivot:交付承运商"]
F --> G["重试同步轨迹和通知"]若创建物流单失败:取消拣货、释放仓库库存、订单回到可取消状态。若交付承运商后通知失败,不能撤销发货,只能重试通知。流程状态必须反映真实阶段。
二十、生产排查Runbook
20.1 Saga长期RUNNING
- 按businessKey找到sagaId和definitionVersion。
- 列出所有步骤状态、尝试次数和最后更新时间。
- 找到第一个未终态步骤。
- 查询参与者业务流水,判断未执行、已成功响应丢失还是永久失败。
- 检查命令消息、消费者、Outbox和回复事件。
- 按同一步骤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:步骤响应丢失后的结果复用
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);
}
}输出:
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的生产价值来自可恢复状态机,不只是“失败后调用几个反向接口”。每个步骤、结果、补偿和流程版本都要持久化;超时先确认事实,补偿按依赖和当前业务语义执行,自动恢复失败后必须能安全人工接管。
