Saga 模式与状态机的结合:本质、实践与两种形态
一、Saga 的本质1.1 从一个基本问题说起分布式场景下,一个业务动作往往需要跨多个服务、多个资源完成。例如:问题是:没有一个全局事务能把这几步包起来一起提交或一起回滚。任意一步失败,前面已经生效的动作都要"想办法撤销"。Saga 就是为这类长事务、跨资源、无法用 2PC 的场景设计的模式。...
一、Saga 的本质
1.1 从一个基本问题说起
分布式场景下,一个业务动作往往需要跨多个服务、多个资源完成。例如:
Diagram
问题是:没有一个全局事务能把这几步包起来一起提交或一起回滚。任意一步失败,前面已经生效的动作都要"想办法撤销"。
Saga 就是为这类长事务、跨资源、无法用 2PC 的场景设计的模式。
1.2 Saga 的核心定义
Saga 是一个由若干本地事务(T1, T2, ..., Tn)组成的序列,每个本地事务 Ti 都有一个对应的补偿事务 Ci。当 Sn 失败时,通过依次执行 Cn-1, Cn-2, ..., C1 来撤销已经完成的工作。
用图表达:
Diagram
1.3 Saga 的本质不是"事务",而是"约定"
关键洞察:Saga 放弃了 ACID 中的 A(原子性) 和 I(隔离性),换取的是:
- 可用性:不需要全局锁,各服务独立可扩展
- 性能:无需协调者、无阻塞等待
- 松耦合:服务之间只通过事件/命令交互 代价是:
- 中间态对外可见(隔离性丢失)——需要业务能容忍"扣了库存但订单还没生成"这种可见状态
- 补偿必须由业务自己定义——框架给不了通用回滚 所以 Saga 的本质是一种"业务级契约":正操作 + 逆操作 + 一致性最终由业务逻辑保证。
1.4 Saga 的两种基本形态
按控制流走向划分:
| 形态 | 特征 | 直觉 |
|---|---|---|
| 前滚型(Forward Recovery) | 只推进不回退,失败就重试直到成功 | "既然做了就做完" |
| 回滚型(Backward Recovery) | 失败逆序补偿回到起点 | "做错了就撤销" |
这是本文后半部分的核心——这两种形态与状态机结合时,会产生完全不同的架构。
二、Saga 的实践变体
2.1 按编排方式划分
编排式(Orchestration):有一个中心协调者调度所有步骤。
Diagram
- 优点:流程集中可见、易调试、状态好追踪
- 缺点:协调者是逻辑瓶颈、单点故障风险
- 适合:流程复杂、步骤依赖多 协同式(Choreography):无中心,每个服务监听事件自己反应。
Diagram
- 优点:彻底解耦、无单点
- 缺点:整体流程隐式、出问题难定位
- 适合:微服务成熟度高、流程简单
2.2 按补偿策略划分
| 策略 | 语义 | 典型场景 |
|---|---|---|
| 纯回滚(Backward) | 失败逆序补偿到初始态 | 有明确反向动作的场景(如加库存反向扣库存) |
| 纯前滚(Forward) | 失败继续重试直到成功 | 反向操作代价过大或不可能的场景 |
| 混合策略 | 前面步骤前滚、后面步骤回滚 | 大部分真实系统 |
2.3 落地形态清单
Saga 在工程中常见的落地形态:
Diagram
无论哪种形态,核心都绕不开一件事:如何管理"我现在走到哪一步了"——这就是状态机的用武之地。
三、Saga 为什么需要状态机
3.1 无状态机的 Saga 是脆弱的
考虑一个简单的 3 步 Saga:
T1 (扣库存) → T2 (扣余额) → T3 (生成订单)
如果 T2 之后系统宕机,重启后需要知道:
- 我是要重放 T3(前滚)还是补偿 T1(回滚)?
- 前面哪几步已经完成、哪几步还没做?
- 补偿到哪一步了? 没有状态机 = 没有"记忆"——每次崩溃后都要靠日志推理、靠人肉判断。
3.2 状态机的三个作用
状态机在 Saga 中承担三个职责:
Diagram
① 记录位置:把"当前进度"物化为一个可查询的状态字段
② 约束转移:非法的状态跳转被拒绝(比如"未 Init"不能直接进"Success")
③ 驱动决策:宕机恢复时,看状态就知道下一步该做什么
3.3 Saga + 状态机 = 可恢复的分布式流程
真正的核心公式:
Saga(正逆步骤定义) + 状态机(持久化的位置)
= 崩溃后可恢复的最终一致性流程
这就是所有生产级 Saga 实现的骨架。
四、形态一:正向状态机 + 前滚 Saga
4.1 核心思路
每个步骤成功都推进一次状态,失败就在当前状态不动、等待重试。永不回退,只往前走。
Diagram
4.2 关键设计点
① 状态即"完成度"
状态字段直接对应"进度到哪一步"。宕机恢复只需要从当前状态往后继续。
② 步骤幂等性是硬要求
因为会重试,每个步骤必须做到执行 N 次和执行 1 次结果相同——通常靠幂等键 + 唯一索引。
③ 状态转移的守卫
每个步骤在执行前校验入口状态:
if (current_state != EXPECTED_LEFT_STATE) reject;
执行操作;
current_state = NEXT_STATE;
这层守卫防止"跳步执行"或"重复推进"。
④ 失败态的处理
失败不改状态,重试直到成功。若长时间失败:
Diagram
⑤ 对外语义:"处理中" 而非 "失败"
因为最终一定要成功,中间态对外表达为 PROCESSING——客户端轮询而非重发。
4.3 前滚型的核心哲学
"承诺发起 = 承诺完成"
一旦流程 INIT 成功,就保证最终会走到 SUCCESS。系统不告诉调用方"失败了",只告诉"还在处理中"。
4.4 适用场景
- 动作难以真正撤销:如资金加减款、外部系统调用
- 业务上要求最终必达:如支付回调、消息投递
- 补偿代价 > 重试代价:如涉及对方服务的操作
4.5 缺点
- 需要极强的幂等性设计,任何非幂等操作都是雷
- 长时间卡在中间态时用户体验差
Compensate名存实亡——大部分补偿代码是空的
五、形态二:正逆状态机 + 补偿 Saga
5.1 核心思路
每个步骤都有正向和逆向两个动作。失败时按 LIFO 顺序执行逆向动作,把系统"擦干净"回到初始态。
Diagram
5.2 关键设计点
① 步骤契约是"正逆对偶"
每个步骤定义两个方法:
Step {
Execute() // 正向:扣库存
Compensate() // 逆向:加回库存
}
② 执行日志是补偿的依据
必须记录"已经执行过哪些步骤",才知道要补偿哪些:
StepLog:
[{name: "T1", status: "executed"},
{name: "T2", status: "executed"},
{name: "T3", status: "failed"}]
补偿时倒序遍历 executed 的步骤,跳过 failed。
③ 逆序补偿(LIFO)
必须按**"后进先出"**顺序补偿——因为后面的步骤可能依赖前面步骤的结果:
Diagram
如果反过来先解锁再释放使用,会出现"资源已释放但仍被占用"的错乱。
④ 补偿失败也要能重试
补偿本身可能失败(网络、下游异常),所以:
- 补偿失败不中断,继续补偿其他步骤
- 全部结果记录,进入
COMPENSATE_FAILED终态 - 定时 Job 扫描该终态,异步重试补偿 ⑤ 幂等性同样是硬要求
- Execute 幂等:重放跳过已完成
- Compensate 幂等:重复补偿不能出错(比如"库存加两次"就完蛋了)
5.3 回滚型的核心哲学
"要么全做,要么当没做"
失败时通过补偿回到初始态,对外表达清晰的"成功 / 失败"二元结果,不留中间态。
5.4 适用场景
- 动作有明确反向操作:加/减库存、创建/删除记录
- 业务能接受"失败":不追求最终必达
- 补偿代价 < 重试代价:反向动作简单可靠
5.5 缺点
- 每个步骤都要设计补偿逻辑,开发成本翻倍
- 补偿本身失败会形成新的一致性问题
- 中间态期间的"脏数据"对其他事务可见(隔离性丢失)
六、两种形态的深度对比
6.1 状态机形态对比
正向型状态机:单向推进
Diagram
正逆型状态机:分叉+回收
Diagram
6.2 机制对比表
| 维度 | 前滚型(正向状态机) | 补偿型(正逆状态机) |
|---|---|---|
| 步骤契约 | 只需 Execute | Execute + Compensate |
| 状态数量 | 步骤数 + 1(每步一态) | 步骤数 + 补偿态 + 终态 |
| 失败方向 | 停在当前态等重试 | 倒序进入补偿链 |
| 重试位置 | 当前失败步骤 | 补偿失败的步骤 |
| 对外结果 | 成功 / 处理中(无失败) | 成功 / 失败 / 补偿失败 |
| 数据可见性 | 中间态可见(长时间) | 中间态可见(短时间) |
| 幂等要求 | Execute 幂等 | Execute + Compensate 都幂等 |
| 兜底手段 | 后台 Job 推进 | 后台 Job 补偿 |
| 开发成本 | 低(每步一个方法) | 高(每步两个方法) |
| 调试难度 | 低(线性) | 中(分叉) |
6.3 选择决策树
Diagram
6.4 混合策略是常态
真实系统很少纯粹用一种:
Diagram
分段策略:
- 前置校验类步骤:回滚(代价小)
- 核心资金类步骤:前滚(不敢回滚)
- 后置通知类步骤:最大努力通知(失败可容忍)
七、Saga + 状态机 的工程实践清单
7.1 状态机设计的通用原则
① 状态最少化:每个状态必须有明确的业务语义,避免"看似有用其实没用"的中间状态。
② 转移显式化:所有合法的状态跳转都要写清楚,非法跳转必须被引擎拒绝。
③ 终态明确:至少要有 SUCCESS 和 FAIL(或 COMPENSATED)两个终态,避免"永远在路上"。
④ 状态可持久化:每次变更必须落库,掉电不丢。
7.2 幂等设计的通用手法
Diagram
三层防御:唯一索引兜底 + 应用查表短路 + 步骤级跳过。
7.3 补偿设计的注意事项
① 补偿不是"反 SQL":不是 UPDATE ... SET balance=balance-100 的反向就是 +100。要考虑并发下的中间变化——补偿应该基于当时的操作痕迹,不是当前状态。
② 空补偿要能处理:如果 T1 还没执行就要补偿(比如 Try 阶段就失败),补偿动作要能识别"没做过所以不用撤销"。
③ 补偿的幂等性同样重要:定时兜底可能反复触发同一个补偿。
7.4 兜底机制是必需品
任何异步系统都会有卡住的时候,兜底 Job 是最后一道防线:
Diagram
没有兜底 Job 的 Saga 都是玩具。
八、总结
8.1 三个核心命题
Saga 的本质:用"业务级的正逆步骤契约"替代 ACID 事务,牺牲隔离性和原子性,换取跨服务的可扩展和最终一致性。
状态机的作用:把 Saga 的"进度"物化,让流程崩溃可恢复、转移可校验、决策可自动。
两种形态的区别:不在于"用不用状态机",而在于状态机是单向前进还是可以分叉回退——这背后是"前滚 vs 补偿"的哲学分歧。
8.2 一句话选型
- 步骤可逆 + 业务能接受失败 → 补偿型(正逆状态机)
- 步骤不可逆 + 业务要求必达 → 前滚型(正向状态机)
- 大多数真实系统 → 混合策略,分段使用
8.3 工程铁律
不论选哪种:
- 幂等键是根——业务单号 + DB 唯一索引,不容妥协
- 状态必须持久化——每次变更立刻落库
- 兜底 Job 必须有——不能靠"应该不会出问题"活着
- 中间态要可查——出问题时能定位到"卡在哪一步" Saga 不是魔法,是纪律。写得对不难,写得可靠很难。