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

《分布式架构中基于 Saga 模式的跨服务事务设计与落地实践》

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

分布式架构中基于 Saga 模式的跨服务事务设计与落地实践

在单体应用里,我们习惯了数据库事务:一条 begin,中间做几次更新,最后 commitrollback。但一旦系统拆成多个服务,事情就不再那么“听话”了。

比如一个典型的下单流程:

  • 订单服务:创建订单
  • 库存服务:冻结库存
  • 支付服务:扣款
  • 营销服务:发优惠券或积分

这些步骤分散在不同服务、不同数据库,甚至不同团队维护。你很难再靠本地事务把它们一次性包起来。这个时候,Saga 模式往往是最现实、最有工程可落地性的选择。

这篇文章我会从架构设计角度,把 Saga 的核心思想、实现方式、代码示例、常见坑和排查思路串起来,尽量讲成一套“拿去就能改造业务”的方法。


背景与问题

为什么跨服务事务难做

跨服务事务的本质难点,不在“调用多个接口”,而在于多个独立资源之间如何保持业务一致性。常见挑战有:

  1. 每个服务有自己的数据库
    • 订单库、库存库、支付库通常不会共享事务上下文。
  2. 服务调用可能失败
    • 网络超时、实例重启、消息重复投递都很常见。
  3. 外部系统不可回滚
    • 比如第三方支付,有时只能“退款”,不能真正回滚原操作。
  4. 一致性要求经常是业务级,而不是数据库级
    • 用户能接受“几秒后订单状态修正”,但不能接受“钱扣了、订单没了”。

很多团队一开始会问:能不能上 2PC、XA?理论上可以,但在微服务环境里,代价通常太大:

  • 对数据库和中间件支持要求高
  • 锁持有时间长,吞吐受影响
  • 一旦协调器或参与方异常,整体恢复复杂
  • 不适合高并发、异构系统、跨组织边界场景

所以现实中,大多数互联网业务更倾向于:

  • 最终一致性
  • 可补偿
  • 可观测
  • 可重试

这正是 Saga 模式适合的土壤。


方案对比与取舍分析

在真正决定用 Saga 前,先把几个常见方案摆出来比较一下。

方案一致性性能落地复杂度适用场景
本地事务强一致单库单服务
2PC/XA强一致中低少量核心系统、资源受控
TCC强一致偏业务很高资金、库存这类强控制场景
Saga最终一致大多数跨服务业务流程
纯消息最终一致最终一致异步链路、状态同步

Saga 适合什么,不适合什么

适合:

  • 下单、履约、营销、通知等长流程业务
  • 接受短时间中间态
  • 每一步都能定义补偿动作
  • 系统规模较大,服务自治明显

不太适合:

  • 必须全局强一致、且中间状态完全不可见
  • 某些动作天然不可补偿
  • 资金核心账务等极度敏感场景(此时更常考虑 TCC 或更强约束模型)

一句话总结:Saga 不是“分布式事务银弹”,它更像一套把失败当常态来设计的业务恢复机制。


核心原理

Saga 的基本思想很朴素:

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

一个最小例子

以“创建订单”为例:

  1. 订单服务:创建订单,状态为 PENDING
  2. 库存服务:冻结库存
  3. 支付服务:扣款
  4. 订单服务:状态改为 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_logoutbox
  • 更新步骤状态

不能一边写库一边依赖远程调用成功,否则失败边界会模糊。

2. 补偿不是回滚,而是反向业务动作

这点很关键。

  • 冻结库存的补偿是“解冻库存”
  • 扣款的补偿通常不是“数据库回滚”,而是“退款”
  • 发券的补偿可能是“撤券”或“标记不可用”

所以,补偿动作必须由业务自己定义,不能幻想数据库替你恢复整个世界。

3. 每个参与方都要幂等

分布式环境里,重复调用是常态:

  • 超时后调用方重试
  • 消息重复投递
  • 协调器崩溃后恢复重放

如果 reserve(orderId) 被调用两次,就不能多冻两次库存。最稳妥的方式是引入业务幂等键,通常就是 sagaId + stepNameorderId + 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

建议至少区分以下状态:

  • NEW
  • RUNNING
  • COMPLETED
  • COMPENSATING
  • COMPENSATED
  • FAILED
  • COMPENSATION_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)

运行效果说明

这个示例里有两个场景:

  1. 支付成功

    • 订单最终为 CONFIRMED
    • 库存已冻结
    • 支付已完成
    • Saga 状态为 COMPLETED
  2. 支付失败

    • 触发库存释放、订单取消
    • 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:把超时当失败,导致误补偿

现象:

  • 协调器认为支付失败,触发退款或取消
  • 但下游其实已经执行成功,只是响应超时

原因:

网络超时不等于业务失败,这是分布式系统里的经典问题。

排查方法:

  1. 查协调器超时日志
  2. 查下游服务执行日志
  3. 核对业务流水是否实际已落账

建议:

对超时场景采用“查询确认”模式:

  1. 首先标记为 UNKNOWN
  2. 调用下游查询接口确认结果
  3. 确认成功则继续,确认失败再补偿

别一超时就回滚,我当年就踩过这个坑,结果库存和支付状态差点打架一下午。


坑 3:补偿顺序错了

现象:

  • 先取消订单,再释放库存,导致后续对账困难
  • 先撤券,再退款,影响用户体验和账务解释

