背景与问题
只要系统一拆成微服务,事务问题几乎一定会冒出来。
最典型的场景是下单:
- 订单服务创建订单
- 库存服务扣减库存
- 支付服务扣款
- 营销服务发券或积分
在单体应用里,这些步骤往往能放进一个数据库事务里,失败就 rollback。但到了微服务架构下,服务各自有自己的数据库,跨库、跨服务、跨网络调用同时出现,传统的本地事务就不够用了。
很多团队第一反应会想到 2PC/XA,但真上生产后常见问题也很直接:
- 性能开销大
- 锁持有时间长
- 对数据库、中间件支持要求高
- 一旦协调者或网络抖动,整个链路吞吐明显下降
所以,Saga 模式成了很多业务系统里更实际的选择:它不追求强一致下的“一把锁到底”,而是通过一组本地事务 + 对应补偿动作,把跨服务事务拆开处理,接受短暂不一致,再通过补偿恢复到业务可接受状态。
但 Saga 真正难的地方不是“知道这个概念”,而是:
- 补偿到底怎么设计才不会越补越乱?
- 服务超时、重复请求、消息重试时,状态怎么兜住?
- 线上出问题时,如何快速判断卡在了哪一步?
- 什么情况该自动补偿,什么情况必须人工介入?
这篇文章我会从排障和实战角度带你走一遍,重点不是讲定义,而是讲:怎么落地、怎么复现问题、怎么止血、怎么避免补偿失控。
背景案例:一个最常见的订单 Saga
我们先固定一个业务流,后面所有代码和排查都围绕它展开。
- 订单服务:创建订单
- 库存服务:冻结库存
- 支付服务:扣款
- 订单服务:确认订单完成
- 若任一步失败:按逆序执行补偿
flowchart TD
A[创建订单 PENDING] --> B[冻结库存]
B -->|成功| C[扣减余额]
B -->|失败| X[取消订单]
C -->|成功| D[确认订单 CONFIRMED]
C -->|失败| E[解冻库存]
E --> F[取消订单]
这个流程看起来不复杂,但线上真正出问题,往往是这些地方:
- 库存冻结成功了,但支付服务超时,调用方不知道扣款是否真的成功
- 补偿执行时,库存已经被人工修改,解冻逻辑出现负数
- 消息重复投递,导致订单被重复取消
- 编排器重启后,事务状态丢失,出现“订单卡死在处理中”
核心原理
1. Saga 的本质
Saga 可以理解为:
一个大事务,拆成多个可独立提交的本地事务;每个本地事务都要有对应的补偿动作。
例如:
| 正向动作 | 补偿动作 |
|---|---|
| 创建订单 | 取消订单 |
| 冻结库存 | 解冻库存 |
| 扣减余额 | 退款/冲正 |
注意,这里有个非常关键的认知:
补偿不是数据库层面的 rollback,而是新的业务操作。
也就是说,补偿也会失败,也需要重试,也要考虑幂等。
2. 两种常见实现方式
编排式(Orchestration)
由一个 Saga Coordinator 统一驱动流程:
- 先调用订单服务
- 再调用库存服务
- 再调用支付服务
- 失败时统一触发补偿
优点:
- 流程清晰,便于排障
- 状态集中管理
- 中级团队更容易落地
缺点:
- 编排器会成为核心组件
- 流程变更可能集中在编排器
协同式(Choreography)
各服务通过事件自己接力:
- 订单创建后发事件
- 库存服务消费事件并冻结库存,再发成功/失败事件
- 支付服务继续消费
优点:
- 松耦合
- 更符合事件驱动架构
缺点:
- 故障排查难
- 业务流分散在多个服务事件里
- 很容易“谁都能改流程,最后没人说得清”
如果你问我在中级团队里更建议哪种,我通常会说:先编排式,后事件化。因为很多事务问题不是不会做,而是出了故障没人能快速定位。
3. 状态机思维比“接口串调用”更重要
落地 Saga 时,最容易犯的错误是把它写成一串 if/else 调接口。
真正稳定的做法是:先定义状态机,再实现调用。
stateDiagram-v2
[*] --> PENDING
PENDING --> INVENTORY_RESERVED: 冻结库存成功
PENDING --> CANCELLED: 创建后直接取消
INVENTORY_RESERVED --> PAYMENT_DONE: 扣款成功
INVENTORY_RESERVED --> COMPENSATING: 扣款失败/超时
PAYMENT_DONE --> CONFIRMED: 确认订单
PAYMENT_DONE --> COMPENSATING: 确认失败
COMPENSATING --> CANCELLED: 补偿完成
COMPENSATING --> MANUAL_REVIEW: 多次补偿失败
CONFIRMED --> [*]
CANCELLED --> [*]
MANUAL_REVIEW --> [*]
这里我特别建议你把“人工介入”也视作合法终态。因为线上总会遇到自动补偿解决不了的情况,比如:
- 支付已成功,但支付网关查单接口异常
- 库存表被人工修过
- 第三方退款接口连续失败
这时别硬自动重试到死,应该有一个 MANUAL_REVIEW 状态把问题显式暴露出来。
现象复现:为什么 Saga 事务会“看起来成功,实际上失败”?
先看一个真实感很强的时序图。
sequenceDiagram
participant C as Coordinator
participant O as OrderService
participant I as InventoryService
participant P as PaymentService
C->>O: createOrder(txId)
O-->>C: success
C->>I: reserve(txId, orderId)
I-->>C: success
C->>P: charge(txId, orderId)
Note over P: 实际扣款成功,但响应超时
P--xC: timeout
C->>I: release(txId, orderId)
I-->>C: success
C->>O: cancel(txId, orderId)
O-->>C: success
Note over C,P: 最终出现:订单取消,但用户可能已被扣款
这是 Saga 排障里最经典的一类问题:调用超时不等于业务失败。
也就是说,编排器看到超时,会按失败处理并触发补偿;但下游服务可能已经执行成功了。
因此,Saga 的设计里必须有两个能力:
- 幂等
- 可查询最终状态
否则一旦超时,就只能靠猜。
实战代码(可运行)
下面我用 Python 写一个简化的 Saga 编排示例。它不依赖外部框架,直接可运行,重点体现:
- 状态持久化思路
- 幂等事务号
tx_id - 补偿逆序执行
- 超时/失败后的止血逻辑
说明:这是教学版,便于你本地跑通和理解。生产环境一般会接数据库、消息队列、可观测系统。
目录结构
saga_demo.py
完整代码
import uuid
import random
from dataclasses import dataclass, field
from typing import Dict, List
class SagaException(Exception):
pass
@dataclass
class Order:
order_id: str
status: str = "PENDING"
@dataclass
class SagaLog:
tx_id: str
order_id: str
steps_done: List[str] = field(default_factory=list)
status: str = "RUNNING"
reason: str = ""
class OrderService:
def __init__(self):
self.orders: Dict[str, Order] = {}
self.processed_tx = set()
def create_order(self, tx_id: str, order_id: str):
if ("create_order", tx_id) in self.processed_tx:
return
self.orders[order_id] = Order(order_id=order_id, status="PENDING")
self.processed_tx.add(("create_order", tx_id))
print(f"[Order] create_order success, order={order_id}")
def confirm_order(self, tx_id: str, order_id: str):
if ("confirm_order", tx_id) in self.processed_tx:
return
self.orders[order_id].status = "CONFIRMED"
self.processed_tx.add(("confirm_order", tx_id))
print(f"[Order] confirm_order success, order={order_id}")
def cancel_order(self, tx_id: str, order_id: str):
if ("cancel_order", tx_id) in self.processed_tx:
return
if order_id in self.orders:
self.orders[order_id].status = "CANCELLED"
self.processed_tx.add(("cancel_order", tx_id))
print(f"[Order] cancel_order success, order={order_id}")
class InventoryService:
def __init__(self):
self.stock = {"item-1": 10}
self.reserved: Dict[str, int] = {}
self.processed_tx = set()
def reserve(self, tx_id: str, order_id: str, item_id: str, qty: int):
if ("reserve", tx_id) in self.processed_tx:
return
if self.stock.get(item_id, 0) < qty:
raise SagaException("库存不足")
self.stock[item_id] -= qty
self.reserved[order_id] = self.reserved.get(order_id, 0) + qty
self.processed_tx.add(("reserve", tx_id))
print(f"[Inventory] reserve success, order={order_id}, qty={qty}")
def release(self, tx_id: str, order_id: str, item_id: str):
if ("release", tx_id) in self.processed_tx:
return
qty = self.reserved.get(order_id, 0)
self.stock[item_id] += qty
self.reserved[order_id] = 0
self.processed_tx.add(("release", tx_id))
print(f"[Inventory] release success, order={order_id}, qty={qty}")
class PaymentService:
def __init__(self, fail_mode="none"):
self.balance = {"user-1": 1000}
self.charged: Dict[str, int] = {}
self.refunded = set()
self.processed_tx = set()
self.fail_mode = fail_mode
def charge(self, tx_id: str, order_id: str, user_id: str, amount: int):
if ("charge", tx_id) in self.processed_tx:
return
if self.fail_mode == "timeout":
# 模拟“可能已扣款但调用超时”
self.balance[user_id] -= amount
self.charged[order_id] = amount
self.processed_tx.add(("charge", tx_id))
raise TimeoutError("支付服务超时")
if self.fail_mode == "fail":
raise SagaException("支付失败")
if self.balance.get(user_id, 0) < amount:
raise SagaException("余额不足")
self.balance[user_id] -= amount
self.charged[order_id] = amount
self.processed_tx.add(("charge", tx_id))
print(f"[Payment] charge success, order={order_id}, amount={amount}")
def refund(self, tx_id: str, order_id: str, user_id: str):
if ("refund", tx_id) in self.processed_tx:
return
amount = self.charged.get(order_id, 0)
self.balance[user_id] += amount
self.refunded.add(order_id)
self.processed_tx.add(("refund", tx_id))
print(f"[Payment] refund success, order={order_id}, amount={amount}")
def query_payment(self, order_id: str):
if order_id in self.charged:
return "SUCCESS"
return "NOT_FOUND"
class SagaCoordinator:
def __init__(self, order_svc, inventory_svc, payment_svc):
self.order_svc = order_svc
self.inventory_svc = inventory_svc
self.payment_svc = payment_svc
self.logs: Dict[str, SagaLog] = {}
def execute(self, order_id: str, user_id: str, item_id: str, qty: int, amount: int):
tx_id = str(uuid.uuid4())
log = SagaLog(tx_id=tx_id, order_id=order_id)
self.logs[tx_id] = log
try:
self.order_svc.create_order(tx_id, order_id)
log.steps_done.append("create_order")
self.inventory_svc.reserve(tx_id, order_id, item_id, qty)
log.steps_done.append("reserve_inventory")
try:
self.payment_svc.charge(tx_id, order_id, user_id, amount)
log.steps_done.append("charge_payment")
except TimeoutError as e:
print(f"[Coordinator] payment timeout, query final state, order={order_id}")
payment_status = self.payment_svc.query_payment(order_id)
if payment_status == "SUCCESS":
print(f"[Coordinator] payment actually succeeded, continue")
log.steps_done.append("charge_payment")
else:
raise e
self.order_svc.confirm_order(tx_id, order_id)
log.steps_done.append("confirm_order")
log.status = "SUCCESS"
print(f"[Coordinator] saga success, tx_id={tx_id}")
return tx_id
except Exception as e:
print(f"[Coordinator] saga failed: {e}, start compensation")
self.compensate(log, user_id, item_id)
log.status = "FAILED"
log.reason = str(e)
return tx_id
def compensate(self, log: SagaLog, user_id: str, item_id: str):
tx_id = log.tx_id
order_id = log.order_id
# 按已完成步骤逆序补偿
if "charge_payment" in log.steps_done:
self.payment_svc.refund(tx_id, order_id, user_id)
if "reserve_inventory" in log.steps_done:
self.inventory_svc.release(tx_id, order_id, item_id)
if "create_order" in log.steps_done:
self.order_svc.cancel_order(tx_id, order_id)
def print_state(order_svc, inventory_svc, payment_svc, order_id):
print("\n===== FINAL STATE =====")
order = order_svc.orders.get(order_id)
print("Order:", order)
print("Inventory stock:", inventory_svc.stock)
print("Reserved:", inventory_svc.reserved)
print("Balance:", payment_svc.balance)
print("Charged:", payment_svc.charged)
print("=======================\n")
if __name__ == "__main__":
print("=== CASE 1: 正常成功 ===")
order_svc = OrderService()
inventory_svc = InventoryService()
payment_svc = PaymentService(fail_mode="none")
coordinator = SagaCoordinator(order_svc, inventory_svc, payment_svc)
order_id = "order-1001"
coordinator.execute(order_id, "user-1", "item-1", 2, 100)
print_state(order_svc, inventory_svc, payment_svc, order_id)
print("=== CASE 2: 支付失败,触发补偿 ===")
order_svc = OrderService()
inventory_svc = InventoryService()
payment_svc = PaymentService(fail_mode="fail")
coordinator = SagaCoordinator(order_svc, inventory_svc, payment_svc)
order_id = "order-1002"
coordinator.execute(order_id, "user-1", "item-1", 2, 100)
print_state(order_svc, inventory_svc, payment_svc, order_id)
print("=== CASE 3: 支付超时,但实际扣款成功,靠查单避免误补偿 ===")
order_svc = OrderService()
inventory_svc = InventoryService()
payment_svc = PaymentService(fail_mode="timeout")
coordinator = SagaCoordinator(order_svc, inventory_svc, payment_svc)
order_id = "order-1003"
coordinator.execute(order_id, "user-1", "item-1", 2, 100)
print_state(order_svc, inventory_svc, payment_svc, order_id)
运行方式
python saga_demo.py
你应该重点观察什么
这个示例故意做了三种情况:
- 正常成功
- 支付失败,触发补偿
- 支付超时,但通过查单发现其实已成功,因此继续确认订单
第三种最关键,因为它对应线上高频问题:超时语义不清导致误补偿。
设计要点:为什么这份代码能跑,但离生产还差几步?
很多教程到“能跑”就结束了,但真正做 troubleshooting,必须知道它的边界。
1. tx_id 必须全链路唯一
每个正向动作和补偿动作都要绑定同一个事务上下文,比如:
tx_idorder_idstep_name
否则服务重试时根本没法做幂等判断。
2. 补偿动作必须天然幂等
比如 release_inventory 不能因为消息重复而把库存加两次。
所以设计时要尽量使用状态驱动而不是“纯增减”:
- 错误做法:收到补偿就
stock += qty - 更稳做法:根据冻结记录释放尚未释放的数量
3. 日志不是“打印出来就算了”,而是事务账本
生产里的 Saga Log 至少要记录:
tx_id- 业务主键
order_id - 当前步骤
- 当前状态
- 重试次数
- 最近一次错误原因
- 最后更新时间
如果没有这些字段,排查基本靠猜。
常见坑与排查
下面这部分是这篇文章最核心的内容。我按“现象 -> 定位路径 -> 止血方案”的方式来写。
坑 1:订单长期停留在 PENDING
现象
- 用户投诉订单一直处理中
- 后台查询订单状态卡在
PENDING - 库存可能已冻结,用户也可能已扣款
常见原因
- 编排器调用中断,未继续后续步骤
- 本地事务成功,但 Saga 状态未落库
- 编排器重启后,未恢复未完成事务
- 消息丢失或消费失败无重试
定位路径
建议按这个顺序查:
- 查 Saga 日志表:
tx_id/order_id当前停在哪一步 - 查订单服务本地状态
- 查库存冻结记录
- 查支付扣款记录或支付网关查单
- 对照时间线,看是“执行没发生”,还是“执行发生但状态没记上”
止血方案
- 增加定时扫描任务,扫描超时未完成事务
- 超时后不要直接补偿,优先查下游最终状态
- 对长时间未决事务转
MANUAL_REVIEW
示例扫描逻辑:
def scan_timeout_sagas(logs, timeout_seconds=60):
# 教学版示例:实际应比较数据库中的更新时间
timeout_list = []
for tx_id, log in logs.items():
if log.status == "RUNNING":
timeout_list.append((tx_id, log.order_id, log.steps_done))
return timeout_list
坑 2:补偿执行了,但数据越补越错
现象
- 库存变成负数或异常增加
- 一个订单重复退款
- 订单状态反复在
CANCELLED/CONFIRMED之间跳
根因
补偿没有做幂等。
这是我见过最多的实现问题。很多人会认为“补偿只会执行一次”,但线上一定会遇到:
- MQ 重投
- 编排器重试
- 服务超时后再次调用
- 运维人工重复触发
定位路径
重点查这三类信息:
- 同一个
tx_id是否出现重复调用 - 同一个
order_id是否存在多条补偿记录 - 补偿逻辑是否基于当前状态判断
止血方案
- 给每个动作建立去重表或幂等键
- 补偿前先判断资源当前状态
- 对外部支付退款使用“退款单号”保证唯一
示例幂等保护:
def idempotent_execute(processed_set, action_name, tx_id, fn):
key = (action_name, tx_id)
if key in processed_set:
return
fn()
processed_set.add(key)
坑 3:支付超时后误补偿,导致“已扣款但订单取消”
现象
- 用户余额已扣
- 订单却显示取消
- 客服无法快速判断是扣款成功还是失败
根因
调用方把“网络超时”当成“业务失败”。
定位路径
- 查调用日志,确认是否发生超时
- 查支付服务本地扣款记录
- 查支付网关查单结果
- 对比补偿时间点是否早于最终成功回写
止血方案
- 任何超时场景先查单,再决定是否补偿
- 查单仍未知时,进入
PAYMENT_UNKNOWN或MANUAL_REVIEW - 不要把超时直接映射为失败
建议引入一个中间状态:
flowchart LR
A[调用支付] --> B{返回结果}
B -->|成功| C[继续流程]
B -->|失败| D[触发补偿]
B -->|超时/未知| E[查单]
E -->|成功| C
E -->|失败| D
E -->|仍未知| F[人工复核/延迟重试]
坑 4:补偿顺序错误
现象
- 先取消订单,再退款失败
- 先解冻库存,但支付未退款
- 用户看到订单关闭,但钱没回来
根因
补偿不是随便做的,一般应按正向步骤逆序执行。
因为后面的动作通常依赖前面的动作而成立。
定位路径
看 Saga 定义表或编排代码,确认:
- 正向顺序
- 补偿顺序
- 每一步是否需要“条件补偿”
止血方案
- 显式维护步骤栈
- 只补偿已成功步骤
- 逆序执行补偿
坑 5:本地事务与消息发送不一致
现象
- 订单已创建,但“订单创建成功”事件没发出去
- 或事件发出去了,但本地订单实际上没写成功
根因
这是典型的“双写不一致”。
定位路径
- 查本地业务表
- 查消息表/outbox 表
- 查消息队列投递状态
- 查消费者是否消费成功
止血方案
生产环境建议用 Outbox Pattern:
- 业务数据和待发送消息写在同一本地事务里
- 再由独立投递程序异步发送 MQ
示例 SQL:
CREATE TABLE orders (
order_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE outbox_events (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
定位路径:线上排障我通常怎么查
如果线上真的出问题,我一般按下面这个顺序走,比较稳。
第一步:先定性,是“执行失败”还是“状态丢失”
你要先搞清楚:
- 下游服务真的没执行?
- 还是执行了,但编排器不知道?
- 还是执行和补偿都发生过,只是最终状态不一致?
这决定后面是查调用链、查数据库,还是查消息系统。
第二步:围绕业务主键串时间线
建议统一所有日志都打印:
tx_idorder_iduser_idstepstatus
这样你能很快按订单维度拉出时间线。
例如:
tx=abc order=1001 step=create_order status=success
tx=abc order=1001 step=reserve_inventory status=success
tx=abc order=1001 step=charge_payment status=timeout
tx=abc order=1001 step=query_payment status=success
tx=abc order=1001 step=confirm_order status=success
如果没有统一日志字段,排查会非常痛苦。
第三步:核对“资源事实”而不是只看编排状态
编排状态只能告诉你“系统以为怎样了”,但真正排障时要看资源事实:
- 订单表里到底是什么状态
- 库存冻结记录还在不在
- 用户余额实际扣没扣
- 退款单是否生成
- MQ 消息是否消费
第四步:决定止血策略
常见止血动作有三种:
- 延迟重试:适合短暂网络抖动
- 自动补偿:适合状态明确的失败
- 人工介入:适合支付未知、第三方状态不明确
这里的原则很简单:
如果你无法确认资源真实状态,就不要盲目补偿。
安全/性能最佳实践
Saga 讨论里经常只讲一致性,但生产环境里,安全和性能同样重要。
1. 安全:补偿接口不能“谁都能调”
补偿接口如果裸露出来,风险很大:
- 恶意重复请求触发退款
- 越权取消订单
- 内部系统误调用导致大量补偿
建议至少做到:
- 内部接口鉴权
- 请求签名或服务间 mTLS
- 幂等校验
- 审计日志记录调用人、调用源、参数快照
2. 安全:敏感字段不要全量打日志
支付、用户、地址相关信息,排障日志里不要直接裸打:
- 用户手机号脱敏
- 支付账户信息脱敏
- 退款结果日志不要带完整卡号、证件号
3. 性能:缩短本地事务时间
Saga 的每一步虽然是本地事务,但如果本地事务很重,仍会拖慢整体链路。
建议:
- 本地事务里只做必要写入
- 避免长 SQL、全表扫描
- 外部调用不要放进数据库事务里
4. 性能:补偿风暴要限流
一旦某个下游服务故障,可能触发大量事务补偿,进而把库存、支付、订单服务再次打爆。
建议:
- 补偿任务限速
- 按租户/业务线隔离重试队列
- 熔断下游不稳定服务
- 设置最大重试次数,超过后转人工
5. 性能:状态存储要支持恢复
编排器如果只把状态放内存里,重启后事务上下文全没了。
至少要落盘:
- Saga 实例表
- Saga 步骤表
- 错误表
- 重试任务表
示例表设计:
CREATE TABLE saga_instances (
tx_id VARCHAR(64) PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
current_step VARCHAR(64),
reason TEXT,
retry_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE saga_steps (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tx_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
step_status VARCHAR(32) NOT NULL,
compensation_status VARCHAR(32),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
6. 可观测性:把事务做成可搜索、可追踪、可告警
建议至少有三类监控:
- 成功率:Saga 成功率、补偿率
- 时延:事务完成平均时长、超时比例
- 堵塞:长时间
RUNNING的事务数量
常见告警阈值可先这样设:
- 同一事务运行超过 5 分钟告警
- 补偿失败率超过 1% 告警
- 某一步超时率骤增告警
什么时候不适合用 Saga?
虽然 Saga 很实用,但也不是银弹。
以下场景要谨慎:
1. 无法定义补偿动作
比如某些不可逆业务:
- 已经发出的外部通知无法撤回
- 已完成的线下履约无法回滚
- 第三方动作不可撤销
如果补偿本身无法定义,就别强行套 Saga。
2. 业务不能接受短暂不一致
例如极少数金融核心账务场景,如果要求严格强一致,Saga 可能不合适,需要更严格的事务方案和账务建模。
3. 团队尚未建立基础设施
如果连这些都没有:
- 幂等机制
- 统一日志
- 可观测链路
- 定时恢复任务
- 手工补偿后台
那直接上 Saga,最后大概率是“代码看着像分布式事务,线上靠人工兜底”。
一份可执行的落地清单
如果你准备在项目里上 Saga,我建议先完成下面这份清单。
设计阶段
- 明确定义正向步骤和补偿步骤
- 每个步骤都定义成功、失败、超时、未知四种结果
- 设计状态机,而不是直接写串行调用
- 明确哪些状态允许人工介入
开发阶段
- 每个动作有幂等键
- 每个补偿动作也有幂等键
- 超时后支持查最终状态
- 状态持久化到数据库
- 失败支持重试和最大重试次数
测试阶段
- 模拟库存不足
- 模拟支付失败
- 模拟支付超时但实际成功
- 模拟消息重复投递
- 模拟编排器重启恢复
- 模拟补偿接口重复调用
生产阶段
- 有超时扫描和恢复任务
- 有手工补偿或人工复核后台
- 有成功率、补偿率、超时率监控
- 有按
tx_id/order_id查询全链路日志能力
总结
Saga 模式真正的价值,不是让分布式事务“像本地事务一样简单”,而是让你在接受最终一致性的前提下,仍然能把复杂业务做得可恢复、可观测、可止血。
如果只记住三句话,我建议你记这三句:
- 补偿不是回滚,而是新的业务动作。
- 超时不是失败,未知状态必须查证。
- 幂等和状态机,比接口调用顺序更重要。
落地上,我给一个比较务实的建议:
- 中级团队优先选编排式 Saga
- 先把状态持久化、幂等、查单、人工介入这四件事做好
- 再考虑事件化、异步化、流程引擎化
最后说个很现实的边界条件:
如果你的业务链路里存在“不可补偿、不可查状态、不可人工介入”的关键步骤,那 Saga 就不是最优解,至少不是可以单独承担一致性责任的解法。
把这些前提想清楚,再上生产,很多坑其实是能提前绕过去的。