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

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

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

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

在单体应用里,我们习惯了“一个本地事务包打天下”:下单、扣库存、扣余额、写日志,要么一起成功,要么一起回滚。但一旦拆成微服务,这件事马上变复杂了。订单服务、库存服务、支付服务各自有自己的数据库,本地事务边界清清楚楚,跨服务却没有一个天然的 ACID 事务管理器。

很多团队刚开始会想:那是不是上 2PC、XA 就完了?理论上可以,实战里常常不太划算:性能差、耦合高、数据库和中间件支持受限,出了问题排查还很痛苦。于是,Saga 模式成了很多业务系统在“可用性、复杂度、最终一致性”之间的主流折中方案。

这篇文章我会从工程落地角度,带你把 Saga 走一遍:为什么需要它、怎么设计、代码怎么写、常见坑怎么避、性能和安全上怎么补齐。重点不是背概念,而是尽量让你看完就能开工。


背景与问题

先看一个典型业务:用户创建订单。

流程大致是:

  1. 订单服务创建订单
  2. 库存服务冻结库存
  3. 支付服务扣减余额
  4. 订单服务把状态更新为“已确认”

如果是单体,这就是一个数据库事务;如果是微服务,则会变成:

  • 订单库一笔本地事务
  • 库存库一笔本地事务
  • 支付库一笔本地事务

问题来了:

  • 订单创建成功了,但库存冻结失败怎么办?
  • 库存冻结成功了,但支付失败怎么办?
  • 支付其实成功了,但网络超时导致上游以为失败,又怎么办?
  • 服务重试时,重复扣款、重复解冻怎么避免?

这类问题的本质是:跨服务步骤可能部分成功,系统必须有能力“收拾残局”

为什么 Saga 适合大多数业务系统

Saga 的核心思想不是“全局锁住,一次性提交”,而是:

  • 把一个长事务拆成多个本地事务
  • 每一步成功后进入下一步
  • 如果中间某一步失败,就执行前面步骤对应的补偿动作

也就是说,Saga 追求的是:

  • 最终一致性
  • 业务可恢复
  • 高可用与可扩展

而不是强一致意义上的即时回滚。


核心原理

Saga 通常有两种实现风格:

  1. Choreography(事件编排/事件驱动)

    • 各服务通过事件自行感知下一步
    • 优点:解耦,天然适合事件流
    • 缺点:流程分散,链路长时可观测性差
  2. Orchestration(中心编排)

    • 由一个 Saga Orchestrator 统一驱动流程
    • 优点:流程清晰、便于审计和可视化
    • 缺点:中心编排器本身要高可用

对于中级工程团队来说,我更建议优先从中心编排式 Saga入手。原因很现实:流程好看、故障好查、改需求也更稳。

Saga 的步骤模型

一个完整 Saga 通常包含:

  • 正向动作(Action)
  • 补偿动作(Compensation)
  • 状态持久化
  • 幂等控制
  • 超时与重试策略

例如订单场景可以设计成:

步骤正向动作补偿动作
1创建订单取消订单
2冻结库存解冻库存
3扣减余额退款/回退余额
4确认订单标记失败或人工介入

要注意一个常被忽略的点:补偿不是数据库回滚,而是新的业务操作
比如“扣款补偿”通常不是简单回滚 SQL,而是生成一笔退款流水。

流程图:Saga 正向与补偿

flowchart TD
    A[开始创建订单] --> B[订单服务: 创建订单]
    B --> C[库存服务: 冻结库存]
    C --> D[支付服务: 扣减余额]
    D --> E[订单服务: 确认订单]
    E --> F[完成]

    D -.失败.-> C1[触发补偿]
    C1 --> C2[库存服务: 解冻库存]
    C2 --> C3[订单服务: 取消订单]

    C -.失败.-> B1[触发补偿]
    B1 --> B2[订单服务: 取消订单]

时序图:编排式 Saga

sequenceDiagram
    participant Client
    participant Orchestrator as Saga编排器
    participant OrderSvc as 订单服务
    participant InventorySvc as 库存服务
    participant PaymentSvc as 支付服务

    Client->>Orchestrator: 创建订单请求
    Orchestrator->>OrderSvc: createOrder()
    OrderSvc-->>Orchestrator: success(orderId)

    Orchestrator->>InventorySvc: reserveStock(orderId)
    InventorySvc-->>Orchestrator: success

    Orchestrator->>PaymentSvc: charge(orderId)
    alt 支付成功
        PaymentSvc-->>Orchestrator: success
        Orchestrator->>OrderSvc: confirmOrder(orderId)
        OrderSvc-->>Orchestrator: success
        Orchestrator-->>Client: 下单成功
    else 支付失败
        PaymentSvc-->>Orchestrator: failed
        Orchestrator->>InventorySvc: releaseStock(orderId)
        Orchestrator->>OrderSvc: cancelOrder(orderId)
        Orchestrator-->>Client: 下单失败
    end