原因:

正向操作是有依赖顺序的,补偿也必须反向遵循依赖关系。

排查方法:

  • 画出正向依赖图
  • 检查补偿是否按逆序执行
  • 分析是否存在跨步骤隐含依赖

建议:

补偿顺序默认逆序;若有业务例外,必须在设计文档里明确写清楚。


坑 4:Saga 日志缺失,导致无法恢复

现象:

  • 服务重启后,不知道哪些步骤做过
  • 重试怕重复,不重试又卡死

原因:

状态只保存在内存或零散日志里,没有正式持久化模型。

排查方法:

  • 看是否有 saga_instancesaga_step
  • 看每一步的请求参数、响应结果、错误原因是否落库
  • 检查是否支持按 saga_id 全链路追踪

建议:

宁可一开始多记一些状态,也不要等线上出事后再补日志。


坑 5:补偿本身也会失败

现象:

  • 支付失败后库存释放接口又超时
  • Saga 长时间停在半补偿状态

原因:

补偿也是远程调用,本身就不可靠。

排查方法:

  • 区分“补偿失败”和“原步骤失败”
  • 检查补偿失败是否进入重试队列
  • 是否有人工介入后台

建议:

对补偿失败要有三层兜底:

  1. 自动重试
  2. 死信/异常队列
  3. 人工运营修复入口

安全/性能最佳实践

Saga 讨论里,大家容易把注意力都放在一致性上,忽略安全和性能。实际上这两个维度同样重要。

安全最佳实践

1. 传递最小必要上下文

跨服务传参时,不要把用户敏感信息、完整支付信息直接塞进 Saga 上下文。

建议只传:

  • orderId
  • userId(必要时脱敏)
  • sagaId
  • 业务摘要字段

敏感数据由服务内部按权限查询。

2. 补偿接口必须鉴权

有些团队觉得“补偿接口是内部接口,无所谓”。这很危险。

至少要做:

  • 服务间身份认证
  • 请求签名或可信网关校验
  • 防重放机制
  • 操作审计

特别是退款、释放库存、撤券这类补偿动作,本质上都是高风险操作。

3. 做好审计留痕

建议记录:

  • 谁发起了 Saga
  • 每一步调用参数摘要
  • 每一步返回结果
  • 补偿触发原因
  • 人工处理记录

这不只是为了排障,很多时候也是合规要求。


性能最佳实践

1. 缩短单步本地事务时间

Saga 性能好不好,很大程度取决于每一步本地事务是否足够轻。

建议:

  • 本地事务里只做核心更新
  • 非必要逻辑异步化
  • 不要在事务内做长时间远程调用

2. 限制 Saga 并发与重试风暴

当下游故障时,如果没有限流和退避机制,重试会把系统打穿。

建议:

  • 设置最大重试次数
  • 指数退避
  • 按服务维度限流
  • 熔断失败下游

3. 使用 Outbox 避免“双写不一致”

典型问题:

  • 本地数据库提交成功
  • 消息发送失败
  • 结果下游没收到事件

解决思路是 Transactional Outbox

  1. 本地事务中同时写业务数据和 outbox 事件
  2. 由后台任务可靠投递消息
  3. 消费方按幂等处理

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_time
  • retry_count
  • operator
  • manual_comment

什么时候该用事件驱动,什么时候该用编排式 Saga

这是设计时很常见的一个分歧点。

更适合事件驱动的时候

  • 流程较短
  • 服务之间关系稳定
  • 对全链路可视化要求不高
  • 团队对异步事件模型足够熟悉

例如:

  • 用户注册后触发欢迎礼包
  • 订单完成后异步发积分

更适合编排式的时候

  • 主流程长、步骤多
  • 失败补偿复杂
  • 需要统一状态视图
  • 需要人工介入与审计

例如:

  • 订单创建 -> 风控 -> 库存 -> 支付 -> 履约
  • 跨多个域、多个团队的业务链路

我个人的经验是:主交易链路优先编排式,外围通知链路优先事件式。这样能兼顾清晰度和扩展性。


总结

Saga 模式解决的,不是“让分布式事务看起来像本地事务”,而是:

  • 承认分布式系统天然会失败
  • 用业务补偿代替数据库回滚
  • 用状态机、幂等、重试和审计把失败管理起来

如果你准备在项目里落地,我建议按下面顺序推进:

  1. 先定义业务边界
    • 哪些步骤必须纳入 Saga
    • 哪些动作能补偿,哪些不能
  2. 再设计状态机
    • 不要只留成功/失败两个状态
  3. 为每一步补上幂等
    • 正向动作、补偿动作都要幂等
  4. 引入持久化日志
    • 实例表、步骤表、审计日志一个都别少
  5. 最后补齐恢复机制
    • 超时查询、自动重试、人工介入、告警监控

最后给一个边界判断建议:

  • 如果你追求的是全局强一致,Saga 不是最优解;
  • 如果你追求的是高可用、可恢复、业务最终一致,Saga 往往是非常实用的方案。

真正成熟的 Saga 设计,不在于图画得多漂亮,而在于线上出问题时,你能不能知道发生了什么、自动补回来多少、剩下多少需要人工处理。这才是分布式事务落地的分水岭。


分享到:

上一篇
《Web逆向实战:从请求重放到参数还原,系统定位前端签名与加密逻辑》
下一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题-310》