微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与避坑
在单体时代,我们习惯了一个数据库事务包打天下:扣库存、创建订单、支付扣款,BEGIN 一开,成功就一起提交,失败就一起回滚。
但到了微服务架构,这套玩法很快失效。订单服务、库存服务、支付服务各自有数据库,各自独立部署,跨服务再想靠本地事务“一把梭”基本不现实。很多团队一开始会纠结:要不要上 2PC/XA?理论上很完美,实践里却经常卡在性能、锁持有时间、兼容性和运维复杂度上。
这时,Saga 模式就成了一个更接地气的选择。
这篇文章我会从为什么需要 Saga、Saga 到底怎么设计、代码怎么落地、线上容易踩哪些坑,一路带你走一遍。文章偏实战,适合已经做过微服务、但在分布式事务上还不够踏实的同学。
背景与问题
先看一个典型下单流程:
- 订单服务创建订单
- 库存服务冻结库存
- 支付服务扣款
- 物流服务创建发货单
如果第 3 步支付失败,前面的订单和库存怎么办?
在单体里,回滚就行;在微服务里,这几个动作已经发生在不同服务、不同数据库里了。于是你会遇到几个很现实的问题:
- 本地事务只对单服务生效
- 跨服务调用天然存在网络失败、超时、重试
- 服务间状态可能短暂不一致
- 单纯依赖“失败就回滚”不再成立
为什么不直接用 2PC/XA?
不是不能用,而是很多业务系统不适合:
- 参与方都要支持 XA
- 长事务导致资源锁持有时间长
- 协调器本身变成核心基础设施
- 在高并发下吞吐下降明显
- 云原生环境和多种存储混搭时兼容性差
如果你的场景是“金融核心账务、强一致极高优先级、参与资源有限且可控”,2PC 可能仍有位置;但在大多数互联网业务里,最终一致性 + 补偿机制通常更符合工程现实。
Saga 解决什么问题?
Saga 的核心思想可以概括成一句话:
把一个长事务拆成多个本地事务,每一步成功后继续下一步;如果某一步失败,就按相反顺序执行补偿操作。
它不追求“所有服务瞬时一致”,而是接受短暂不一致,通过补偿恢复到业务可接受状态。
方案对比与取舍分析
Saga 不是唯一选择。先把它放到常见方案里对比一下。
| 方案 | 一致性 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 较低 | 高 | 少量核心系统、强一致要求极高 |
| TCC | 很强的业务控制力 | 中 | 很高 | 核心交易、账户类系统 |
| Saga | 最终一致 | 较高 | 中 | 订单、履约、库存、营销等流程型业务 |
| 本地消息表/事件驱动 | 最终一致 | 高 | 中 | 异步解耦明显的业务 |
Saga 和 TCC 的差别
很多人第一次接触时会把 Saga 和 TCC 混在一起。
- TCC:Try / Confirm / Cancel,业务接口天生按预留资源设计,侵入性强,但控制力极高。
- Saga:直接把现有业务动作串起来,再为每个动作定义补偿逻辑,改造成本通常更低。
一个很实用的判断标准:
- 如果你能清晰做“资源预留”,如账户冻结、库存预占,且愿意改造接口,优先考虑 TCC
- 如果你面对的是跨多个已有服务的业务编排,希望在现有接口基础上演进,优先考虑 Saga
核心原理
Saga 有两种常见实现方式:
- Choreography(事件编排 / 去中心化):服务之间通过事件相互驱动
- Orchestration(集中编排):由一个 Saga Orchestrator 负责推进流程
对于中级读者和大多数团队落地来说,我更推荐先从集中编排入手,因为:
- 流程可视化更直观
- 状态管理更集中
- 排查问题更容易
- 更适合一开始建立工程规范
Saga 的基本组成
一个完整的 Saga 通常包含:
- Saga 实例 ID
- 步骤定义
- 每一步的执行动作
- 每一步的补偿动作
- 状态持久化
- 重试与幂等控制
- 超时与人工介入机制
流程图:下单 Saga
flowchart TD
A[创建订单] --> B[冻结库存]
B --> C[创建支付单]
C --> D{支付是否成功}
D -- 是 --> E[确认订单]
D -- 否 --> F[解冻库存]
F --> G[取消订单]
时序图:正常路径与补偿路径
sequenceDiagram
participant Client as Client
participant Orchestrator as Saga Orchestrator
participant Order as Order Service
participant Inventory as Inventory Service
participant Payment as Payment Service
Client->>Orchestrator: 发起下单
Orchestrator->>Order: createOrder()
Order-->>Orchestrator: orderCreated
Orchestrator->>Inventory: reserveStock()
Inventory-->>Orchestrator: stockReserved
Orchestrator->>Payment: createPayment()
Payment-->>Orchestrator: paymentFailed
Orchestrator->>Inventory: releaseStock()
Inventory-->>Orchestrator: stockReleased
Orchestrator->>Order: cancelOrder()
Order-->>Orchestrator: orderCancelled
状态机思维很重要
Saga 不是“写几个 if-else 调接口”那么简单,它本质上是一个状态机。
stateDiagram-v2
[*] --> INIT
INIT --> ORDER_CREATED
ORDER_CREATED --> STOCK_RESERVED
STOCK_RESERVED --> PAYMENT_CREATED
PAYMENT_CREATED --> COMPLETED
PAYMENT_CREATED --> COMPENSATING
STOCK_RESERVED --> COMPENSATING
ORDER_CREATED --> COMPENSATING
COMPENSATING --> COMPENSATED
COMPLETED --> [*]
COMPENSATED --> [*]
如果你不把它当状态机管理,线上一旦发生重试、超时、重复消息、服务部分成功,你很快就会掉进“到底该不该补偿”的混乱里。
设计落地:一个可执行的 Saga 实现
下面用 Python 做一个可运行的最小 Saga 示例。它不依赖真实微服务,但会完整展示:
- 步骤定义
- 补偿逻辑
- 状态流转
- 幂等处理
- 失败回滚
你可以把它理解成 Orchestrator 的骨架。
实战代码(可运行)
from dataclasses import dataclass, field
from typing import Callable, List, Dict, Any
import uuid
# ===== 模拟服务数据库 =====
db = {
"orders": {},
"inventory": {"sku-1": 10},
"payments": {}
}
# 幂等日志:防止重复执行
idempotency_log = set()
def idempotent(key: str):
if key in idempotency_log:
return False
idempotency_log.add(key)
return True
# ===== 业务服务 =====
class OrderService:
@staticmethod
def create_order(saga_id: str, order_id: str, sku: str, amount: int):
key = f"create_order:{saga_id}"
if not idempotent(key):
return {"status": "duplicated", "order_id": order_id}
db["orders"][order_id] = {
"status": "CREATED",
"sku": sku,
"amount": amount
}
return {"status": "success", "order_id": order_id}
@staticmethod
def cancel_order(saga_id: str, order_id: str):
key = f"cancel_order:{saga_id}"
if not idempotent(key):
return {"status": "duplicated", "order_id": order_id}
order = db["orders"].get(order_id)
if order:
order["status"] = "CANCELLED"
return {"status": "success", "order_id": order_id}
class InventoryService:
@staticmethod
def reserve_stock(saga_id: str, sku: str, count: int):
key = f"reserve_stock:{saga_id}"
if not idempotent(key):
return {"status": "duplicated", "sku": sku}
stock = db["inventory"].get(sku, 0)
if stock < count:
raise Exception("库存不足")
db["inventory"][sku] -= count
return {"status": "success", "sku": sku, "remain": db["inventory"][sku]}
@staticmethod
def release_stock(saga_id: str, sku: str, count: int):
key = f"release_stock:{saga_id}"
if not idempotent(key):
return {"status": "duplicated", "sku": sku}
db["inventory"][sku] = db["inventory"].get(sku, 0) + count
return {"status": "success", "sku": sku, "remain": db["inventory"][sku]}
class PaymentService:
@staticmethod
def create_payment(saga_id: str, payment_id: str, order_id: str, amount: int, should_fail=False):
key = f"create_payment:{saga_id}"
if not idempotent(key):
return {"status": "duplicated", "payment_id": payment_id}
if should_fail:
raise Exception("支付失败")
db["payments"][payment_id] = {
"order_id": order_id,
"amount": amount,
"status": "PAID"
}
return {"status": "success", "payment_id": payment_id}
@staticmethod
def refund_payment(saga_id: str, payment_id: str):
key = f"refund_payment:{saga_id}"
if not idempotent(key):
return {"status": "duplicated", "payment_id": payment_id}
payment = db["payments"].get(payment_id)
if payment:
payment["status"] = "REFUNDED"
return {"status": "success", "payment_id": payment_id}
# ===== Saga 核心 =====
@dataclass
class SagaStep:
name: str
action: Callable[[], Any]
compensation: Callable[[], Any]
@dataclass
class SagaExecution:
saga_id: str
steps: List[SagaStep]
completed_steps: List[SagaStep] = field(default_factory=list)
state: str = "INIT"
def execute(self):
print(f"[Saga] start: {self.saga_id}")
try:
for step in self.steps:
print(f"[Saga] execute step: {step.name}")
result = step.action()
print(f"[Saga] step result: {result}")
self.completed_steps.append(step)
self.state = "COMPLETED"
print(f"[Saga] completed: {self.saga_id}")
except Exception as e:
print(f"[Saga] failed: {e}")
self.state = "COMPENSATING"
self.compensate()
self.state = "COMPENSATED"
def compensate(self):
for step in reversed(self.completed_steps):
try:
print(f"[Saga] compensate step: {step.name}")
result = step.compensation()
print(f"[Saga] compensation result: {result}")
except Exception as e:
print(f"[Saga] compensation failed on {step.name}: {e}")
# 真实系统里此处不能简单打印,要持久化并告警
def run_demo(should_fail_payment: bool):
saga_id = str(uuid.uuid4())
order_id = "order-1001"
payment_id = "pay-9001"
sku = "sku-1"
amount = 100
count = 2
saga = SagaExecution(
saga_id=saga_id,
steps=[
SagaStep(
name="create_order",
action=lambda: OrderService.create_order(saga_id, order_id, sku, amount),
compensation=lambda: OrderService.cancel_order(saga_id, order_id)
),
SagaStep(
name="reserve_stock",
action=lambda: InventoryService.reserve_stock(saga_id, sku, count),
compensation=lambda: InventoryService.release_stock(saga_id, sku, count)
),
SagaStep(
name="create_payment",
action=lambda: PaymentService.create_payment(
saga_id, payment_id, order_id, amount, should_fail=should_fail_payment
),
compensation=lambda: PaymentService.refund_payment(saga_id, payment_id)
),
]
)
saga.execute()
print("final saga state:", saga.state)
print("db snapshot:", db)
if __name__ == "__main__":
print("=== 场景1:支付成功 ===")
run_demo(should_fail_payment=False)
print("\n=== 场景2:支付失败,触发补偿 ===")
# 为了方便演示,重置部分数据
db["orders"].clear()
db["payments"].clear()
db["inventory"]["sku-1"] = 10
idempotency_log.clear()
run_demo(should_fail_payment=True)
运行效果你会看到什么?
- 支付成功时,Saga 状态进入
COMPLETED - 支付失败时,会自动逆序补偿:
- 释放库存
- 取消订单
这就是 Saga 的基本形态。
从 Demo 到生产:真正要补上的工程能力
上面的代码只是骨架,真实生产系统还需要补上这几层。
1. Saga 状态持久化
不能只放在内存里。至少要落库保存:
saga_id- 当前状态
- 已完成步骤
- 步骤输入参数
- 错误信息
- 重试次数
- 更新时间
一个简单表结构示例:
CREATE TABLE saga_instance (
saga_id VARCHAR(64) PRIMARY KEY,
business_key VARCHAR(128) NOT NULL,
state VARCHAR(32) NOT NULL,
current_step VARCHAR(64),
payload TEXT,
retry_count INT DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
2. Outbox 保证“本地事务 + 发消息”一致
如果你的 Saga 推进依赖消息队列,一个经典问题是:
- 本地数据库提交成功
- 但消息没发出去
结果下游永远收不到事件,Saga 卡死。
常见解法是 Transactional Outbox:
- 在本地事务中同时写业务表和 outbox 表
- 后台任务轮询 outbox 表投递 MQ
- 投递成功后标记发送状态
flowchart LR
A[业务请求] --> B[本地事务]
B --> C[写业务数据]
B --> D[写Outbox事件]
D --> E[后台投递器]
E --> F[消息队列]
F --> G[下游服务]
3. 幂等必须贯穿全链路
我想强调一句:Saga 不是怕失败,而是怕“重复执行后的混乱”。
因为你几乎一定会遇到:
- 消息重复投递
- 接口超时后重试
- 补偿任务重复触发
- 消费者宕机恢复后重复处理
幂等常见做法:
- 唯一业务键约束
- 请求号 / 幂等号
- 消费去重表
- 状态机校验:只允许从合法状态迁移
比如取消订单时,不应该无脑改状态,而要校验当前是否还能取消:
def cancel_order_if_allowed(order):
if order["status"] in ("CANCELLED", "COMPLETED"):
return
order["status"] = "CANCELLED"
常见坑与排查
这一节是实战里最容易翻车的地方。我把几个常见问题集中讲透。
1. 补偿不等于回滚
这是很多人第一次做 Saga 时最容易误解的点。
数据库回滚是“精确恢复到之前状态”,但 Saga 补偿往往做不到这么理想。比如:
- 优惠券已经核销并给用户发了通知
- 支付已经成功并触发了第三方清结算
- 库存已经被后续流程占用
这时补偿不是“像没发生过”,而是“做一个业务上可接受的反向动作”。
正确理解
- 扣款失败 → 订单取消、库存释放:比较自然
- 已支付成功但发货失败 → 可能是退款而不是简单“删除支付记录”
- 已发券失败 → 可能是再发一张补偿券,而不是硬撤销
所以补偿动作要按业务语义设计,不是按数据库语义设计。
2. 补偿接口本身失败怎么办?
这在生产上非常常见,甚至比主流程失败更常见。
例如:
- 库存服务短时不可用
- 取消订单接口超时
- MQ 消费积压导致补偿延迟
处理建议
- 补偿动作也要支持重试
- 补偿失败必须持久化
- 加告警和人工介入入口
- 区分“可自动恢复”和“需人工处理”的异常
一个实用经验是:
不要把“补偿失败”只记在日志里。
日志不是任务系统,日志也不是状态机。
3. 空补偿与悬挂问题
这两个词在分布式事务里经常一起出现。
空补偿
补偿请求先到了,但对应的正向操作其实还没成功或根本没执行。
例子:
- 由于网络乱序,先收到“释放库存”
- 但“冻结库存”其实没成功
如果你的补偿逻辑不做检查,库存就会凭空加回去。
悬挂
某个操作已经被判定要补偿,但由于并发或延迟,正向请求又晚到了,导致数据再次被改动。
处理方法
- 给每一步设置唯一事务号
- 正向、补偿都记录执行状态
- 通过状态机控制“是否允许执行”
- 补偿前检查正向步骤是否真的成功过
4. 把 Saga 设计成同步串行长链路
有些系统虽然用了 Saga,但实现上还是同步 HTTP 一路串到底:
- 订单调库存
- 库存调支付
- 支付调营销
- 营销调物流
最后整个调用链 5 秒起步,超时率居高不下。
建议
- 编排层统一推进,不要让服务层互相嵌套失控
- 可异步的步骤尽量异步
- 给外部请求尽快返回“处理中”
- 后续通过状态查询或消息通知告知结果
换句话说,Saga 适合长流程,但不适合做超长同步阻塞请求。
5. 监控不到 Saga 卡在哪一步
线上排查最痛苦的不是失败,而是“不知道它停在哪”。
必备观测字段
- saga_id
- business_key(如 order_id)
- current_step
- state
- retry_count
- last_error
- step_latency
- compensation_flag
如果你们接了链路追踪系统,最好把 saga_id 放进 trace 或日志 MDC 里。这样排查某一笔订单时,会轻松很多。
安全/性能最佳实践
这一部分通常容易被忽略,但实际上决定你这个方案能不能撑住线上。
安全最佳实践
1. 补偿接口必须鉴权
很多团队把补偿接口当内部接口,结果权限做得很弱。实际上它的风险很高:
- 取消订单
- 释放库存
- 发起退款
这些都是高敏操作。
建议:
- 只允许内网或网关访问
- 服务间鉴权使用 mTLS / 签名 / Token
- 补偿接口记录完整审计日志
2. 避免用可猜测业务 ID 直接触发补偿
比如直接调用:
POST /orders/cancel?orderId=10001
如果没有额外校验,风险非常大。更推荐:
- 使用内部
saga_id驱动 - 校验当前状态是否合法
- 限制来源服务身份
3. 防止重复退款、重复释放
高风险补偿动作一定要幂等,并且建议加:
- 唯一流水号
- 审批或人工复核阈值
- 风控校验
性能最佳实践
1. 补偿动作要轻量
补偿不是越复杂越好,越复杂越容易再次失败。优先保证:
- 原子性
- 幂等性
- 可重试性
2. 减少跨服务强依赖
不是每一步都必须实时调用。能异步就异步,能本地落状态就不要远程阻塞。
3. 热点步骤单独扩容
在订单链路里,往往是库存和支付最容易成为热点。Saga 编排器虽然重要,但通常不是唯一瓶颈。要分别看:
- 步骤平均耗时
- 失败率
- 重试流量放大
- 补偿流量峰值
4. 做容量估算时别忘了“失败流量”
很多团队只按成功 QPS 算容量,忽略了补偿和重试。
一个粗略估算公式:
- 正向请求量:
QPS - 平均步骤数:
N - 失败率:
F - 每次失败平均补偿步数:
C - 重试放大系数:
R
那么总调用量可近似估算为:
总调用量 ≈ QPS × N + QPS × F × C × R
举个例子:
- QPS = 1000
- N = 4
- F = 5%
- C = 2
- R = 2
则额外补偿相关调用量约为:
1000 × 0.05 × 2 × 2 = 200
总量约 4200 次调用单位,而不是你想象中的 4000。
失败率一高,放大很明显。
落地建议:一个更稳的实施路径
如果你所在团队还没系统化做过 Saga,我建议不要一上来就全链路大改。更稳的路径是:
第一步:先挑“天然适合补偿”的场景
例如:
- 订单创建 + 库存冻结 + 支付单创建
- 营销发券 + 用户资产变更
- 履约派单 + 状态同步
避开那些“几乎不可逆”的场景做第一枪。
第二步:先做编排式 Saga
因为更容易:
- 管流程
- 打日志
- 做排查
- 建告警
等团队对补偿、幂等、状态机都形成共识后,再考虑事件驱动的去中心化扩展。
第三步:先把失败链路打透
很多项目只验证成功路径,失败时全靠“应该能补偿”。这是很危险的。
至少要演练这些场景:
- 第 2 步失败
- 第 3 步超时
- 补偿接口超时
- MQ 重复消息
- Orchestrator 重启恢复
- 人工重试补偿
第四步:建立人工兜底机制
再成熟的 Saga 也不是 100% 自动恢复。你需要:
- 后台可查看 Saga 实例
- 支持人工触发重试
- 支持人工标记已处理
- 能导出失败明细做对账
这一步很土,但特别重要。真正线上出事故时,能救命的往往不是理论,而是有没有兜底工具。
一个实用的判断:哪些业务不适合 Saga?
Saga 很好,但不是万能钥匙。
以下场景要慎用:
- 要求严格实时强一致,且不能接受短暂不一致
- 补偿动作几乎无法定义
- 每一步都涉及不可逆外部副作用
- 状态流转极其复杂但团队还缺少状态机治理能力
这时你可能需要重新评估:
- 是否应改用 TCC
- 是否收敛服务边界
- 是否回到单体或模块化单体处理核心交易
架构没有银弹,适配业务边界比“上什么名词”更重要。
总结
Saga 模式的价值,不在于它把分布式事务“变简单”了,而在于它把问题从基础设施强一致,转成了业务可补偿的一致性设计。
你可以把本文记成 5 句话:
- Saga 用多个本地事务 + 补偿动作,解决微服务长事务问题
- 它追求最终一致,不追求瞬时强一致
- 真正难点不在调用链,而在状态机、幂等、补偿和观测性
- 补偿不是数据库回滚,而是业务语义上的反向修复
- 没有持久化、重试、告警和人工兜底的 Saga,基本不算可上线
如果你准备在项目里落地,我的建议很明确:
- 优先选一个补偿语义清晰的流程试点
- 先上编排式 Saga
- 把状态持久化、幂等、Outbox、监控做完整
- 重点演练失败路径,不要只看 happy path
- 为补偿失败准备人工处理工具
最后说一句我自己踩坑后的感受:
分布式事务从来不是“怎么保证永不失败”,而是“失败之后能不能有秩序地恢复”。
而 Saga,正是把这种“有秩序的恢复”工程化的一种很务实的方法。