跳转到内容
123xiao | 无名键客

《微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与避坑》

字数: 0 阅读时长: 1 分钟

微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与避坑

在单体时代,我们习惯了一个数据库事务包打天下:扣库存、创建订单、支付扣款,BEGIN 一开,成功就一起提交,失败就一起回滚。

但到了微服务架构,这套玩法很快失效。订单服务、库存服务、支付服务各自有数据库,各自独立部署,跨服务再想靠本地事务“一把梭”基本不现实。很多团队一开始会纠结:要不要上 2PC/XA?理论上很完美,实践里却经常卡在性能、锁持有时间、兼容性和运维复杂度上。

这时,Saga 模式就成了一个更接地气的选择。

这篇文章我会从为什么需要 SagaSaga 到底怎么设计代码怎么落地线上容易踩哪些坑,一路带你走一遍。文章偏实战,适合已经做过微服务、但在分布式事务上还不够踏实的同学。


背景与问题

先看一个典型下单流程:

  1. 订单服务创建订单
  2. 库存服务冻结库存
  3. 支付服务扣款
  4. 物流服务创建发货单

如果第 3 步支付失败,前面的订单和库存怎么办?

在单体里,回滚就行;在微服务里,这几个动作已经发生在不同服务、不同数据库里了。于是你会遇到几个很现实的问题:

  • 本地事务只对单服务生效
  • 跨服务调用天然存在网络失败、超时、重试
  • 服务间状态可能短暂不一致
  • 单纯依赖“失败就回滚”不再成立

为什么不直接用 2PC/XA?

不是不能用,而是很多业务系统不适合:

  • 参与方都要支持 XA
  • 长事务导致资源锁持有时间长
  • 协调器本身变成核心基础设施
  • 在高并发下吞吐下降明显
  • 云原生环境和多种存储混搭时兼容性差

如果你的场景是“金融核心账务、强一致极高优先级、参与资源有限且可控”,2PC 可能仍有位置;但在大多数互联网业务里,最终一致性 + 补偿机制通常更符合工程现实。

Saga 解决什么问题?

Saga 的核心思想可以概括成一句话:

把一个长事务拆成多个本地事务,每一步成功后继续下一步;如果某一步失败,就按相反顺序执行补偿操作。

它不追求“所有服务瞬时一致”,而是接受短暂不一致,通过补偿恢复到业务可接受状态。


方案对比与取舍分析

Saga 不是唯一选择。先把它放到常见方案里对比一下。

方案一致性性能实现复杂度适用场景
2PC/XA强一致较低少量核心系统、强一致要求极高
TCC很强的业务控制力很高核心交易、账户类系统
Saga最终一致较高订单、履约、库存、营销等流程型业务
本地消息表/事件驱动最终一致异步解耦明显的业务

Saga 和 TCC 的差别

很多人第一次接触时会把 Saga 和 TCC 混在一起。

  • TCC:Try / Confirm / Cancel,业务接口天生按预留资源设计,侵入性强,但控制力极高。
  • Saga:直接把现有业务动作串起来,再为每个动作定义补偿逻辑,改造成本通常更低。

一个很实用的判断标准:

  • 如果你能清晰做“资源预留”,如账户冻结、库存预占,且愿意改造接口,优先考虑 TCC
  • 如果你面对的是跨多个已有服务的业务编排,希望在现有接口基础上演进,优先考虑 Saga

核心原理

Saga 有两种常见实现方式:

  1. Choreography(事件编排 / 去中心化):服务之间通过事件相互驱动
  2. 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

  1. 在本地事务中同时写业务表和 outbox 表
  2. 后台任务轮询 outbox 表投递 MQ
  3. 投递成功后标记发送状态
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 句话:

  1. Saga 用多个本地事务 + 补偿动作,解决微服务长事务问题
  2. 它追求最终一致,不追求瞬时强一致
  3. 真正难点不在调用链,而在状态机、幂等、补偿和观测性
  4. 补偿不是数据库回滚,而是业务语义上的反向修复
  5. 没有持久化、重试、告警和人工兜底的 Saga,基本不算可上线

如果你准备在项目里落地,我的建议很明确:

  • 优先选一个补偿语义清晰的流程试点
  • 先上编排式 Saga
  • 把状态持久化、幂等、Outbox、监控做完整
  • 重点演练失败路径,不要只看 happy path
  • 为补偿失败准备人工处理工具

最后说一句我自己踩坑后的感受:
分布式事务从来不是“怎么保证永不失败”,而是“失败之后能不能有秩序地恢复”。
而 Saga,正是把这种“有秩序的恢复”工程化的一种很务实的方法。


分享到:

上一篇
《区块链节点状态同步优化实战:从快照导入、区块回放到存储性能调优》
下一篇
《自动化测试中的测试数据管理实战:构建可复用、可维护的数据驱动用例体系》