背景与问题
微服务拆开以后,最先变复杂的,往往不是业务本身,而是“原来一个本地事务能搞定的事,现在跨了 3 个服务、4 张表、还带一个消息队列”。
一个典型场景:
- 订单服务:创建订单
- 库存服务:冻结库存
- 支付服务:扣款
- 营销服务:发优惠券或积分
在单体应用里,这些动作通常可以放进一个数据库事务里,失败就一起回滚。
但到了微服务架构里:
- 服务之间是网络调用,不可靠
- 数据各自持有,不能共享本地事务
- 下游可能超时、重试、消息重复
- 某一步成功了,后续失败时要“补偿”而不是“数据库回滚”
这时,很多团队会想到 2PC/XA,但真实落地里常见问题是:
- 对数据库、中间件支持要求高
- 性能损耗明显
- 协调器成为瓶颈或故障点
- 对云原生和异构系统不够友好
所以,Saga 模式几乎成了微服务分布式事务里更务实的选择。
但 Saga 不是“选了就完事”。真正难的是:
- 补偿动作怎么定义才可靠?
- 并发下怎么避免重复执行?
- 某个步骤超时了,到底是失败、处理中,还是成功但响应丢了?
- 补偿失败时怎么止血?
- 如何排查“订单取消了但库存没释放”这种线上事故?
这篇文章我会从故障排查和落地实践角度来讲,不只讲概念,而是带你走一遍:设计、实现、复现问题、定位路径、止血方案。
核心原理
什么是 Saga
Saga 的核心思想很朴素:
把一个长事务拆成多个本地事务,每个本地事务成功后继续下一步;如果某一步失败,就按相反顺序执行补偿操作。
比如:
- 创建订单
- 冻结库存
- 扣减余额
- 完成订单
如果第 3 步扣款失败,则触发补偿:
- 释放库存
- 取消订单
Saga 的两种实现风格
1. Choreography(事件编排/无中心)
各服务通过事件自行驱动:
- 订单创建后发事件
- 库存服务收到后冻结库存,再发事件
- 支付服务收到后扣款,再发事件
优点:
- 去中心化,简单场景上手快
缺点:
- 业务链路变长后难追踪
- 状态散落在多个服务里
- 排查故障时像“猜谜”
2. Orchestration(中心编排)
引入一个 Saga Orchestrator:
- 由编排器决定下一步执行谁
- 记录整个 Saga 的状态
- 失败时统一触发补偿
优点:
- 更适合复杂流程
- 可观测性和排障体验更好
缺点:
- 编排器本身需要高可用设计
对中级工程师和大多数业务团队来说,我更建议优先采用 Orchestration。原因很现实:线上出问题时,能不能快速定位,比“架构图看起来优雅”更重要。
Saga 的关键设计点
1. 正向动作与补偿动作必须成对设计
不是所有动作都能完美回滚。
例如:
- 冻结库存 → 释放库存:适合补偿
- 扣款 → 退款:不是严格回滚,而是一个新业务动作
- 发短信 → 无法真正撤回,只能记录并容忍
所以在设计时要先分清:
- 可逆操作:冻结/解冻、预占/释放
- 近似补偿:扣款/退款、发券/撤券
- 不可补偿操作:外部通知、第三方副作用
2. 补偿必须幂等
这是 Saga 成败的分水岭。
为什么?
- 网络超时后,调用方可能重试
- 消息队列可能投递重复消息
- 编排器恢复后可能再次调度补偿
- 人工介入时也可能重复触发
所以你要接受一个事实:重复执行是常态,不是异常。
3. 每一步都要有明确状态机
最怕的是代码里只有一个 success/fail,线上一出问题就看不懂到底卡在哪。
建议至少有这些状态:
PENDINGRUNNINGSUCCESSFAILEDCOMPENSATINGCOMPENSATED
如果步骤级别更细,还可以拆成:
- 执行中
- 执行成功待确认
- 补偿中
- 补偿失败待重试
Saga 执行流
flowchart TD
A[创建订单 Saga] --> B[步骤1: 创建订单]
B -->|成功| C[步骤2: 冻结库存]
B -->|失败| X[结束: 失败]
C -->|成功| D[步骤3: 扣减余额]
C -->|失败| C1[补偿: 取消订单]
D -->|成功| E[步骤4: 完成订单]
D -->|失败| D1[补偿: 释放库存]
D1 --> D2[补偿: 取消订单]
E -->|成功| F[结束: 成功]
E -->|失败| E1[补偿: 退款/人工核查]
状态机视角
stateDiagram-v2
[*] --> PENDING
PENDING --> RUNNING
RUNNING --> SUCCESS
RUNNING --> FAILED
FAILED --> COMPENSATING
COMPENSATING --> COMPENSATED
COMPENSATING --> COMPENSATION_FAILED
COMPENSATION_FAILED --> COMPENSATING
SUCCESS --> [*]
COMPENSATED --> [*]
实战代码(可运行)
下面用一个可运行的 Python 示例模拟一个简单的 Saga 编排器:
- 订单服务:创建/取消订单
- 库存服务:冻结/释放库存
- 支付服务:扣款/退款
- 编排器:驱动流程、记录状态、失败时补偿
这个例子偏教学,但结构上已经能映射真实系统中的关键点:幂等、状态记录、补偿链、故障模拟。
示例代码
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, List, Dict, Optional
import uuid
import random
class StepStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
COMPENSATING = "COMPENSATING"
COMPENSATED = "COMPENSATED"
@dataclass
class StepRecord:
name: str
status: StepStatus = StepStatus.PENDING
error: Optional[str] = None
@dataclass
class SagaContext:
saga_id: str
order_id: str
user_id: str
product_id: str
amount: int
logs: List[str] = field(default_factory=list)
step_records: Dict[str, StepRecord] = field(default_factory=dict)
idempotency_keys: set = field(default_factory=set)
def log(self, message: str):
self.logs.append(message)
print(message)
class OrderService:
def __init__(self):
self.orders = {}
def create_order(self, ctx: SagaContext):
if ctx.order_id in self.orders:
ctx.log(f"[OrderService] 订单已存在,幂等返回: {ctx.order_id}")
return
self.orders[ctx.order_id] = {"status": "CREATED", "amount": ctx.amount}
ctx.log(f"[OrderService] 创建订单成功: {ctx.order_id}")
def cancel_order(self, ctx: SagaContext):
order = self.orders.get(ctx.order_id)
if not order:
ctx.log(f"[OrderService] 订单不存在,取消视为幂等成功: {ctx.order_id}")
return
if order["status"] == "CANCELLED":
ctx.log(f"[OrderService] 订单已取消,幂等返回: {ctx.order_id}")
return
order["status"] = "CANCELLED"
ctx.log(f"[OrderService] 取消订单成功: {ctx.order_id}")
def complete_order(self, ctx: SagaContext):
order = self.orders.get(ctx.order_id)
if not order:
raise RuntimeError("订单不存在,无法完成")
if order["status"] == "COMPLETED":
ctx.log(f"[OrderService] 订单已完成,幂等返回: {ctx.order_id}")
return
if order["status"] == "CANCELLED":
raise RuntimeError("订单已取消,不能完成")
order["status"] = "COMPLETED"
ctx.log(f"[OrderService] 完成订单成功: {ctx.order_id}")
class InventoryService:
def __init__(self):
self.stock = {"product-1": 10}
self.frozen = {}
def reserve(self, ctx: SagaContext):
key = (ctx.order_id, ctx.product_id)
if key in self.frozen:
ctx.log(f"[InventoryService] 库存已冻结,幂等返回: {key}")
return
available = self.stock.get(ctx.product_id, 0)
if available <= 0:
raise RuntimeError("库存不足")
self.stock[ctx.product_id] -= 1
self.frozen[key] = 1
ctx.log(f"[InventoryService] 冻结库存成功: {key}")
def release(self, ctx: SagaContext):
key = (ctx.order_id, ctx.product_id)
if key not in self.frozen:
ctx.log(f"[InventoryService] 无冻结记录,释放视为幂等成功: {key}")
return
self.stock[ctx.product_id] += 1
del self.frozen[key]
ctx.log(f"[InventoryService] 释放库存成功: {key}")
class PaymentService:
def __init__(self):
self.balance = {"user-1": 100}
self.paid = {}
def charge(self, ctx: SagaContext, fail_random=False):
key = (ctx.order_id, ctx.user_id)
if key in self.paid:
ctx.log(f"[PaymentService] 已扣款,幂等返回: {key}")
return
if fail_random and random.choice([True, False]):
raise RuntimeError("模拟支付通道超时")
if self.balance.get(ctx.user_id, 0) < ctx.amount:
raise RuntimeError("余额不足")
self.balance[ctx.user_id] -= ctx.amount
self.paid[key] = ctx.amount
ctx.log(f"[PaymentService] 扣款成功: {key}, amount={ctx.amount}")
def refund(self, ctx: SagaContext):
key = (ctx.order_id, ctx.user_id)
amount = self.paid.get(key)
if amount is None:
ctx.log(f"[PaymentService] 无扣款记录,退款视为幂等成功: {key}")
return
self.balance[ctx.user_id] += amount
del self.paid[key]
ctx.log(f"[PaymentService] 退款成功: {key}, amount={amount}")
@dataclass
class SagaStep:
name: str
action: Callable[[SagaContext], None]
compensation: Optional[Callable[[SagaContext], None]] = None
class SagaOrchestrator:
def __init__(self, steps: List[SagaStep]):
self.steps = steps
def execute(self, ctx: SagaContext):
completed_steps = []
for step in self.steps:
ctx.step_records[step.name] = StepRecord(name=step.name)
record = ctx.step_records[step.name]
try:
record.status = StepStatus.RUNNING
ctx.log(f"[Saga] 开始执行步骤: {step.name}")
step.action(ctx)
record.status = StepStatus.SUCCESS
completed_steps.append(step)
ctx.log(f"[Saga] 步骤成功: {step.name}")
except Exception as e:
record.status = StepStatus.FAILED
record.error = str(e)
ctx.log(f"[Saga] 步骤失败: {step.name}, error={e}")
self.compensate(ctx, completed_steps)
raise
def compensate(self, ctx: SagaContext, completed_steps: List[SagaStep]):
ctx.log("[Saga] 开始补偿流程")
for step in reversed(completed_steps):
if not step.compensation:
continue
record = ctx.step_records.get(step.name)
try:
if record:
record.status = StepStatus.COMPENSATING
ctx.log(f"[Saga] 补偿步骤: {step.name}")
step.compensation(ctx)
if record:
record.status = StepStatus.COMPENSATED
ctx.log(f"[Saga] 补偿成功: {step.name}")
except Exception as e:
ctx.log(f"[Saga] 补偿失败: {step.name}, error={e}")
# 真实生产中这里应落库、告警、进入重试队列
raise
def main():
order_service = OrderService()
inventory_service = InventoryService()
payment_service = PaymentService()
ctx = SagaContext(
saga_id=str(uuid.uuid4()),
order_id="order-1001",
user_id="user-1",
product_id="product-1",
amount=30
)
steps = [
SagaStep(
name="create_order",
action=order_service.create_order,
compensation=order_service.cancel_order
),
SagaStep(
name="reserve_inventory",
action=inventory_service.reserve,
compensation=inventory_service.release
),
SagaStep(
name="charge_payment",
action=lambda c: payment_service.charge(c, fail_random=True),
compensation=payment_service.refund
),
SagaStep(
name="complete_order",
action=order_service.complete_order,
compensation=None
)
]
orchestrator = SagaOrchestrator(steps)
try:
orchestrator.execute(ctx)
print("\n=== Saga 执行成功 ===")
except Exception as e:
print(f"\n=== Saga 执行失败: {e} ===")
print("\n=== 最终订单状态 ===")
print(order_service.orders)
print("\n=== 最终库存状态 ===")
print(inventory_service.stock, inventory_service.frozen)
print("\n=== 最终余额状态 ===")
print(payment_service.balance, payment_service.paid)
print("\n=== 步骤状态 ===")
for k, v in ctx.step_records.items():
print(k, v.status, v.error)
if __name__ == "__main__":
main()
如何运行
python saga_demo.py
你会看到两类结果:
- 成功:订单完成,库存减少,余额扣减
- 失败:支付步骤抛错,触发库存释放与订单取消
这段代码对应的真实落地含义
这个例子虽然简单,但几个点是“生产上真的要做”的:
1. 步骤状态要可持久化
示例里放在内存。生产中应落到数据库,比如:
saga_instance:记录一个 Saga 实例saga_step:记录每个步骤状态compensation_task:记录待补偿任务
2. 补偿动作必须是业务语义上的逆操作
不是简单“delete 一条数据”。
比如支付成功后失败,不能直接改数据库余额,而应走标准退款逻辑,留下审计记录。
3. 每个服务都要支持幂等键
例如:
order_idsaga_id + step_namerequest_id
我当时踩过一个坑:编排器超时后重试,支付服务没有幂等校验,结果用户被扣了两次款。
这类事故只靠“上层少重试”根本挡不住,必须把幂等做在服务入口。
现象复现
既然文章偏 troubleshooting,就不能只讲“正确姿势”,还要讲故障怎么复现。
故障 1:支付超时,但其实已经扣款成功
这是最典型、也最难受的一类。
场景:
- 编排器调用支付服务
- 支付服务已经完成扣款
- 响应在网络中丢失或超时
- 编排器认为支付失败,开始补偿
- 后面又触发退款,甚至出现状态错乱
复现方式
可以把 PaymentService.charge() 改成:
def charge(self, ctx: SagaContext, fail_random=False):
key = (ctx.order_id, ctx.user_id)
if key in self.paid:
ctx.log(f"[PaymentService] 已扣款,幂等返回: {key}")
return
if self.balance.get(ctx.user_id, 0) < ctx.amount:
raise RuntimeError("余额不足")
self.balance[ctx.user_id] -= ctx.amount
self.paid[key] = ctx.amount
ctx.log(f"[PaymentService] 扣款成功: {key}, amount={ctx.amount}")
raise RuntimeError("模拟:扣款后响应超时")
这时你会得到一个很真实的问题:
- 支付实际成功
- Saga 视角却认为失败
- 补偿是否执行,要依赖查询支付最终状态而不是只看调用是否抛异常
正确思路
对外部或不稳定调用,步骤状态不要直接二元化为“成功/失败”,而应允许:
UNKNOWNCONFIRMING
然后通过查询接口、回查任务、对账任务确认最终结果。
故障 2:补偿失败导致事务半悬挂
场景:
- 创建订单成功
- 冻结库存成功
- 支付失败
- 开始补偿
- 释放库存时又失败
这时系统会处于一种很尴尬的状态:
- 订单取消了
- 库存还冻结着
- 用户没付钱
- 客服收到投诉
这就是典型的半悬挂状态。
定位路径
线上排查 Saga 问题,我建议按下面这条路径走,效率很高。
1. 先查 Saga 实例,而不是先翻业务日志
先回答三个问题:
- 这个 Saga 的全局 ID 是什么?
- 当前停在第几步?
- 正在正向执行还是补偿?
生产中建议统一透传:
trace_idsaga_idorder_idstep_name
2. 看步骤状态时间线
你真正需要的是一条时间线:
- 订单创建成功时间
- 库存冻结成功时间
- 支付调用发起时间
- 支付响应超时时间
- 补偿启动时间
- 补偿完成/失败时间
有了时间线,很多“玄学问题”会立刻清楚:
到底是重试太快、补偿太早,还是下游成功回执太慢。
3. 查幂等表和去重记录
很多重复扣款、重复释放、重复取消,本质上不是业务逻辑错,而是:
- 没做幂等
- 幂等键不稳定
- 幂等记录写入时机不对
例如:
- 先执行业务,再写幂等表:有窗口期
- 用随机 UUID 做幂等键:重试时变了,等于没做
- 仅内存缓存去重:实例重启就失效
4. 检查消息队列与本地事务的一致性
很多 Saga 流程会和消息结合,比如步骤成功后发事件。
如果你是:
- 先提交本地事务
- 再发送 MQ 消息
那么只要 MQ 发送失败,就可能出现:
- 本地已经成功
- 下游永远没收到事件
这类问题建议用:
- Outbox Pattern
- 本地消息表 + 异步投递
- 消息状态回查
5. 判断是“需要重试”还是“需要人工介入”
不是所有失败都适合无限自动重试。
适合自动重试:
- 网络闪断
- 下游暂时超时
- 死锁/短暂资源竞争
适合人工介入:
- 数据已错乱
- 外部支付状态长期不一致
- 补偿多次失败
- 第三方接口无回查能力
典型时序图
sequenceDiagram
participant O as Orchestrator
participant OS as OrderService
participant IS as InventoryService
participant PS as PaymentService
O->>OS: createOrder(orderId)
OS-->>O: success
O->>IS: reserve(orderId, productId)
IS-->>O: success
O->>PS: charge(orderId, userId, amount)
Note over PS: 实际已扣款
PS--xO: 响应超时/丢失
O->>PS: queryPayment(orderId)
PS-->>O: paid=true
O->>OS: completeOrder(orderId)
OS-->>O: success
这个图想说明一个关键点:
调用超时不等于业务失败。
常见坑与排查
下面这些坑,我基本都见过,甚至有些就是线上事故复盘里反复出现的。
坑 1:把补偿当成数据库回滚
很多人刚做 Saga 时,下意识会想:“失败就撤销前面的操作”。
但补偿不是数据库回滚,它是一个新的业务动作。
这意味着:
- 会有时延
- 会失败
- 会被重试
- 可能与用户行为并发发生
排查方式
如果发现“补偿后数据还是不对”,先检查:
- 补偿动作是否真的覆盖了业务副作用
- 是否存在用户侧并发修改
- 补偿动作是否幂等
- 补偿的前置条件是否过严
坑 2:步骤顺序设计不合理,导致补偿成本很高
例如你把“真实扣款”放在前面,把“库存预占”放在后面,一旦库存不足,就要退款。
从业务体验上看,这会明显放大退款链路压力。
建议顺序
通常更稳妥的是:
- 创建订单
- 预占库存/额度
- 扣款
- 最终确认
原则是:越难补偿的动作越靠后。
坑 3:补偿和正向操作并发冲突
比如:
- 支付超时,编排器准备补偿
- 支付服务其实晚一点返回成功
- 一个线程在退款,一个线程在完成订单
最终可能出现:
- 订单完成但又退款了
- 库存释放后又发货了
排查方式
看有没有做这些保护:
- 订单状态机 CAS 更新
- 分布式锁或业务锁
- 乐观锁版本号
- “最终态不可逆”约束
坑 4:重试没有退避策略,造成雪崩
下游支付超时后,编排器立即重试 3 次,多个实例同时打爆支付服务,然后整个链路一起抖。
建议
使用指数退避:
- 第 1 次:1s
- 第 2 次:5s
- 第 3 次:30s
- 第 4 次:5min
并加抖动(jitter),避免同一时刻集中重试。
坑 5:没有“死信”和“人工修复”通道
很多团队代码里写了补偿失败重试,但没有上限。
结果一条异常任务每天重试几千次,日志刷满,问题却没人知道。
止血方案
出现补偿持续失败时:
- 暂停自动重试
- 写入死信队列或异常任务表
- 告警到值班人
- 提供后台工具执行人工补偿/重放
- 做对账修复
止血方案
当线上已经出现 Saga 失控,先别急着“修优雅”,要优先止损。
场景 1:重复扣款
立即止血
- 关闭编排器对该步骤的自动重试
- 在支付服务入口加幂等校验
- 根据
order_id拉取重复扣款明细 - 批量触发退款
后续修复
- 幂等键改为稳定业务键
- 将“调用超时”与“业务失败”分离
- 增加支付状态回查机制
场景 2:库存长期冻结
立即止血
- 扫描超过阈值未完成的冻结记录
- 根据订单最终状态批量释放
- 暂时降低下游失败步骤的流量入口
后续修复
- 引入冻结过期时间 TTL
- 增加定时清理任务
- 对补偿失败任务建立告警与人工处理台账
场景 3:订单状态错乱
例如订单显示“已取消”,但支付和库存又显示成功。
立即止血
- 以最关键业务实体为主视图建立修复规则
比如交易系统里,通常支付状态优先级更高 - 对不一致订单先冻结后续操作
- 防止继续发货、继续退款或继续记账
后续修复
- 明确统一状态机
- 所有服务状态变更必须带版本号
- 做跨服务对账任务
安全/性能最佳实践
Saga 不是只关心一致性,安全和性能也很重要。
安全最佳实践
1. 补偿接口不要对外裸奔
补偿接口本质上是“逆向业务操作”,风险很高。必须做到:
- 鉴权
- 签名校验
- 白名单或内网调用限制
- 审计日志
否则一个误调用就可能批量退款、批量释放库存。
2. 敏感字段最小暴露
跨服务传递 Saga 上下文时,不要把用户敏感信息一路透传。
能传 user_id 就不要传手机号,能传引用就不要传明文卡号。
3. 审计日志要可追溯
至少记录:
- 谁发起的操作
- 哪个 Saga 实例
- 哪个步骤
- 请求参数摘要
- 响应结果
- 补偿触发原因
性能最佳实践
1. 避免长链路串行过多步骤
步骤太多会放大失败概率。经验上:
- 能合并的本地操作尽量合并
- 低价值副作用改为异步最终一致
- 非核心步骤不要强行纳入主事务
2. Saga 状态存储要有索引
常见查询条件:
saga_idbusiness_id/order_idstatusnext_retry_time
否则一到补偿堆积,后台扫描任务就会拖垮库。
3. 重试任务要限流
补偿重试本身也可能制造事故。建议:
- 按服务维度限速
- 按租户/业务线隔离
- 对第三方接口设置熔断和降级
推荐的表结构思路
CREATE TABLE saga_instance (
saga_id VARCHAR(64) PRIMARY KEY,
business_id VARCHAR(64) NOT NULL,
saga_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
current_step VARCHAR(64),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE saga_step (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
error_msg VARCHAR(512),
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
UNIQUE KEY uk_saga_step (saga_id, step_name)
);
CREATE TABLE compensation_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
next_retry_time TIMESTAMP NULL,
retry_count INT NOT NULL DEFAULT 0,
error_msg VARCHAR(512),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
方案边界与取舍
这一节很重要,因为 Saga 不是银弹。
适合 Saga 的场景
- 业务允许短暂最终不一致
- 每一步都能设计补偿或近似补偿
- 更看重可用性与扩展性
- 涉及多个独立服务和数据库
不适合 Saga 的场景
- 强一致要求极高,且不能容忍任何中间态
- 核心动作不可补偿
- 第三方系统没有幂等、没有回查、没有补偿能力
- 团队还没有基础设施支撑状态管理、重试、告警、对账
如果你的业务是“扣一次钱绝对不能错,错了也不能事后退款弥补”,那 Saga 可能就不是最佳答案。
这时要重新评估:
- 是否应收敛服务边界
- 是否保留局部单体事务
- 是否引入更强事务中间件
总结
Saga 真正难的,不是“知道它是什么”,而是把它做成一个线上出问题也能收拾得住的系统。
如果你只记住几条,我建议是这几条:
- 优先选编排式 Saga,排障体验更好。
- 每个步骤都要有幂等能力,而且幂等键必须稳定。
- 调用超时不等于业务失败,要有状态回查机制。
- 补偿是新业务动作,不是数据库回滚。
- 自动重试要有限度,失败任务要能进入人工处理通道。
- 必须建设可观测性:
trace_id、saga_id、步骤状态时间线、告警、对账,一个都别少。
最后给一个比较务实的落地顺序:
- 第一步:先把状态机、幂等和补偿接口定义清楚
- 第二步:做一个最小可用编排器
- 第三步:补齐日志、监控、告警、死信、人工修复工具
- 第四步:通过故障注入演练支付超时、消息重复、补偿失败
- 第五步:上线后持续做对账和复盘
我自己的经验是:
能跑的 Saga 不难,出了故障还能稳定收口的 Saga,才算真的落地。