分布式架构中基于 Saga 模式的跨服务事务设计与落地实践
在单体应用里,我们习惯了数据库事务:一条 begin,中间做几次更新,最后 commit 或 rollback。但一旦系统拆成多个服务,事情就不再那么“听话”了。
比如一个典型的下单流程:
- 订单服务:创建订单
- 库存服务:冻结库存
- 支付服务:扣款
- 营销服务:发优惠券或积分
这些步骤分散在不同服务、不同数据库,甚至不同团队维护。你很难再靠本地事务把它们一次性包起来。这个时候,Saga 模式往往是最现实、最有工程可落地性的选择。
这篇文章我会从架构设计角度,把 Saga 的核心思想、实现方式、代码示例、常见坑和排查思路串起来,尽量讲成一套“拿去就能改造业务”的方法。
背景与问题
为什么跨服务事务难做
跨服务事务的本质难点,不在“调用多个接口”,而在于多个独立资源之间如何保持业务一致性。常见挑战有:
- 每个服务有自己的数据库
- 订单库、库存库、支付库通常不会共享事务上下文。
- 服务调用可能失败
- 网络超时、实例重启、消息重复投递都很常见。
- 外部系统不可回滚
- 比如第三方支付,有时只能“退款”,不能真正回滚原操作。
- 一致性要求经常是业务级,而不是数据库级
- 用户能接受“几秒后订单状态修正”,但不能接受“钱扣了、订单没了”。
很多团队一开始会问:能不能上 2PC、XA?理论上可以,但在微服务环境里,代价通常太大:
- 对数据库和中间件支持要求高
- 锁持有时间长,吞吐受影响
- 一旦协调器或参与方异常,整体恢复复杂
- 不适合高并发、异构系统、跨组织边界场景
所以现实中,大多数互联网业务更倾向于:
- 最终一致性
- 可补偿
- 可观测
- 可重试
这正是 Saga 模式适合的土壤。
方案对比与取舍分析
在真正决定用 Saga 前,先把几个常见方案摆出来比较一下。
| 方案 | 一致性 | 性能 | 落地复杂度 | 适用场景 |
|---|---|---|---|---|
| 本地事务 | 强一致 | 高 | 低 | 单库单服务 |
| 2PC/XA | 强一致 | 中低 | 高 | 少量核心系统、资源受控 |
| TCC | 强一致偏业务 | 中 | 很高 | 资金、库存这类强控制场景 |
| Saga | 最终一致 | 高 | 中 | 大多数跨服务业务流程 |
| 纯消息最终一致 | 最终一致 | 高 | 中 | 异步链路、状态同步 |
Saga 适合什么,不适合什么
适合:
- 下单、履约、营销、通知等长流程业务
- 接受短时间中间态
- 每一步都能定义补偿动作
- 系统规模较大,服务自治明显
不太适合:
- 必须全局强一致、且中间状态完全不可见
- 某些动作天然不可补偿
- 资金核心账务等极度敏感场景(此时更常考虑 TCC 或更强约束模型)
一句话总结:Saga 不是“分布式事务银弹”,它更像一套把失败当常态来设计的业务恢复机制。
核心原理
Saga 的基本思想很朴素:
把一个长事务拆成多个本地事务,每个本地事务成功后继续下一步;如果中间某一步失败,就按相反顺序执行已完成步骤的补偿操作。
一个最小例子
以“创建订单”为例:
- 订单服务:创建订单,状态为
PENDING - 库存服务:冻结库存
- 支付服务:扣款
- 订单服务:状态改为
CONFIRMED
如果支付失败:
- 调用库存服务:释放已冻结库存
- 调用订单服务:将订单改为
CANCELLED
Saga 两种编排方式
1. Choreography:事件编排
没有中央协调者,每个服务监听事件并决定下一步。
优点:
- 服务解耦,天然事件驱动
- 中心节点压力小
缺点:
- 流程链路分散,排查困难
- 流程变更容易牵一发而动全身
- 事件风暴时依赖关系不清晰
2. Orchestration:中心协调
由一个 Saga Orchestrator 统一驱动流程,决定谁先执行、失败后如何补偿。
优点:
- 流程可见性强
- 更适合复杂业务编排
- 更容易做审计、重试、状态机管理
缺点:
- 协调器需要高可用设计
- 会引入中心编排组件
在中型以上团队里,我更常建议复杂主流程走 Orchestration,领域内简单联动走事件驱动。不要为了“去中心化”把排障成本转嫁给未来的自己。
Saga 执行流程图
下面先看一个典型的编排式 Saga 流程。
flowchart TD
A[用户发起下单] --> B[Orchestrator 创建 Saga 实例]
B --> C[订单服务 创建订单 PENDING]
C --> D[库存服务 冻结库存]
D --> E[支付服务 扣款]
E --> F[订单服务 确认订单 CONFIRMED]
F --> G[流程结束 Success]
E -.失败.-> H[库存服务 释放库存]
H --> I[订单服务 取消订单 CANCELLED]
I --> J[流程结束 Failed]
D -.失败.-> I
C -.失败.-> J
时序图:成功与失败分支
sequenceDiagram
participant U as User
participant O as Orchestrator
participant OS as OrderService
participant IS as InventoryService
participant PS as PaymentService
U->>O: createOrder(req)
O->>OS: createPendingOrder()
OS-->>O: orderId
O->>IS: reserve(orderId, items)
IS-->>O: reserved
O->>PS: charge(orderId, amount)
alt 支付成功
PS-->>O: paid
O->>OS: confirm(orderId)
OS-->>O: confirmed
O-->>U: success
else 支付失败
PS-->>O: failed
O->>IS: release(orderId)
IS-->>O: released
O->>OS: cancel(orderId)
OS-->>O: cancelled
O-->>U: failed
end
核心设计要点
Saga 真正难的地方,不是“知道补偿”三个字,而是补偿如何定义、状态如何推进、异常如何恢复。
1. 每一步都必须是本地事务
例如订单服务里的“创建订单”,应该在自己库里原子完成:
- 写
orders - 写
saga_log或outbox - 更新步骤状态
不能一边写库一边依赖远程调用成功,否则失败边界会模糊。
2. 补偿不是回滚,而是反向业务动作
这点很关键。
- 冻结库存的补偿是“解冻库存”
- 扣款的补偿通常不是“数据库回滚”,而是“退款”
- 发券的补偿可能是“撤券”或“标记不可用”
所以,补偿动作必须由业务自己定义,不能幻想数据库替你恢复整个世界。
3. 每个参与方都要幂等
分布式环境里,重复调用是常态:
- 超时后调用方重试
- 消息重复投递
- 协调器崩溃后恢复重放
如果 reserve(orderId) 被调用两次,就不能多冻两次库存。最稳妥的方式是引入业务幂等键,通常就是 sagaId + stepName 或 orderId + actionType。
4. 状态机必须可恢复
Saga 不应该只靠内存里的流程跑。协调器重启后,必须能根据数据库恢复现场,知道:
- 当前 Saga 到哪一步
- 哪些步骤已成功
- 哪些补偿已执行
- 下一步该重试还是终止
5. 失败分为“可重试”和“需补偿”
这是很多实现里最容易混乱的地方。
- 暂时性失败:如网络超时、下游短暂不可用
应优先重试 - 确定性失败:如余额不足、库存不足
应直接进入补偿
如果你把所有失败都立刻补偿,会让系统非常“敏感”;如果所有失败都无限重试,又会拖垮资源。
状态机设计
一个清晰的状态机,能极大降低 Saga 失控的概率。
stateDiagram-v2
[*] --> NEW
NEW --> RUNNING: start
RUNNING --> COMPLETED: all steps success
RUNNING --> COMPENSATING: step failed
COMPENSATING --> COMPENSATED: all compensations success
COMPENSATING --> COMPENSATION_FAILED: compensation error
RUNNING --> FAILED: unrecoverable before compensation
COMPENSATION_FAILED --> COMPENSATING: retry
建议至少区分以下状态:
NEWRUNNINGCOMPLETEDCOMPENSATINGCOMPENSATEDFAILEDCOMPENSATION_FAILED
不要只用“成功/失败”两个状态,后期排查会非常痛苦。
实战代码(可运行)
下面给一个可运行的 Python 示例。它不是生产级框架,但足够演示 Saga 编排、补偿和幂等的核心机制。
你可以直接保存为 saga_demo.py 运行。
from dataclasses import dataclass, field
from typing import Callable, List, Dict
import uuid
class SagaStepError(Exception):
pass
@dataclass
class Step:
name: str
action: Callable[[], None]
compensation: Callable[[], None]
@dataclass
class SagaState:
saga_id: str
status: str = "NEW"
completed_steps: List[str] = field(default_factory=list)
compensated_steps: List[str] = field(default_factory=list)
class InMemoryIdempotencyStore:
def __init__(self):
self.done_keys = set()
def execute_once(self, key: str, func: Callable[[], None]):
if key in self.done_keys:
print(f"[幂等] 跳过重复执行: {key}")
return
func()
self.done_keys.add(key)
class OrderService:
def __init__(self):
self.orders: Dict[str, str] = {}
def create_pending_order(self, order_id: str):
self.orders[order_id] = "PENDING"
print(f"[OrderService] 创建订单 {order_id}, 状态=PENDING")
def confirm_order(self, order_id: str):
self.orders[order_id] = "CONFIRMED"
print(f"[OrderService] 确认订单 {order_id}, 状态=CONFIRMED")
def cancel_order(self, order_id: str):
self.orders[order_id] = "CANCELLED"
print(f"[OrderService] 取消订单 {order_id}, 状态=CANCELLED")
class InventoryService:
def __init__(self):
self.reserved_orders = set()
def reserve(self, order_id: str):
self.reserved_orders.add(order_id)
print(f"[InventoryService] 冻结库存: {order_id}")
def release(self, order_id: str):
self.reserved_orders.discard(order_id)
print(f"[InventoryService] 释放库存: {order_id}")
class PaymentService:
def __init__(self, should_fail: bool = False):
self.paid_orders = set()
self.should_fail = should_fail
def charge(self, order_id: str):
if self.should_fail:
raise SagaStepError(f"[PaymentService] 扣款失败: {order_id}")
self.paid_orders.add(order_id)
print(f"[PaymentService] 扣款成功: {order_id}")
def refund(self, order_id: str):
self.paid_orders.discard(order_id)
print(f"[PaymentService] 退款成功: {order_id}")
class SagaOrchestrator:
def __init__(self, idem_store: InMemoryIdempotencyStore):
self.idem_store = idem_store
def run(self, saga_state: SagaState, steps: List[Step]):
saga_state.status = "RUNNING"
print(f"\n[Saga] 启动 saga_id={saga_state.saga_id}")
try:
for step in steps:
action_key = f"{saga_state.saga_id}:action:{step.name}"
self.idem_store.execute_once(action_key, step.action)
if step.name not in saga_state.completed_steps:
saga_state.completed_steps.append(step.name)
saga_state.status = "COMPLETED"
print(f"[Saga] 执行完成 saga_id={saga_state.saga_id}, status={saga_state.status}")
except Exception as e:
print(f"[Saga] 步骤失败: {e}")
saga_state.status = "COMPENSATING"
for step in reversed(steps):
if step.name in saga_state.completed_steps:
compensation_key = f"{saga_state.saga_id}:compensation:{step.name}"
try:
self.idem_store.execute_once(compensation_key, step.compensation)
if step.name not in saga_state.compensated_steps:
saga_state.compensated_steps.append(step.name)
except Exception as ce:
saga_state.status = "COMPENSATION_FAILED"
print(f"[Saga] 补偿失败: step={step.name}, err={ce}")
raise
saga_state.status = "COMPENSATED"
print(f"[Saga] 补偿完成 saga_id={saga_state.saga_id}, status={saga_state.status}")
def demo(payment_fail: bool):
order_id = str(uuid.uuid4())[:8]
saga_id = str(uuid.uuid4())
order_service = OrderService()
inventory_service = InventoryService()
payment_service = PaymentService(should_fail=payment_fail)
idem_store = InMemoryIdempotencyStore()
orchestrator = SagaOrchestrator(idem_store)
steps = [
Step(
name="create_order",
action=lambda: order_service.create_pending_order(order_id),
compensation=lambda: order_service.cancel_order(order_id)
),
Step(
name="reserve_inventory",
action=lambda: inventory_service.reserve(order_id),
compensation=lambda: inventory_service.release(order_id)
),
Step(
name="charge_payment",
action=lambda: payment_service.charge(order_id),
compensation=lambda: payment_service.refund(order_id)
),
Step(
name="confirm_order",
action=lambda: order_service.confirm_order(order_id),
compensation=lambda: order_service.cancel_order(order_id)
),
]
state = SagaState(saga_id=saga_id)
orchestrator.run(state, steps)
print("\n[最终状态]")
print("saga_status =", state.status)
print("completed_steps =", state.completed_steps)
print("compensated_steps =", state.compensated_steps)
print("order_status =", order_service.orders.get(order_id))
print("inventory_reserved =", order_id in inventory_service.reserved_orders)
print("payment_done =", order_id in payment_service.paid_orders)
if __name__ == "__main__":
print("====== 场景一:成功流程 ======")
demo(payment_fail=False)
print("\n====== 场景二:支付失败触发补偿 ======")
demo(payment_fail=True)
运行效果说明
这个示例里有两个场景:
-
支付成功
- 订单最终为
CONFIRMED - 库存已冻结
- 支付已完成
- Saga 状态为
COMPLETED
- 订单最终为
-
支付失败
- 触发库存释放、订单取消
- Saga 状态为
COMPENSATED
这段代码映射到生产环境时,要怎么升级
这段代码是“教学版”,实际生产中通常还要补上:
- Saga 状态持久化到数据库
- 步骤执行日志、审计日志
- 超时任务与重试调度器
- Outbox 事件投递
- 死信队列处理
- 分布式链路追踪
- 告警与人工干预台
落地架构建议
如果要把 Saga 真正带进线上,建议围绕下面几个组件来设计。
1. Saga Orchestrator
职责:
- 创建 Saga 实例
- 推进状态机
- 决定下一步执行/补偿
- 记录上下文与错误
- 恢复失败实例
可以是:
- 应用内模块
- 独立流程服务
- 基于工作流引擎的实现
2. 参与服务(Participant)
每个服务应提供两类接口:
- 正向动作:如
reserveInventory - 补偿动作:如
releaseInventory
最好都遵循以下要求:
- 幂等
- 有明确业务主键
- 结果可查询
- 能区分“处理中”“成功”“失败”
3. 持久化与消息层
推荐最少具备:
saga_instance:记录整体状态saga_step:记录每一步结果outbox_event:记录待投递事件
很多线上故障不是“逻辑错”,而是状态没记全,重启后不知道该怎么办。
常见坑与排查
这一节我想讲得更接地气一点,因为 Saga 真正折磨人的地方基本都在这里。
坑 1:补偿接口不幂等
现象:
- 库存被重复释放
- 退款被重复发起
- 订单状态被错误覆盖
原因:
补偿动作往往在异常路径里,测试覆盖少,容易漏掉幂等设计。
排查方法:
- 检查是否存在全局幂等键
- 看补偿接口是否支持“已处理直接返回成功”
- 查日志里是否有相同
sagaId + stepName的多次执行
建议:
补偿接口一律按“至少一次调用”设计,而不是按“只会调用一次”设计。
坑 2:把超时当失败,导致误补偿
现象:
- 协调器认为支付失败,触发退款或取消
- 但下游其实已经执行成功,只是响应超时
原因:
网络超时不等于业务失败,这是分布式系统里的经典问题。
排查方法:
- 查协调器超时日志
- 查下游服务执行日志
- 核对业务流水是否实际已落账
建议:
对超时场景采用“查询确认”模式:
- 首先标记为
UNKNOWN - 调用下游查询接口确认结果
- 确认成功则继续,确认失败再补偿
别一超时就回滚,我当年就踩过这个坑,结果库存和支付状态差点打架一下午。
坑 3:补偿顺序错了
现象:
- 先取消订单,再释放库存,导致后续对账困难
- 先撤券,再退款,影响用户体验和账务解释
原因:
正向操作是有依赖顺序的,补偿也必须反向遵循依赖关系。
排查方法:
- 画出正向依赖图
- 检查补偿是否按逆序执行
- 分析是否存在跨步骤隐含依赖
建议:
补偿顺序默认逆序;若有业务例外,必须在设计文档里明确写清楚。
坑 4:Saga 日志缺失,导致无法恢复
现象:
- 服务重启后,不知道哪些步骤做过
- 重试怕重复,不重试又卡死
原因:
状态只保存在内存或零散日志里,没有正式持久化模型。
排查方法:
- 看是否有
saga_instance、saga_step表 - 看每一步的请求参数、响应结果、错误原因是否落库
- 检查是否支持按
saga_id全链路追踪
建议:
宁可一开始多记一些状态,也不要等线上出事后再补日志。
坑 5:补偿本身也会失败
现象:
- 支付失败后库存释放接口又超时
- Saga 长时间停在半补偿状态
原因:
补偿也是远程调用,本身就不可靠。
排查方法:
- 区分“补偿失败”和“原步骤失败”
- 检查补偿失败是否进入重试队列
- 是否有人工介入后台
建议:
对补偿失败要有三层兜底:
- 自动重试
- 死信/异常队列
- 人工运营修复入口
安全/性能最佳实践
Saga 讨论里,大家容易把注意力都放在一致性上,忽略安全和性能。实际上这两个维度同样重要。
安全最佳实践
1. 传递最小必要上下文
跨服务传参时,不要把用户敏感信息、完整支付信息直接塞进 Saga 上下文。
建议只传:
orderIduserId(必要时脱敏)sagaId- 业务摘要字段
敏感数据由服务内部按权限查询。
2. 补偿接口必须鉴权
有些团队觉得“补偿接口是内部接口,无所谓”。这很危险。
至少要做:
- 服务间身份认证
- 请求签名或可信网关校验
- 防重放机制
- 操作审计
特别是退款、释放库存、撤券这类补偿动作,本质上都是高风险操作。
3. 做好审计留痕
建议记录:
- 谁发起了 Saga
- 每一步调用参数摘要
- 每一步返回结果
- 补偿触发原因
- 人工处理记录
这不只是为了排障,很多时候也是合规要求。
性能最佳实践
1. 缩短单步本地事务时间
Saga 性能好不好,很大程度取决于每一步本地事务是否足够轻。
建议:
- 本地事务里只做核心更新
- 非必要逻辑异步化
- 不要在事务内做长时间远程调用
2. 限制 Saga 并发与重试风暴
当下游故障时,如果没有限流和退避机制,重试会把系统打穿。
建议:
- 设置最大重试次数
- 指数退避
- 按服务维度限流
- 熔断失败下游
3. 使用 Outbox 避免“双写不一致”
典型问题:
- 本地数据库提交成功
- 消息发送失败
- 结果下游没收到事件
解决思路是 Transactional Outbox:
- 本地事务中同时写业务数据和 outbox 事件
- 由后台任务可靠投递消息
- 消费方按幂等处理
4. 做容量估算
一个常被忽略的问题是:Saga 会放大调用量。
假设:
- 每秒 1000 个下单请求
- 每个 Saga 4 个正向步骤
- 失败率 5%
- 每次失败平均触发 2 个补偿步骤
那么总调用量大约是:
正向调用 = 1000 * 4 = 4000 次/秒
补偿调用 = 1000 * 5% * 2 = 100 次/秒
总调用量 ≈ 4100 次/秒
如果再叠加重试,峰值可能更高。所以容量评估要把:
- 正向流量
- 补偿流量
- 查询确认流量
- 重试放大量
一起算进去。
一个更贴近生产的表结构示例
下面给一个简化版表结构,方便你落地时有抓手。
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,
context_json TEXT,
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,
step_order INT NOT NULL,
action_status VARCHAR(32) NOT NULL,
compensation_status VARCHAR(32) NOT NULL,
request_json TEXT,
response_json TEXT,
error_msg TEXT,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_saga_step (saga_id, step_name)
);
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload_json TEXT NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_event_id (event_id)
);
表设计建议
saga_instance保存整体状态saga_step保存步骤执行与补偿结果outbox_event保证事件可靠投递- 唯一索引用于幂等控制
如果业务复杂,还可以加:
next_retry_timeretry_countoperatormanual_comment
什么时候该用事件驱动,什么时候该用编排式 Saga
这是设计时很常见的一个分歧点。
更适合事件驱动的时候
- 流程较短
- 服务之间关系稳定
- 对全链路可视化要求不高
- 团队对异步事件模型足够熟悉
例如:
- 用户注册后触发欢迎礼包
- 订单完成后异步发积分
更适合编排式的时候
- 主流程长、步骤多
- 失败补偿复杂
- 需要统一状态视图
- 需要人工介入与审计
例如:
- 订单创建 -> 风控 -> 库存 -> 支付 -> 履约
- 跨多个域、多个团队的业务链路
我个人的经验是:主交易链路优先编排式,外围通知链路优先事件式。这样能兼顾清晰度和扩展性。
总结
Saga 模式解决的,不是“让分布式事务看起来像本地事务”,而是:
- 承认分布式系统天然会失败
- 用业务补偿代替数据库回滚
- 用状态机、幂等、重试和审计把失败管理起来
如果你准备在项目里落地,我建议按下面顺序推进:
- 先定义业务边界
- 哪些步骤必须纳入 Saga
- 哪些动作能补偿,哪些不能
- 再设计状态机
- 不要只留成功/失败两个状态
- 为每一步补上幂等
- 正向动作、补偿动作都要幂等
- 引入持久化日志
- 实例表、步骤表、审计日志一个都别少
- 最后补齐恢复机制
- 超时查询、自动重试、人工介入、告警监控
最后给一个边界判断建议:
- 如果你追求的是全局强一致,Saga 不是最优解;
- 如果你追求的是高可用、可恢复、业务最终一致,Saga 往往是非常实用的方案。
真正成熟的 Saga 设计,不在于图画得多漂亮,而在于线上出问题时,你能不能知道发生了什么、自动补回来多少、剩下多少需要人工处理。这才是分布式事务落地的分水岭。