方案对比与取舍分析

在真正落地前,先把几个常见方案摆平,不然后面很容易“听上去都能做,实际上都没做透”。

1. 本地事务 + 人工补单

适合:

  • 非核心链路
  • 金额小、偶发一致性可接受
  • 业务量不大

问题:

  • 一旦出问题靠人兜底,规模上不去
  • 没有统一补偿语义

2. 2PC / XA

适合:

  • 强一致要求极高
  • 参与者少
  • 基础设施强约束、统一技术栈

问题:

  • 性能损耗明显
  • 资源锁持有时间长
  • 微服务环境下适配成本高

3. TCC

适合:

  • 核心资金链路
  • 业务可以明确建模 Try / Confirm / Cancel

问题:

  • 侵入性强
  • 开发成本高
  • 对业务建模能力要求高

4. Saga

适合:

  • 订单、库存、履约、营销等长链路业务
  • 能接受最终一致性
  • 希望高可用、可扩展

问题:

  • 补偿设计复杂
  • 对幂等、顺序、可观测性要求高
  • 不是所有业务都能自然补偿

一句话建议:

  • 核心资金强一致:优先考虑 TCC 或账户体系隔离设计
  • 普通跨服务业务流程:Saga 往往是更平衡的方案

实战代码(可运行)

下面我用一个Python 版简化可运行示例演示中心编排式 Saga。
这个例子不依赖数据库和消息队列,直接在内存里模拟流程,便于理解核心机制。

你可以把它保存为 saga_demo.py 并直接运行。

from dataclasses import dataclass, field
from typing import Dict, List
import uuid


class SagaError(Exception):
    pass


@dataclass
class Order:
    order_id: str
    user_id: str
    product_id: str
    amount: int
    status: str = "PENDING"


@dataclass
class OrderService:
    orders: Dict[str, Order] = field(default_factory=dict)

    def create_order(self, user_id: str, product_id: str, amount: int) -> str:
        order_id = str(uuid.uuid4())
        self.orders[order_id] = Order(
            order_id=order_id,
            user_id=user_id,
            product_id=product_id,
            amount=amount,
            status="CREATED",
        )
        print(f"[OrderService] create_order success: {order_id}")
        return order_id

    def confirm_order(self, order_id: str):
        order = self.orders[order_id]
        if order.status == "CONFIRMED":
            print(f"[OrderService] confirm_order idempotent: {order_id}")
            return
        order.status = "CONFIRMED"
        print(f"[OrderService] confirm_order success: {order_id}")

    def cancel_order(self, order_id: str):
        order = self.orders.get(order_id)
        if not order:
            return
        if order.status == "CANCELLED":
            print(f"[OrderService] cancel_order idempotent: {order_id}")
            return
        order.status = "CANCELLED"
        print(f"[OrderService] cancel_order success: {order_id}")


@dataclass
class InventoryService:
    stock: Dict[str, int] = field(default_factory=lambda: {"SKU-1": 10})
    reserved: Dict[str, int] = field(default_factory=dict)

    def reserve_stock(self, order_id: str, product_id: str, count: int = 1):
        if order_id in self.reserved:
            print(f"[InventoryService] reserve_stock idempotent: {order_id}")
            return

        available = self.stock.get(product_id, 0)
        if available < count:
            raise SagaError("库存不足")

        self.stock[product_id] -= count
        self.reserved[order_id] = count
        print(f"[InventoryService] reserve_stock success: {order_id}")

    def release_stock(self, order_id: str, product_id: str):
        if order_id not in self.reserved:
            print(f"[InventoryService] release_stock idempotent: {order_id}")
            return

        count = self.reserved.pop(order_id)
        self.stock[product_id] = self.stock.get(product_id, 0) + count
        print(f"[InventoryService] release_stock success: {order_id}")


