背景与问题
在单体时代,事务这件事通常没那么“折磨人”。一条 BEGIN,几条 SQL,最后 COMMIT,心里很踏实。可一旦拆成微服务,订单、库存、支付、积分各自有库,各自部署,各自扩缩容,原来那个本地事务的安全感就没了。
很多团队第一次碰到分布式事务时,会下意识去找“全局锁”和“强一致”方案,比如 2PC/XA。但在微服务里,这类方案常常会把系统拖进另一个坑:性能下降、长事务、资源锁持有时间过长、故障放大。于是,Saga 模式成了很多业务系统更现实的选择。
但 Saga 也不是“用了就稳”。我自己踩过的坑里,最典型的几个是:
- 订单创建成功了,库存也扣了,但支付超时,最后订单取消了,库存没加回来
- 补偿接口写得像普通回滚,结果重复调用时把数据补“穿了”
- 消息丢了,或者消费了两次,流程状态彻底乱掉
- 线上排查时只看到“事务失败”,却不知道是哪个子步骤卡住了
这篇文章不打算只讲概念,而是从实战落地和故障排查的角度,带你把 Saga 的设计、补偿机制、代码实现,以及常见坑的定位路径串起来。
背景案例:一个最容易出事故的下单流程
我们先用一个典型链路作为主线:
- 订单服务创建订单
- 库存服务冻结库存
- 支付服务扣款
- 订单服务确认成功
如果任一步失败,就按相反顺序执行补偿:
- 支付成功后订单确认失败:退款
- 库存已冻结但支付失败:释放库存
- 订单已创建但库存失败:取消订单
这就是 Saga 的基本思路:把一个长事务拆成多个本地事务,每个本地事务都配一个补偿动作。
flowchart LR
A[创建订单] --> B[冻结库存]
B --> C[支付扣款]
C --> D[确认订单]
C -.失败补偿.-> E[释放库存]
B -.失败补偿.-> F[取消订单]
D -.失败补偿.-> G[退款]
核心原理
1. Saga 到底解决了什么
Saga 解决的不是“绝对实时的全局一致性”,而是:
- 避免跨服务持有全局锁
- 通过本地事务 + 补偿动作实现最终一致性
- 让业务在复杂链路下依然能恢复到可接受状态
说得更直白一点:
Saga 承认“中间状态会暂时不一致”,但要求系统最终能回到正确结果。
2. 两种常见实现方式
Saga 常见有两种:
编排式(Orchestration)
由一个 Saga Coordinator 统一调度各服务。
优点:
- 流程集中,易观测
- 故障定位相对简单
- 适合链路较长、补偿逻辑复杂的场景
缺点:
- 协调器容易变成核心依赖
- 流程变更常需修改协调逻辑
协同式(Choreography)
服务之间通过事件自行驱动下一步,无中心协调器。
优点:
- 去中心化
- 服务自治更强
缺点:
- 流程分散
- 出问题时更难排查
- 容易形成“事件风暴”
对中级工程师来说,我的建议很实际:
如果你的团队还在建设期,先用编排式,排障成本更低。
3. Saga 的关键设计原则
补偿不是数据库回滚
这是最容易误解的一点。
数据库回滚依赖锁和日志,Saga 补偿是一个新的业务动作。比如:
- 扣库存的补偿不是“时光倒流”,而是“增加可用库存”
- 支付扣款的补偿不是“撤销 SQL”,而是“发起退款”
- 创建订单的补偿不是“delete from orders”,而是“标记为已取消”
补偿必须幂等
补偿请求可能因为超时、重试、重复消费,被执行多次。
如果补偿不是幂等的,第二次执行就会把状态弄坏。
每一步都要可观测
一个事务链路里,必须能追踪:
- Saga ID
- 当前步骤
- 步骤状态
- 补偿状态
- 错误信息
- 重试次数
否则线上问题来了,只能靠猜。
一个可落地的 Saga 状态模型
实践里,我通常会要求给 Saga 单独建一张状态表,不要只靠日志拼凑。
stateDiagram-v2
[*] --> PENDING
PENDING --> ORDER_CREATED
ORDER_CREATED --> INVENTORY_RESERVED
INVENTORY_RESERVED --> PAYMENT_COMPLETED
PAYMENT_COMPLETED --> COMPLETED
INVENTORY_RESERVED --> COMPENSATING
PAYMENT_COMPLETED --> COMPENSATING
ORDER_CREATED --> COMPENSATING
COMPENSATING --> COMPENSATED
COMPENSATING --> FAILED
COMPLETED --> [*]
COMPENSATED --> [*]
FAILED --> [*]
建议至少记录这些字段:
saga_idbusiness_id,比如order_idcurrent_stepstatuslast_errorretry_countupdated_at
实战代码(可运行)
下面用 Python 做一个简化版、可直接运行的编排式 Saga 示例。
它不依赖消息队列和数据库,重点是把流程控制、补偿、幂等讲清楚。你可以先在本地跑通,再迁移到真实服务里。
1. 示例目标
模拟一个下单事务:
- 创建订单
- 冻结库存
- 扣减余额
- 任一步失败则补偿
2. 代码实现
from dataclasses import dataclass, field
from typing import Callable, List, Dict
import uuid
@dataclass
class SagaStep:
name: str
action: Callable[[], None]
compensate: Callable[[], None]
@dataclass
class SagaContext:
saga_id: str
order_id: str
executed_steps: List[SagaStep] = field(default_factory=list)
logs: List[str] = field(default_factory=list)
class InMemoryStore:
def __init__(self):
self.orders: Dict[str, str] = {}
self.inventory_reserved: Dict[str, bool] = {}
self.account_balance: Dict[str, int] = {"u1001": 1000}
self.payment_done: Dict[str, bool] = {}
self.compensation_done: Dict[str, set] = {}
def mark_compensated(self, saga_id: str, step_name: str):
self.compensation_done.setdefault(saga_id, set()).add(step_name)
def is_compensated(self, saga_id: str, step_name: str) -> bool:
return step_name in self.compensation_done.get(saga_id, set())
store = InMemoryStore()
class OrderService:
def create_order(self, order_id: str):
if order_id in store.orders:
raise Exception("订单已存在")
store.orders[order_id] = "CREATED"
def cancel_order(self, order_id: str, saga_id: str):
step = "create_order"
if store.is_compensated(saga_id, step):
return
if store.orders.get(order_id) == "CREATED":
store.orders[order_id] = "CANCELLED"
store.mark_compensated(saga_id, step)
class InventoryService:
def reserve(self, order_id: str):
if store.inventory_reserved.get(order_id):
raise Exception("库存已冻结")
store.inventory_reserved[order_id] = True
def release(self, order_id: str, saga_id: str):
step = "reserve_inventory"
if store.is_compensated(saga_id, step):
return
if store.inventory_reserved.get(order_id):
store.inventory_reserved[order_id] = False
store.mark_compensated(saga_id, step)
class PaymentService:
def pay(self, user_id: str, order_id: str, amount: int, fail=False):
if fail:
raise Exception("模拟支付失败")
if store.account_balance[user_id] < amount:
raise Exception("余额不足")
store.account_balance[user_id] -= amount
store.payment_done[order_id] = True
def refund(self, user_id: str, order_id: str, amount: int, saga_id: str):
step = "pay_order"
if store.is_compensated(saga_id, step):
return
if store.payment_done.get(order_id):
store.account_balance[user_id] += amount
store.payment_done[order_id] = False
store.mark_compensated(saga_id, step)
class SagaCoordinator:
def __init__(self, context: SagaContext):
self.context = context
def execute(self, steps: List[SagaStep]):
try:
for step in steps:
step.action()
self.context.executed_steps.append(step)
self.context.logs.append(f"执行成功: {step.name}")
self.context.logs.append("Saga 执行完成")
except Exception as e:
self.context.logs.append(f"执行失败: {str(e)}")
self.compensate()
raise
def compensate(self):
self.context.logs.append("开始补偿")
for step in reversed(self.context.executed_steps):
try:
step.compensate()
self.context.logs.append(f"补偿成功: {step.name}")
except Exception as e:
self.context.logs.append(f"补偿失败: {step.name}, error={str(e)}")
self.context.logs.append("补偿结束")
def main(simulate_payment_fail=True):
order_service = OrderService()
inventory_service = InventoryService()
payment_service = PaymentService()
saga_id = str(uuid.uuid4())
order_id = "O20231114001"
user_id = "u1001"
amount = 300
context = SagaContext(saga_id=saga_id, order_id=order_id)
coordinator = SagaCoordinator(context)
steps = [
SagaStep(
name="create_order",
action=lambda: order_service.create_order(order_id),
compensate=lambda: order_service.cancel_order(order_id, saga_id),
),
SagaStep(
name="reserve_inventory",
action=lambda: inventory_service.reserve(order_id),
compensate=lambda: inventory_service.release(order_id, saga_id),
),
SagaStep(
name="pay_order",
action=lambda: payment_service.pay(user_id, order_id, amount, fail=simulate_payment_fail),
compensate=lambda: payment_service.refund(user_id, order_id, amount, saga_id),
),
]
try:
coordinator.execute(steps)
except Exception:
pass
print("==== LOGS ====")
for log in context.logs:
print(log)
print("\n==== STORE ====")
print("orders:", store.orders)
print("inventory_reserved:", store.inventory_reserved)
print("account_balance:", store.account_balance)
print("payment_done:", store.payment_done)
print("compensation_done:", store.compensation_done)
if __name__ == "__main__":
main(simulate_payment_fail=True)
3. 如何运行
python saga_demo.py
当 simulate_payment_fail=True 时,预期输出会类似:
- 订单先创建成功
- 库存冻结成功
- 支付失败
- 自动执行释放库存、取消订单
4. 这个示例里故意体现了什么
幂等补偿
compensation_done 用于记录某个 Saga 的某个补偿步骤是否已经执行。
这样即使补偿重复触发,也不会反复释放库存或反复退款。
逆序补偿
补偿按已成功步骤的逆序执行,这是 Saga 的基本规则。
先做的后补,后做的先补。
显式日志
context.logs 虽然简单,但体现了一个真实原则:
Saga 的每一步都要留下结构化记录。
一次完整的时序图
为了更贴近真实系统,我们再看一张时序图。
sequenceDiagram
participant Client as Client
participant SC as SagaCoordinator
participant OS as OrderService
participant IS as InventoryService
participant PS as PaymentService
Client->>SC: 创建订单请求
SC->>OS: createOrder(orderId)
OS-->>SC: success
SC->>IS: reserveInventory(orderId)
IS-->>SC: success
SC->>PS: pay(orderId, amount)
PS-->>SC: fail(timeout)
SC->>IS: releaseInventory(orderId)
IS-->>SC: success
SC->>OS: cancelOrder(orderId)
OS-->>SC: success
SC-->>Client: 下单失败,已补偿
现象复现:线上最常见的 4 类故障
做 troubleshooting,不能只讲“正确姿势”,还得讲“错的时候长什么样”。
1. 现象一:订单取消了,但库存没释放
常见原因
- 补偿消息没发出去
- 库存服务补偿接口超时
- 协调器宕机,补偿流程中断
- 库存释放接口非幂等,第一次成功但响应丢失,第二次执行异常
典型排查点
- 查 Saga 表:状态是否停在
COMPENSATING - 查库存服务日志:是否收到
releaseInventory - 查消息队列:补偿消息是否积压、死信、消费失败
- 查链路追踪:请求是否到达库存服务
2. 现象二:重复退款
常见原因
- 支付补偿接口没做幂等
- 消息消费至少一次语义导致重复投递
- 协调器因为超时重试了退款动作
典型排查点
- 支付服务是否以
saga_id + step_name或refund_request_id做唯一约束 - 是否存在“请求超时但服务端已成功”的双写错觉
- 是否做了 outbox/inbox 去重
3. 现象三:Saga 一直卡在处理中
常见原因
- 某个步骤成功后没有上报状态
- 协调器状态机更新失败
- 异步消息丢失
- 下游服务已成功,但响应网络抖动导致上游误判失败
典型排查点
- 对照业务表和 Saga 表,看谁是事实来源
- 查每一步的操作日志和消息发送日志
- 看是否有超时扫描任务接管悬挂 Saga
4. 现象四:补偿失败后无人处理
常见原因
- 团队只实现了正向流程,补偿失败后没有二次恢复机制
- 失败任务没有进入重试队列
- 没有人工干预后台
典型排查点
- 是否有
FAILED/COMPENSATION_FAILED状态 - 是否有定时重试任务
- 是否有管理后台支持人工重放/人工终止
定位路径:我建议按这条线查
线上真的出问题时,不要一上来就看一堆零散日志。
我一般按下面这个顺序查,效率最高。
第一步:先锁定 Saga ID / 业务单号
没有统一关联 ID,排查几乎没法做。
最少要能通过 order_id 查到:
- saga_id
- 当前步骤
- 当前状态
- 最近一次异常
第二步:对照“流程状态”和“业务事实”
例如一个订单失败了,不要只看 Saga 状态,还要看:
- 订单表:是
CREATED、CONFIRMED还是CANCELLED - 库存表:到底冻结了没有
- 支付表:到底扣款了没有,退款了没有
经验上,很多“逻辑 bug”其实是状态记录和业务事实不一致。
第三步:确认消息链路有没有断
如果你用了 MQ,这一步特别关键:
- 消息是否成功写入 broker
- 是否进入死信队列
- 消费组是否堆积
- 消费失败是否无限重试
第四步:判断该“重试”还是“人工修复”
不是所有失败都适合自动重试。
适合重试的:
- 网络超时
- 临时资源不足
- 依赖服务短时不可用
不适合盲目重试的:
- 参数错误
- 业务状态冲突
- 数据已被人工修改
常见坑与排查
1. 把补偿接口写成“反向操作”,但没考虑状态边界
例如库存补偿直接 available = available + count,看起来没问题。
但如果冻结根本没成功,或者补偿被执行两次,就会把库存加多。
正确做法
- 记录冻结流水
- 补偿基于冻结记录释放
- 同一冻结流水只能释放一次
2. 没有幂等键,只靠“业务感觉不会重复”
这是我见过最多的线上事故来源之一。
只要有重试、消息队列、超时,就一定可能重复。
建议
每个正向动作、补偿动作都要有幂等键,例如:
saga_id + step_namebusiness_id + action_type- 独立的请求号
request_id
数据库层面最好配唯一索引。
CREATE TABLE saga_step_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
saga_id VARCHAR(64) NOT NULL,
step_name VARCHAR(64) NOT NULL,
action_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_saga_step_action (saga_id, step_name, action_type)
);
3. 只记“失败”,不记“谁失败、为什么失败”
没有结构化错误信息,排障会非常痛苦。
至少记录
- 错误码
- 异常摘要
- 服务名
- 步骤名
- 请求参数摘要
- 重试次数
- 首次失败时间 / 最近失败时间
4. 把补偿设计成强一致的执念
有些补偿动作本身也是外部调用,比如退款。它同样可能失败。
所以真实世界里,常常不是“失败后立刻恢复”,而是:
- 先进入补偿中
- 重试
- 最终人工介入
这个现实要接受,不然设计会很理想化。
5. 缺少“止血方案”
troubleshooting 里一个很现实的问题是:
你还没定位完,但故障已经在扩散。
止血优先级建议
- 暂停新的事务入口
- 关闭自动重试,避免重复副作用
- 隔离故障服务
- 导出异常 Saga 清单
- 人工按规则补偿或修复
我当时遇到过一次支付服务抖动,协调器不断重试,结果把少量超时请求放大成大面积重复扣减告警。那次之后我就特别强调:
自动重试不是万能药,出问题时先止血。
安全/性能最佳实践
Saga 经常只被当成业务架构问题,但其实它也有安全和性能边界。
1. 不要在 Saga 日志里泄露敏感信息
尤其是:
- 支付账号
- 身份证号
- 手机号
- 完整卡号
- 完整请求报文
建议:
- 关键字段脱敏
- 日志中只保留必要摘要
- 敏感补偿操作走审计链路
2. 给补偿接口做权限与签名校验
补偿接口往往“威力很大”:
- 能释放库存
- 能取消订单
- 能发起退款
如果没有鉴权,风险很高。建议至少做到:
- 服务间 mTLS 或内部鉴权
- 请求签名
- 防重放 token
- 操作审计
3. 为 Saga 状态查询建索引
常见查询条件通常是:
saga_idbusiness_idstatusupdated_at
没有索引时,线上扫描超时任务会非常慢。
CREATE TABLE saga_instance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
saga_id VARCHAR(64) NOT NULL,
business_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
current_step VARCHAR(64),
retry_count INT DEFAULT 0,
last_error VARCHAR(512),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_saga_id (saga_id),
KEY idx_business_id (business_id),
KEY idx_status_updated_at (status, updated_at)
);
4. 超时与重试必须分级
不是所有步骤都用同一个超时值。
例如:
- 创建订单:短超时
- 冻结库存:中等超时
- 支付:更长超时,但更谨慎重试
建议区分:
- 请求超时
- 步骤级重试
- Saga 级恢复
- 人工介入阈值
5. Outbox 模式要配合使用
如果 Saga 依赖消息事件,建议使用 Outbox 模式,避免“本地事务成功但消息没发出”的经典问题。
flowchart TD
A[本地事务写业务表] --> B[同事务写Outbox表]
B --> C[后台任务扫描Outbox]
C --> D[发送消息到MQ]
D --> E[消费者处理并幂等去重]
6. 控制补偿风暴
当下游服务故障时,大量 Saga 同时进入补偿,可能造成“补偿风暴”。
建议:
- 限流补偿任务
- 分批恢复
- 设置熔断
- 对相同错误原因聚合处理
落地建议:从“能跑”到“能运维”
如果你准备在真实项目里落地,我建议按下面四层建设,而不是一开始就追求“完美平台”。
第一层:先把状态机和补偿补齐
最基础但最重要:
- 每一步有正向动作
- 每一步有补偿动作
- 补偿动作幂等
- 状态可追踪
第二层:接入统一日志和链路追踪
至少做到:
- 请求头透传
trace_id - 业务层透传
saga_id - 日志按服务、步骤、状态检索
第三层:补上恢复机制
包括:
- 超时扫描
- 自动重试
- 死信处理
- 人工重放工具
第四层:压测和故障演练
别等线上事故帮你测试。建议主动演练:
- 支付服务超时
- MQ 丢消息
- 库存补偿重复执行
- 协调器中途重启
只有演练过,团队才知道设计到底是否站得住。
总结
Saga 模式不是银弹,但它是微服务里处理分布式事务最实用的一类方案之一。真正落地时,重点不只是“把流程串起来”,而是把下面几件事做扎实:
- 用本地事务 + 补偿替代全局锁
- 补偿动作必须幂等
- 每一步都要有状态记录和可观测性
- 为“失败后的失败”准备恢复机制
- 排障时先看 Saga 状态、业务事实、消息链路
如果你让我给一个最可执行的建议,那就是:
- 先选编排式 Saga
- 先把幂等和状态表做对
- 再加 Outbox、重试、人工修复后台
- 最后用故障演练验证补偿链路
边界条件也要说清楚:
如果你的业务要求极强一致、无法接受短暂中间态,Saga 可能就不是最佳答案;但如果你更看重可用性、扩展性和现实可维护性,Saga 往往是更适合微服务的落地路径。
一句话收尾:
Saga 真正难的不是“设计流程”,而是“出错之后还能收得回来”。