背景与问题
只要系统一拆成微服务,分布式事务几乎迟早会找上门。
一个最常见的业务链路是这样的:下单 -> 扣库存 -> 扣余额 -> 创建物流单。
单体时代,我们可以把这几个步骤塞进一个本地事务里,成了就一起提交,失败就一起回滚。但到了微服务架构里,每个服务都有自己的数据库,甚至技术栈都不一样,这时候再想靠数据库事务“一把梭”基本不现实。
很多团队一开始会纠结:要不要上 2PC / XA?
现实通常是:
- 性能开销大
- 资源锁持有时间长
- 云原生环境支持一般
- 一旦链路长、参与方多,问题会非常难排查
所以在业务系统里,Saga 模式更常见。它的核心思路不是“全局强一致”,而是:
把一个长事务拆成多个本地事务,每个本地事务成功后继续向前;如果中途失败,就按相反顺序执行补偿操作,把系统拉回一个可接受状态。
这套思路听起来很顺,但真正上线后,问题往往不是“会不会写”,而是:
- 为什么补偿没生效?
- 为什么消息重复导致库存被扣两次?
- 为什么订单状态卡在“处理中”?
- 为什么重试后越修越乱?
这篇文章我会从落地设计 + 可运行代码 + 排错路径三个角度,把 Saga 模式讲透,尤其聚焦故障排查。
背景中的典型问题场景
先看一个具体例子。
假设有 3 个服务:
OrderService:创建订单InventoryService:冻结库存PaymentService:冻结或扣减余额
目标流程:
- 创建订单
- 冻结库存
- 冻结余额
- 全部成功后,订单改为
CONFIRMED - 如果任一步骤失败,则执行补偿:
- 余额解冻
- 库存解冻
- 订单取消
这就是一个典型的 Saga。
为什么 Saga 容易“看起来没问题,上线后问题很多”?
因为它把一个原子问题,拆成了一组最终一致性问题。
而最终一致性最大的问题不在“业务逻辑”,而在“异常路径”。
比如:
- 库存服务成功了,但响应丢了,编排器以为失败,于是触发补偿
- 支付服务其实没收到请求,但消息系统返回成功
- 补偿操作执行了两次,结果把已经恢复的库存又加了一遍
- 某个服务本地事务提交成功,但事件没有发出去,链路断在半路
这些问题,如果设计时没留“幂等、状态机、可观测性”的位置,后面排查会非常痛苦。
核心原理
Saga 通常有两种实现方式:
-
Choreography(事件编排/舞蹈式)
- 各服务通过事件彼此驱动
- 优点:解耦
- 缺点:链路一长就难追踪,排错困难
-
Orchestration(集中编排)
- 由一个 Saga Orchestrator 统一驱动流程
- 优点:流程清晰,便于排查
- 缺点:编排器会成为核心组件
在 troubleshooting 类型的文章里,我更推荐先采用集中编排式 Saga。原因很简单:更容易定位问题。
Saga 的状态流转
stateDiagram-v2
[*] --> CREATED
CREATED --> INVENTORY_RESERVED: 冻结库存成功
INVENTORY_RESERVED --> PAYMENT_RESERVED: 冻结余额成功
PAYMENT_RESERVED --> CONFIRMED: 订单确认
INVENTORY_RESERVED --> COMPENSATING: 支付失败
PAYMENT_RESERVED --> COMPENSATING: 下游确认失败
CREATED --> FAILED: 创建订单失败
COMPENSATING --> CANCELLED: 补偿完成
COMPENSATING --> COMPENSATION_FAILED: 补偿异常
这个图里最重要的不是“成功路径”,而是两件事:
- 每一步都必须有明确状态
- 补偿失败也必须有状态承接
很多系统的问题恰恰在于:只定义了成功和失败,没有定义“补偿中”“补偿失败”“待人工处理”。
Saga 的执行与补偿顺序
sequenceDiagram
participant Client
participant Orchestrator
participant OrderService
participant InventoryService
participant PaymentService
Client->>Orchestrator: 发起下单
Orchestrator->>OrderService: createOrder()
OrderService-->>Orchestrator: success(orderId)
Orchestrator->>InventoryService: reserveInventory(orderId)
InventoryService-->>Orchestrator: success
Orchestrator->>PaymentService: reservePayment(orderId)
PaymentService-->>Orchestrator: failed(balance insufficient)
Orchestrator->>InventoryService: compensateReleaseInventory(orderId)
InventoryService-->>Orchestrator: success
Orchestrator->>OrderService: compensateCancelOrder(orderId)
OrderService-->>Orchestrator: success
Orchestrator-->>Client: 下单失败,已补偿
这里有个很关键的工程原则:
补偿不是回滚。补偿是一个新的、显式的业务动作。
比如:
- 扣库存的补偿不是“数据库 rollback”
- 而是“把冻结库存释放回来”
- 支付的补偿不是“事务撤销”
- 而是“解除冻结”或“发起退款”
这意味着补偿逻辑必须像正向逻辑一样认真设计,而不是顺手写个反操作。
设计落地:先把边界划清楚
我通常会让团队先回答 4 个问题,再开始写代码。
1. 每个本地事务的提交点在哪里?
例如:
- 订单服务:订单记录落库即视为成功
- 库存服务:冻结库存写入冻结表即成功
- 支付服务:账户冻结记录落库即成功
提交点必须明确,否则你永远说不清“到底该不该补偿”。
2. 补偿动作是否天然可逆?
不是所有动作都适合 Saga。
适合 Saga 的动作通常是:
- 冻结/解冻
- 预占/释放
- 待确认/取消
不太适合直接做 Saga 的动作通常是:
- 发短信、发邮件
- 调用不可逆的第三方接口
- 已经产生法律/财务效力的动作
如果业务里存在不可逆操作,建议:
- 把不可逆动作放到 Saga 最后
- 或引入人工审核
- 或用对账机制兜底
3. 是否具备幂等能力?
正向操作和补偿操作都要幂等。
这是分布式事务里最不能省的一条。
幂等至少要覆盖:
- 请求重复投递
- 超时后重试
- 补偿重复触发
- 消息乱序到达
4. 链路是否可观测?
至少要有:
sagaIdorderIdstepNamestepStatusretryCountlastError
否则线上一出问题,你只能在日志里“盲人摸象”。
实战代码(可运行)
下面我用一个Python Flask 单文件示例模拟一个最小可运行的 Saga 编排器。
它不是生产级框架,但足够把核心逻辑说明白:状态流转、幂等、补偿、故障注入。
你可以直接保存为 app.py 运行。
from flask import Flask, request, jsonify
from uuid import uuid4
import threading
app = Flask(__name__)
lock = threading.Lock()
# 模拟数据库
orders = {}
inventory = {"item-1": 10}
inventory_reservations = {}
accounts = {"user-1": 100}
payment_reservations = {}
sagas = {}
def idempotent_step_done(store, key):
return store.get(key) is True
def mark_step_done(store, key):
store[key] = True
@app.route("/saga/order", methods=["POST"])
def create_order_saga():
payload = request.json or {}
user_id = payload.get("userId", "user-1")
item_id = payload.get("itemId", "item-1")
quantity = int(payload.get("quantity", 1))
amount = int(payload.get("amount", 10))
inject = payload.get("injectFailureAt") # inventory / payment / confirm
saga_id = str(uuid4())
order_id = str(uuid4())
sagas[saga_id] = {
"sagaId": saga_id,
"orderId": order_id,
"status": "STARTED",
"steps": [],
"lastError": None
}
try:
# Step 1: 创建订单
create_order(order_id, user_id, item_id, quantity, amount)
record_step(saga_id, "create_order", "DONE")
# Step 2: 冻结库存
reserve_inventory(order_id, item_id, quantity, inject == "inventory")
record_step(saga_id, "reserve_inventory", "DONE")
# Step 3: 冻结余额
reserve_payment(order_id, user_id, amount, inject == "payment")
record_step(saga_id, "reserve_payment", "DONE")
# Step 4: 确认订单
confirm_order(order_id, inject == "confirm")
record_step(saga_id, "confirm_order", "DONE")
sagas[saga_id]["status"] = "COMPLETED"
return jsonify({
"success": True,
"sagaId": saga_id,
"orderId": order_id,
"status": "COMPLETED"
})
except Exception as e:
sagas[saga_id]["lastError"] = str(e)
sagas[saga_id]["status"] = "COMPENSATING"
compensation_errors = []
# 按逆序补偿
try:
compensate_payment(order_id)
record_step(saga_id, "compensate_payment", "DONE")
except Exception as ce:
compensation_errors.append(f"compensate_payment: {ce}")
record_step(saga_id, "compensate_payment", "FAILED")
try:
compensate_inventory(order_id)
record_step(saga_id, "compensate_inventory", "DONE")
except Exception as ce:
compensation_errors.append(f"compensate_inventory: {ce}")
record_step(saga_id, "compensate_inventory", "FAILED")
try:
cancel_order(order_id)
record_step(saga_id, "cancel_order", "DONE")
except Exception as ce:
compensation_errors.append(f"cancel_order: {ce}")
record_step(saga_id, "cancel_order", "FAILED")
if compensation_errors:
sagas[saga_id]["status"] = "COMPENSATION_FAILED"
return jsonify({
"success": False,
"sagaId": saga_id,
"orderId": order_id,
"status": "COMPENSATION_FAILED",
"error": str(e),
"compensationErrors": compensation_errors
}), 500
sagas[saga_id]["status"] = "CANCELLED"
return jsonify({
"success": False,
"sagaId": saga_id,
"orderId": order_id,
"status": "CANCELLED",
"error": str(e)
}), 400
@app.route("/saga/<saga_id>", methods=["GET"])
def get_saga(saga_id):
saga = sagas.get(saga_id)
if not saga:
return jsonify({"error": "not found"}), 404
return jsonify(saga)
def record_step(saga_id, step_name, status):
sagas[saga_id]["steps"].append({
"step": step_name,
"status": status
})
def create_order(order_id, user_id, item_id, quantity, amount):
with lock:
if order_id in orders:
return
orders[order_id] = {
"orderId": order_id,
"userId": user_id,
"itemId": item_id,
"quantity": quantity,
"amount": amount,
"status": "CREATED"
}
def confirm_order(order_id, fail=False):
if fail:
raise Exception("confirm order failed")
with lock:
if order_id not in orders:
raise Exception("order not found")
orders[order_id]["status"] = "CONFIRMED"
def cancel_order(order_id):
with lock:
if order_id not in orders:
return
if orders[order_id]["status"] == "CANCELLED":
return
orders[order_id]["status"] = "CANCELLED"
def reserve_inventory(order_id, item_id, quantity, fail=False):
with lock:
if idempotent_step_done(inventory_reservations, order_id):
return
if fail:
raise Exception("inventory reservation failed")
if inventory.get(item_id, 0) < quantity:
raise Exception("inventory not enough")
inventory[item_id] -= quantity
mark_step_done(inventory_reservations, order_id)
def compensate_inventory(order_id):
with lock:
if not idempotent_step_done(inventory_reservations, order_id):
return
order = orders.get(order_id)
if not order:
return
inventory[order["itemId"]] += order["quantity"]
inventory_reservations[order_id] = False
def reserve_payment(order_id, user_id, amount, fail=False):
with lock:
if idempotent_step_done(payment_reservations, order_id):
return
if fail:
raise Exception("payment reservation failed")
if accounts.get(user_id, 0) < amount:
raise Exception("balance not enough")
accounts[user_id] -= amount
mark_step_done(payment_reservations, order_id)
def compensate_payment(order_id):
with lock:
if not idempotent_step_done(payment_reservations, order_id):
return
order = orders.get(order_id)
if not order:
return
accounts[order["userId"]] += order["amount"]
payment_reservations[order_id] = False
if __name__ == "__main__":
app.run(debug=True)
运行方式
先安装依赖:
pip install flask
python app.py
正常执行
curl -X POST http://127.0.0.1:5000/saga/order \
-H "Content-Type: application/json" \
-d '{
"userId": "user-1",
"itemId": "item-1",
"quantity": 2,
"amount": 20
}'
注入支付失败,观察补偿
curl -X POST http://127.0.0.1:5000/saga/order \
-H "Content-Type: application/json" \
-d '{
"userId": "user-1",
"itemId": "item-1",
"quantity": 2,
"amount": 20,
"injectFailureAt": "payment"
}'
查询 Saga 状态
curl http://127.0.0.1:5000/saga/<sagaId>
现象复现:3 类最常见的线上故障
下面这部分是我觉得最有价值的:不是只讲原理,而是告诉你线上一般怎么坏。
场景 1:订单卡在处理中,迟迟不结束
现象
- 用户侧看到“下单中”
- Saga 状态停在
STARTED或COMPENSATING - 下游某一步没有最终状态
常见原因
- 编排器调用下游超时,但没有明确重试策略
- 本地事务成功了,但响应丢失
- 编排状态更新失败,业务做完了但 saga 表没写进去
典型误区
最容易犯的错误是:
“超时 == 失败”
在分布式系统里,超时只代表:你不知道它成功还是失败。
这时最正确的动作通常不是立刻补偿,而是先做查询确认。
建议做法
为每个步骤设计 3 个接口:
executequerycompensate
如果 execute 超时:
- 先调用
query - 如果确认已成功,继续下一个步骤
- 如果确认未执行,再重试
- 如果状态未知,进入人工或延迟恢复队列
场景 2:补偿执行了,但数据越来越乱
现象
- 库存被多加
- 余额被多退
- 订单状态与库存状态不一致
常见原因
- 补偿接口不是幂等的
- 补偿顺序写错
- 同一个 saga 被并发补偿多次
- 补偿依据“当前状态”而不是“原始操作记录”
我踩过的一个坑
有一次库存补偿是直接:
inventory += quantity
看起来没问题,但如果补偿消息重复投递两次,就会多加一次库存。
后来改成基于冻结记录来补偿,只有存在冻结记录时才释放,而且释放后把冻结标记改掉,这才真正稳下来。
正确原则
补偿必须满足:
- 幂等
- 可审计
- 基于操作日志/冻结记录
- 不依赖模糊的当前值推断
场景 3:业务明明成功了,但用户看到失败
现象
- 实际订单已确认
- 库存和余额都已处理
- 前端却提示“下单失败”
常见原因
- 最后一跳响应丢失
- 编排器在返回前崩溃
- 客户端超时过短
这种场景很危险,因为用户可能再次发起下单,导致重复业务。
止血方案
短期止血最有效的是两件事:
-
给客户端引入业务幂等键
- 如
requestId - 同一个
requestId多次提交返回同一订单结果
- 如
-
前端失败后先查单再重试
- 不是直接再次下单
- 而是查询“这次请求到底有没有成功”
定位路径:排错不要靠猜
排查 Saga 问题,我建议固定按下面这条路径走。
flowchart TD
A[收到故障现象] --> B[确认 sagaId / orderId / requestId]
B --> C[查看编排器状态机记录]
C --> D{卡在哪个步骤?}
D -->|执行前| E[查上一步是否真的完成]
D -->|执行中| F[查下游服务日志与超时情况]
D -->|补偿中| G[查补偿幂等记录与重试次数]
E --> H[核对消息/调用链追踪]
F --> H
G --> H
H --> I[确认是重复执行、状态丢失还是补偿失败]
I --> J[临时止血]
J --> K[补监控与修复设计]
第一步:先拿到关联 ID
线上排查最怕没有统一标识。
建议至少串起来这几个字段:
requestId:客户端请求幂等键sagaId:一次 Saga 实例orderId:业务实体 IDtraceId:调用链跟踪 ID
如果日志里只有 orderId,没有 sagaId,排查效率会差很多。
第二步:确认“步骤真实执行状态”
不要只看编排器日志,也不要只看下游服务日志。
要交叉确认:
- 编排器认为这一步执行了吗?
- 下游服务实际上执行了吗?
- 执行记录是否持久化?
- 响应有没有丢?
这是判断“该继续还是该补偿”的核心。
第三步:核对消息与重试行为
如果你是通过消息队列驱动步骤,还要看:
- 消息是否重复消费
- 是否发生堆积
- 消费成功前是否提前 ack
- 死信队列是否有残留
- 重试间隔是否过短导致雪崩
常见坑与排查
下面把我见过最常见的一些坑收敛成清单。
1. 只做正向幂等,不做补偿幂等
问题表现
- 正向接口安全
- 一到补偿就重复退款、重复释放库存
排查点
- 补偿表里是否有唯一键
- 是否存在“已补偿”标记
- 是否通过业务流水判断补偿是否执行过
建议
补偿接口要像正向接口一样,单独设计幂等键。
不要把补偿当成附属逻辑。
2. 用数据库当前值直接回滚
问题表现
- 高并发下补偿结果不稳定
- 数值型资源出现偏差
错误示例
UPDATE inventory SET stock = stock + 1 WHERE item_id = 'item-1';
如果你不知道最初到底扣了多少、扣了哪一笔,这种补偿就不可靠。
建议
用冻结表 / 预占表 / 操作流水表做依据,例如:
CREATE TABLE inventory_reservation (
order_id VARCHAR(64) PRIMARY KEY,
item_id VARCHAR(64) NOT NULL,
quantity INT NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
补偿时根据这张表释放,而不是凭空“加回去”。
3. 没有中间状态,只有成功/失败
问题表现
- 出故障时只能看到“失败”
- 不知道是执行失败、等待重试,还是补偿失败
建议状态
至少包括:
STARTEDSTEP_DONECOMPENSATINGCANCELLEDCOMPENSATION_FAILEDCOMPLETED
中间状态不是为了好看,是为了运维和排障。
4. 编排状态和业务状态分离,但更新无原子保障
问题表现
- 业务做完了,Saga 状态没更新
- Saga 状态显示完成,实际业务没做完
根因
本地事务提交与消息发送/状态更新不是一个原子动作。
建议
这是经典问题,通常用 Outbox Pattern 处理更稳。
flowchart LR
A[本地业务事务] --> B[写业务表]
A --> C[写Outbox事件表]
C --> D[后台投递器]
D --> E[消息队列/下游服务]
也就是说:
- 在同一个本地事务里
- 写业务数据
- 写待发送事件
- 再由后台任务异步投递事件
这样能避免“业务成功但事件丢失”。
5. 补偿顺序写反
问题表现
- 已取消订单但库存没释放
- 资金先退了,但库存仍冻结
- 产生临时不一致且无法继续补偿
原则
补偿顺序应与执行顺序相反。
执行顺序:
- 创建订单
- 冻结库存
- 冻结余额
补偿顺序:
- 释放余额
- 释放库存
- 取消订单
如果顺序错了,常常会把后续补偿依赖的上下文一并删掉。
安全/性能最佳实践
Saga 通常不只是一道业务题,也是一道稳定性题。
安全最佳实践
1. 补偿接口要鉴权
很多团队默认“内部接口就安全”,这非常危险。
补偿接口本质上能修改关键业务状态,必须有:
- 服务间身份认证
- 最小权限控制
- 防重放机制
- 操作审计日志
尤其是退款、解冻、取消类操作,权限要比普通查询严格。
2. 敏感信息不要打全量日志
排查分布式事务确实需要日志,但别把这些直接写出去:
- 用户完整支付信息
- 身份证号
- 银行卡号
- 完整请求体中的敏感字段
建议:
- 只记录必要字段
- 对敏感字段脱敏
- 用 traceId 关联,而不是把所有数据直接打日志
3. 人工兜底入口要可控
当 Saga 进入 COMPENSATION_FAILED 时,通常需要人工介入。
这时候后台系统要支持:
- 查看完整步骤
- 单步重试
- 强制补偿
- 挂起处理
- 审计操作人和时间
而不是让研发直接上数据库改状态。
性能最佳实践
1. 缩短 Saga 链路
步骤越多,故障概率越高。
如果某个步骤不是强依赖,不要硬塞进主链路。
例如:
- 主链路:订单、库存、支付
- 异步链路:积分、优惠券通知、短信
2. 避免长时间资源占用
Saga 虽然不持有数据库全局锁,但仍可能通过“冻结资源”间接占用业务资源。
所以要为冻结资源设置:
- 过期时间
- 自动清理任务
- 异常恢复策略
否则会出现大量“幽灵冻结”。
3. 重试要有限制
重试不是越多越好。
建议明确:
- 最大重试次数
- 指数退避
- 熔断策略
- 失败转人工队列
否则在下游故障时,编排器的重试会把整个系统拖垮。
4. 关键步骤尽量本地化决策
如果一个简单动作必须跨多个服务才能确认,就会徒增延迟和不确定性。
能在本地服务内完成校验的,尽量别拆成多个远程调用。
一套可执行的排错清单
如果线上已经出问题了,可以按这个清单快速处理。
止血方案
- 暂停新流量或按租户限流
- 关闭高风险自动补偿,避免越补越乱
- 提高查询接口优先级,先让用户能查到真实状态
- 开启失败 Saga 的隔离队列
- 导出
COMPENSATION_FAILED实例,人工逐条核对
定位清单
- 是否存在重复请求?
- sagaId 是否唯一?
- 当前卡在哪个 step?
- step 的本地事务是否实际提交?
- 是否发生超时但真实成功?
- 补偿是否重复执行?
- 是否有 outbox 未投递?
- 消息队列是否重复消费或堆积?
- 是否缺少中间状态导致误判?
修复清单
- 给步骤加幂等键
- 给补偿加独立幂等保障
- 增加 query 接口用于超时确认
- 引入 outbox 保障业务与事件一致性
- 给 Saga 增加人工处理状态
- 补齐 traceId / sagaId 全链路日志
总结
Saga 不是“分布式事务的银弹”,但它是微服务场景下最务实的一种落地方式。
如果只记住一句话,我建议记这句:
Saga 的难点不在成功流程,而在失败后的可恢复性。
真正可上线、可运维、可排障的 Saga,至少要做到这几点:
- 本地事务边界清晰
- 正向与补偿都幂等
- 状态机完整,不省中间态
- 超时不等于失败,要支持查询确认
- 关键链路有
sagaId和traceId - 补偿失败可转人工,而不是硬重试到天荒地老
- 业务数据与事件发送之间用 outbox 之类的机制兜住
最后给一个很实际的边界建议:
- 如果你的业务动作天然可逆、允许短暂不一致,Saga 很适合
- 如果你的业务动作强一致要求极高、且不可逆,不要勉强套 Saga,应该重新设计业务顺序或引入更强的约束机制
分布式事务真正考验的,不是“把成功流程跑通”,而是“出问题时你有没有能力把系统拉回来”。
而这,恰恰是 Saga 设计与排错的核心。