@dataclass
class PaymentService:
    balances: Dict[str, int] = field(default_factory=lambda: {"U-1": 100})
    charged_orders: Dict[str, int] = field(default_factory=dict)
    fail_on_charge: bool = False

    def charge(self, order_id: str, user_id: str, amount: int):
        if order_id in self.charged_orders:
            print(f"[PaymentService] charge idempotent: {order_id}")
            return

        if self.fail_on_charge:
            raise SagaError("支付通道异常")

        balance = self.balances.get(user_id, 0)
        if balance < amount:
            raise SagaError("余额不足")

        self.balances[user_id] -= amount
        self.charged_orders[order_id] = amount
        print(f"[PaymentService] charge success: {order_id}")

    def refund(self, order_id: str, user_id: str):
        if order_id not in self.charged_orders:
            print(f"[PaymentService] refund idempotent: {order_id}")
            return

        amount = self.charged_orders.pop(order_id)
        self.balances[user_id] = self.balances.get(user_id, 0) + amount
        print(f"[PaymentService] refund success: {order_id}")


class SagaOrchestrator:
    def __init__(self, order_svc: OrderService, inventory_svc: InventoryService, payment_svc: PaymentService):
        self.order_svc = order_svc
        self.inventory_svc = inventory_svc
        self.payment_svc = payment_svc

    def create_order_saga(self, user_id: str, product_id: str, amount: int) -> str:
        completed_steps: List[str] = []
        order_id = None

        try:
            order_id = self.order_svc.create_order(user_id, product_id, amount)
            completed_steps.append("create_order")

            self.inventory_svc.reserve_stock(order_id, product_id, 1)
            completed_steps.append("reserve_stock")

            self.payment_svc.charge(order_id, user_id, amount)
            completed_steps.append("charge")

            self.order_svc.confirm_order(order_id)
            completed_steps.append("confirm_order")

            print(f"[Saga] success: {order_id}")
            return order_id

        except Exception as e:
            print(f"[Saga] failed: {e}")
            self.compensate(order_id, user_id, product_id, completed_steps)
            raise

    def compensate(self, order_id: str, user_id: str, product_id: str, completed_steps: List[str]):
        print(f"[Saga] start compensation: {order_id}, steps={completed_steps}")

        if "charge" in completed_steps:
            self.payment_svc.refund(order_id, user_id)

        if "reserve_stock" in completed_steps:
            self.inventory_svc.release_stock(order_id, product_id)

        if "create_order" in completed_steps:
            self.order_svc.cancel_order(order_id)

        print(f"[Saga] compensation done: {order_id}")


def print_state(order_svc, inventory_svc, payment_svc):
    print("\n===== CURRENT STATE =====")
    print("orders:", order_svc.orders)
    print("stock:", inventory_svc.stock)
    print("reserved:", inventory_svc.reserved)
    print("balances:", payment_svc.balances)
    print("charged_orders:", payment_svc.charged_orders)
    print("=========================\n")


if __name__ == "__main__":
    order_svc = OrderService()
    inventory_svc = InventoryService()
    payment_svc = PaymentService()

    orchestrator = SagaOrchestrator(order_svc, inventory_svc, payment_svc)

    print("=== 场景1:成功下单 ===")
    try:
        orchestrator.create_order_saga("U-1", "SKU-1", 20)
    except Exception as e:
        print("unexpected error:", e)
    print_state(order_svc, inventory_svc, payment_svc)

    print("=== 场景2:支付失败,触发补偿 ===")
    payment_svc.fail_on_charge = True
    try:
        orchestrator.create_order_saga("U-1", "SKU-1", 30)
    except Exception as e:
        print("expected error:", e)
    print_state(order_svc, inventory_svc, payment_svc)

运行后你会看到什么

  • 场景 1:订单成功创建、库存冻结、支付成功、订单确认
  • 场景 2:支付失败后触发补偿,库存解冻、订单取消

这个例子虽然简化,但它已经包含 Saga 落地最关键的几个骨架:

  • 正向步骤
  • 逆向补偿
  • 幂等接口
  • 编排器集中控制

从示例走向生产:关键设计补齐

上面的代码能帮助理解机制,但真实系统还需要补上几块基础设施。

1. Saga 状态持久化

编排器不能只靠内存维护流程,否则服务一重启,运行中的 Saga 就丢了。

通常需要一张状态表,例如:

CREATE TABLE saga_instance (
  saga_id VARCHAR(64) PRIMARY KEY,
  business_key VARCHAR(128) NOT NULL,
  saga_type VARCHAR(64) NOT NULL,
  state VARCHAR(32) NOT NULL,
  current_step VARCHAR(64),
  context_json TEXT NOT NULL,
  retry_count INT NOT NULL DEFAULT 0,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

再配一张步骤执行表:

CREATE TABLE saga_step_log (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  saga_id VARCHAR(64) NOT NULL,
  step_name VARCHAR(64) NOT NULL,
  action_status VARCHAR(32) NOT NULL,
  compensation_status VARCHAR(32),
  idempotency_key VARCHAR(128) NOT NULL,
  error_message TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

2. 本地事务 + 消息投递一致性

如果你的 Saga 是通过消息驱动各个服务执行,那么要避免:

  • 数据库更新成功,但消息没发出去
  • 消息发出去了,但数据库事务回滚了

常见做法:

  • Outbox Pattern
  • 本地事务写业务数据 + outbox 事件表
  • 后台任务异步投递消息

示意流程:

flowchart LR
    A[业务请求] --> B[本地事务]
    B --> C[写业务表]
    B --> D[写Outbox事件表]
    D --> E[消息投递器]
    E --> F[MQ]
    F --> G[下游服务消费]

我个人很推荐这套,因为它比“代码里先写库再发 MQ”稳定得多,排查时也有据可查。

3. 幂等键设计

每个步骤都应该支持幂等,至少保证:

  • 重试不会重复扣款
  • 重试不会重复扣库存
  • 补偿重试不会重复退款

典型幂等键可以是:

  • sagaId + stepName
  • businessId + actionType
  • 请求头中的 Idempotency-Key

常见坑与排查

这部分是我觉得最值钱的。很多团队不是不会写 Saga,而是写出来之后,线上一复杂就开始失控。

坑 1:把补偿当成“数据库回滚”

这是最常见误区。

错误理解:

  • 扣款失败就 rollback
  • 库存失败就 rollback

真实情况:

跨服务步骤一旦提交,本地事务已经结束,无法像单库事务那样统一回滚。你只能发起新的业务动作去“抵消影响”。

排查建议

如果你发现补偿逻辑只是简单修改某个状态字段,先问自己:

  • 这一步有没有对外部系统产生副作用?
  • 有没有账户流水、库存流水、优惠券核销记录?
  • 只改状态是否足以恢复业务语义?

如果答案是否定的,说明补偿设计还不完整。


坑 2:补偿接口没做幂等

现实里,超时、重试、消息重复投递是常态,不是异常。

比如支付补偿:

  • 编排器第一次调用退款,支付服务其实已经成功退款
  • 但响应在网络中丢了
  • 编排器超时后再次发起退款
  • 结果重复退款

解决办法

在补偿服务里落幂等记录,例如:

CREATE TABLE idempotency_record (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  idempotency_key VARCHAR(128) NOT NULL UNIQUE,
  operation VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  response_json TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

处理逻辑大致是:

  1. 先查幂等键是否存在
  2. 存在且成功,直接返回旧结果
  3. 不存在则执行业务并落库

坑 3:步骤顺序没问题,补偿顺序错了

Saga 的补偿一般要按逆序执行。

例如:

  1. 创建订单
  2. 冻结库存
  3. 扣款

失败后应当:

  1. 退款
  2. 解冻库存
  3. 取消订单

如果你先取消订单再退款,可能会引发:

  • 对账系统无法关联
  • 用户侧状态与资金状态短暂冲突
  • 人工排查链路变乱

排查建议

把每个步骤都当成“有外部观察者的独立业务动作”,问自己:

  • 谁依赖这个状态?
  • 哪一步最难恢复?
  • 哪一步需要最后收口?

坑 4:把所有失败都自动补偿

不是所有失败都适合立即补偿。

举个例子:

  • 支付服务超时,究竟是失败了,还是其实成功了但响应丢失?

如果这时直接退款,可能会出现“未扣款先退款”这种荒诞情况。

更稳的做法

引入中间状态

  • PAYMENT_PENDING
  • COMPENSATING
  • MANUAL_REVIEW

先查交易结果,再决定是否补偿。

状态机示意

stateDiagram-v2
    [*] --> CREATED
    CREATED --> STOCK_RESERVED
    STOCK_RESERVED --> PAYMENT_PENDING
    PAYMENT_PENDING --> CONFIRMED: 支付成功
    PAYMENT_PENDING --> COMPENSATING: 支付明确失败
    PAYMENT_PENDING --> MANUAL_REVIEW: 超时/结果未知
    COMPENSATING --> CANCELLED
    MANUAL_REVIEW --> CONFIRMED: 补查成功
    MANUAL_REVIEW --> CANCELLED: 补查失败并补偿

坑 5:可观测性不足,出了问题只能翻日志

线上 Saga 最大的问题通常不是失败本身,而是你不知道它卡在哪一步。

至少要做到:

  • 每个 Saga 有唯一 sagaId
  • 每个步骤有状态
  • 正向动作和补偿动作都可查询
  • 日志、Trace、指标能串起来

最低可用观测面板建议

  • 成功率
  • 平均耗时
  • 每一步失败率
  • 补偿次数
  • 超时次数
  • 人工介入数量

安全/性能最佳实践

Saga 不只是“事务”问题,到了生产环境,安全和性能必须一起考虑。

安全最佳实践

1. 补偿接口必须鉴权

很多团队会把退款、解冻、取消订单这类接口暴露为内部 API,然后默认认为“内网就是安全的”。这是危险的。

建议:

  • 服务间调用必须有身份认证
  • 对高风险动作增加签名或 mTLS
  • 补偿接口只允许编排器或指定系统调用

2. 防止重放攻击

如果补偿请求被重复提交,幂等能解决一部分问题,但还不够。

建议加上:

  • 请求时间戳
  • 签名
  • nonce
  • 有效期校验

3. 敏感上下文不要全量落日志

Saga 上下文常常包含:

  • 用户 ID
  • 订单号
  • 支付流水
  • 金额信息

日志中要做脱敏,不要把完整支付参数、鉴权头、银行卡标识直接打印出来。


性能最佳实践

1. 缩短单步骤耗时

Saga 本质上是长事务,如果每一步都很慢,积压会快速放大。

建议:

  • 步骤接口做瘦身
  • 避免同步做非关键操作
  • 对通知、埋点、审计走异步

2. 给补偿留容量

很多系统只评估正向 TPS,不评估补偿峰值。
一旦下游故障,补偿流量可能瞬间放大。

简单估算思路:

  • 正向峰值:1000 TPS
  • 支付步骤失败率:10%
  • 每次失败平均触发 2 个补偿调用

那么补偿峰值可能接近:

1000 * 10% * 2 = 200 次/秒

如果失败率短时升到 50%,补偿洪峰就完全不是一个量级。

3. 重试要有退避和熔断

不要无脑立刻重试。

推荐策略:

  • 指数退避
  • 最大重试次数
  • 死信队列
  • 熔断保护
  • 人工介入兜底

4. 避免跨服务大对象传递

Saga 上下文尽量轻量,只传:

  • 业务主键
  • 操作类型
  • 必要金额/数量
  • 幂等键
  • Trace 信息

不要把完整订单快照塞进每条消息里,不然链路开销和变更成本都很高。


一套更务实的落地建议

如果你准备在团队内推动 Saga,我建议按下面顺序来,而不是一上来就搞“企业级大而全平台”。

第一步:先挑一个可补偿、边界清晰的业务

比如:

  • 下单 + 冻结库存 + 扣余额
  • 优惠券锁定 + 订单确认
  • 预约单创建 + 资源占用

不要一开始就碰最复杂的资金总账。

第二步:先做编排式,而不是纯事件放飞

对于多数团队,中心编排能更快形成统一标准:

  • Saga 定义
  • 步骤接口规范
  • 幂等规范
  • 状态表结构
  • 失败告警规则

第三步:先把“失败可见”做出来

早期最重要的不是 100% 自动恢复,而是:

  • 失败能看到
  • 失败能重试
  • 失败能人工接管

这一点我非常有感触。很多系统不是死于补偿做不到,而是死于失败根本没人知道。

第四步:最后再平台化

当你已经有多个业务都在跑 Saga,再去抽象:

  • Saga SDK
  • 编排 DSL
  • 通用状态机
  • 重试与补偿框架
  • 管控后台

这样成功率更高。


总结

Saga 不是银弹,但它确实是微服务时代处理分布式事务最实用的一类方案。它解决问题的方式,不是追求跨服务强一致,而是承认分布式系统会失败,然后通过本地事务 + 补偿机制 + 幂等控制 + 状态持久化来实现最终一致。

如果你只记住几条,请记这几条:

  1. 补偿不是回滚,是新的业务动作
  2. 每个步骤和补偿都必须幂等
  3. 流程状态必须持久化,不能只放内存
  4. 结果未知时不要盲目补偿,要先补查
  5. 先做好可观测性,再谈自动化恢复
  6. Saga 适合最终一致性业务,不适合所有强一致场景

最后给一个可执行建议:
如果你的系统当前还在“跨服务调用 + 失败靠人工修”的阶段,不必一步到位。先从一个核心但可控的业务流程开始,做一个最小可用的编排式 Saga,把状态表、幂等表、补偿接口、告警面板先建起来。你会发现,很多所谓“分布式事务难题”,其实在工程上是可以逐步拆掉的。

而真正难的,不是写出一段 Saga 代码,而是把它变成一套遇到异常也能稳定收场的业务能力。


分享到:

上一篇
《Web3 钱包登录实战:基于 SIWE(Sign-In with Ethereum)构建安全的去中心化身份认证方案》
下一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题-488》