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

《微服务架构中的分布式事务实战:基于 Saga 模式的设计、实现与故障补偿》

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

背景与问题

在单体应用里,事务通常靠数据库的 ACID 就能解决:一段业务逻辑包在一个本地事务里,要么一起成功,要么一起回滚。

但到了微服务架构,事情就没这么简单了。

比如一个典型下单流程:

  1. 订单服务创建订单
  2. 库存服务扣减库存
  3. 支付服务扣款
  4. 积分服务发放积分

如果这些服务各自都有自己的数据库,那么“一个全局事务”就很难直接成立。很多团队第一反应会想到 2PC/XA,但在真实生产环境里,它常常带来几个问题:

  • 服务之间强耦合
  • 协调器成为瓶颈
  • 长事务拖垮吞吐
  • 网络抖动下容易卡死
  • 云原生和多语言环境支持并不统一

所以,很多微服务系统最后会走向 最终一致性,而 Saga 模式就是其中非常实用的一种。

不过,Saga 不是“用了就万事大吉”。我见过不少项目,流程图画得很漂亮,线上一出问题就开始人工改单、补库存、对账修数据。原因通常不是 Saga 理论不对,而是实现细节没做好:

  • 补偿动作不幂等
  • 消息重复消费
  • 状态机不完整
  • 中间步骤超时后无人兜底
  • 失败链路缺少可观测性

这篇文章我就从 “怎么设计、怎么写、怎么排障” 三个角度,带你把 Saga 真正落到地上。


背景案例:一个最容易出事故的下单流程

我们先约定一个简化业务:

  • OrderService:创建订单
  • InventoryService:预留库存 / 释放库存
  • PaymentService:扣款 / 退款

目标流程:

  • 创建订单成功
  • 预留库存成功
  • 扣款成功
  • 最终订单变为 CONFIRMED

如果扣款失败,则:

  • 释放库存
  • 取消订单

这就是一个典型的 Saga。

为什么这里不用“强一致”?

因为订单、库存、支付本来就是不同领域,不适合共享数据库;而且支付通常还可能是外部系统,根本不支持你做数据库级别分布式事务。

所以在这个场景里,正确的问题不是“怎么做到绝对原子”,而是:

当局部步骤成功、后续步骤失败时,系统如何可靠地执行补偿,并把状态收敛到业务可接受的一致状态?


核心原理

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

把一个长事务拆成多个本地事务,每个本地事务都有对应的补偿操作;当前向流程失败时,按相反顺序执行补偿。

Saga 一般有两种实现风格:

1. 编排式(Orchestration)

由一个中心协调者负责驱动整个流程:

  • 调订单服务
  • 调库存服务
  • 调支付服务
  • 某一步失败则统一触发补偿

优点:

  • 流程清晰
  • 状态集中,便于排查
  • 对复杂业务更友好

缺点:

  • 协调器逻辑会变重
  • 中心节点需要高可用设计

2. 协同式(Choreography)

每个服务消费事件并发布下一个事件:

  • 订单已创建
  • 库存已预留
  • 支付已完成
  • 失败则发布回滚事件

优点:

  • 解耦更强
  • 看起来更“事件驱动”

缺点:

  • 业务链长时,事件流很难跟踪
  • 排障困难
  • 容易形成“隐式流程”

对于中级工程师实际落地,我通常建议:

核心交易链路优先用编排式 Saga,尤其是订单、支付、履约这类需要强可观测性的场景。


Saga 的状态流转

stateDiagram-v2
    [*] --> PENDING
    PENDING --> ORDER_CREATED: 创建订单
    ORDER_CREATED --> INVENTORY_RESERVED: 预留库存成功
    INVENTORY_RESERVED --> PAYMENT_COMPLETED: 支付成功
    PAYMENT_COMPLETED --> CONFIRMED: 订单确认

    INVENTORY_RESERVED --> COMPENSATING: 支付失败
    ORDER_CREATED --> COMPENSATING: 库存失败
    COMPENSATING --> CANCELLED: 补偿完成
    COMPENSATING --> FAILED: 补偿失败需人工介入

    CONFIRMED --> [*]
    CANCELLED --> [*]
    FAILED --> [*]

这个状态图有两个重点:

  1. 业务完成状态补偿中状态 要明确区分
  2. “失败”不等于“回滚完成”,中间还有一个 COMPENSATING 阶段

很多系统的问题就出在这里:日志里写了“支付失败”,但数据库里订单状态还是“处理中”,过几小时没人管,最终变成脏数据。


一次完整 Saga 的时序

sequenceDiagram
    participant Client
    participant Orchestrator as Saga协调器
    participant Order
    participant Inventory
    participant Payment

    Client->>Orchestrator: 发起下单
    Orchestrator->>Order: createOrder()
    Order-->>Orchestrator: orderId

    Orchestrator->>Inventory: reserve(orderId, sku, count)
    Inventory-->>Orchestrator: reserved

    Orchestrator->>Payment: charge(orderId, amount)
    Payment-->>Orchestrator: failed

    Orchestrator->>Inventory: release(orderId, sku, count)
    Inventory-->>Orchestrator: released

    Orchestrator->>Order: cancelOrder(orderId)
    Order-->>Orchestrator: cancelled

    Orchestrator-->>Client: 下单失败,已补偿

设计落地时最容易被忽略的 5 个原则

在开始写代码之前,先把几个设计原则说透,不然后面排障会非常痛苦。

1. 补偿不是数据库回滚

补偿是一个新的业务动作。

例如:

  • 扣库存的补偿不是“数据库 rollback”
  • 而是“新增一条释放库存的业务操作”

这意味着补偿也要:

  • 记录日志
  • 可重试
  • 可幂等
  • 可审计

2. 每个步骤都要幂等

因为现实里会发生:

  • 消息重复投递
  • 协调器重试
  • 服务超时但实际已执行成功
  • 网络闪断导致响应丢失

所以接口必须支持类似下面的语义:

  • reserve(orderId) 重复调用只生效一次
  • release(orderId) 重复调用不会多释放
  • cancelOrder(orderId) 多次调用结果一致

3. 前向操作和补偿操作都要持久化状态

不能只靠内存变量判断流程走到哪一步。服务重启后,内存就没了。

至少要持久化:

  • Saga 实例 ID
  • 当前步骤
  • 每步执行结果
  • 补偿状态
  • 重试次数
  • 最后错误信息

4. 超时不等于失败,但必须进入待确认流程

这是线上高频坑。

比如支付服务 3 秒超时,你的协调器把它判为失败,开始退款/回滚;但第 4 秒支付网关其实已经扣款成功了。于是你就会得到:

  • 用户支付成功
  • 订单却被取消
  • 库存被释放

这种情况必须通过 查询确认异步回执 收敛,而不是简单地“超时即补偿”。

5. 人工介入是系统能力的一部分

别把“人工兜底”理解成系统设计失败。真正成熟的系统都会留出:

  • 卡单列表
  • 补偿重试按钮
  • 手工确认入口
  • 对账修复任务

因为外部支付、第三方物流、短信、风控这些系统,永远可能出现不可自动收敛的异常。


实战代码(可运行)

下面我们用 Python 写一个 可运行的编排式 Saga 示例。它不依赖外部中间件,但会模拟真实问题:

  • 库存预留
  • 支付扣款
  • 支付失败后触发补偿
  • 幂等控制
  • 状态记录

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

from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, Set
import random


class SagaStatus(str, Enum):
    PENDING = "PENDING"
    ORDER_CREATED = "ORDER_CREATED"
    INVENTORY_RESERVED = "INVENTORY_RESERVED"
    PAYMENT_COMPLETED = "PAYMENT_COMPLETED"
    CONFIRMED = "CONFIRMED"
    COMPENSATING = "COMPENSATING"
    CANCELLED = "CANCELLED"
    FAILED = "FAILED"


@dataclass
class SagaLog:
    saga_id: str
    status: SagaStatus = SagaStatus.PENDING
    steps: list = field(default_factory=list)
    retries: int = 0
    last_error: str = ""


class OrderService:
    def __init__(self):
        self.orders: Dict[str, str] = {}

    def create_order(self, order_id: str):
        if order_id in self.orders:
            return
        self.orders[order_id] = "CREATED"

    def cancel_order(self, order_id: str):
        if order_id not in self.orders:
            return
        self.orders[order_id] = "CANCELLED"

    def confirm_order(self, order_id: str):
        if order_id not in self.orders:
            raise ValueError("order not found")
        self.orders[order_id] = "CONFIRMED"


class InventoryService:
    def __init__(self, initial_stock: int):
        self.stock = initial_stock
        self.reserved_orders: Set[str] = set()

    def reserve(self, order_id: str, amount: int):
        # 幂等:重复预留不重复扣减
        if order_id in self.reserved_orders:
            return
        if self.stock < amount:
            raise ValueError("insufficient stock")
        self.stock -= amount
        self.reserved_orders.add(order_id)

    def release(self, order_id: str, amount: int):
        # 幂等:未预留过则直接返回
        if order_id not in self.reserved_orders:
            return
        self.stock += amount
        self.reserved_orders.remove(order_id)


class PaymentService:
    def __init__(self):
        self.paid_orders: Set[str] = set()
        self.refunded_orders: Set[str] = set()

    def charge(self, order_id: str, amount: int, force_fail: bool = False):
        # 幂等:重复扣款不重复收费
        if order_id in self.paid_orders:
            return
        if force_fail:
            raise ValueError("payment failed")
        self.paid_orders.add(order_id)

    def refund(self, order_id: str, amount: int):
        # 幂等:未支付过则无需退款;已退款则忽略
        if order_id not in self.paid_orders:
            return
        if order_id in self.refunded_orders:
            return
        self.refunded_orders.add(order_id)


class SagaOrchestrator:
    def __init__(self, order_service, inventory_service, payment_service):
        self.order_service = order_service
        self.inventory_service = inventory_service
        self.payment_service = payment_service
        self.logs: Dict[str, SagaLog] = {}

    def create_log(self, saga_id: str):
        self.logs[saga_id] = SagaLog(saga_id=saga_id)

    def execute(self, saga_id: str, order_id: str, stock_amount: int, pay_amount: int, force_payment_fail=False):
        self.create_log(saga_id)
        log = self.logs[saga_id]

        try:
            self.order_service.create_order(order_id)
            log.status = SagaStatus.ORDER_CREATED
            log.steps.append("create_order")

            self.inventory_service.reserve(order_id, stock_amount)
            log.status = SagaStatus.INVENTORY_RESERVED
            log.steps.append("reserve_inventory")

            self.payment_service.charge(order_id, pay_amount, force_fail=force_payment_fail)
            log.status = SagaStatus.PAYMENT_COMPLETED
            log.steps.append("charge_payment")

            self.order_service.confirm_order(order_id)
            log.status = SagaStatus.CONFIRMED
            log.steps.append("confirm_order")

        except Exception as e:
            log.last_error = str(e)
            log.status = SagaStatus.COMPENSATING
            self.compensate(log, order_id, stock_amount, pay_amount)

        return log

    def compensate(self, log: SagaLog, order_id: str, stock_amount: int, pay_amount: int):
        try:
            # 补偿顺序与前向顺序相反
            self.payment_service.refund(order_id, pay_amount)
            log.steps.append("refund_payment")

            self.inventory_service.release(order_id, stock_amount)
            log.steps.append("release_inventory")

            self.order_service.cancel_order(order_id)
            log.steps.append("cancel_order")

            log.status = SagaStatus.CANCELLED
        except Exception as e:
            log.last_error = f"compensation failed: {e}"
            log.status = SagaStatus.FAILED


if __name__ == "__main__":
    order_service = OrderService()
    inventory_service = InventoryService(initial_stock=10)
    payment_service = PaymentService()

    orchestrator = SagaOrchestrator(order_service, inventory_service, payment_service)

    print("=== 场景1:成功下单 ===")
    result1 = orchestrator.execute(
        saga_id="saga-001",
        order_id="order-001",
        stock_amount=2,
        pay_amount=100,
        force_payment_fail=False
    )
    print(result1)
    print("orders =", order_service.orders)
    print("stock =", inventory_service.stock)
    print()

    print("=== 场景2:支付失败,触发补偿 ===")
    result2 = orchestrator.execute(
        saga_id="saga-002",
        order_id="order-002",
        stock_amount=3,
        pay_amount=150,
        force_payment_fail=True
    )
    print(result2)
    print("orders =", order_service.orders)
    print("stock =", inventory_service.stock)

运行结果你应该关注什么

成功场景下:

  • 订单状态变成 CONFIRMED
  • 库存减少
  • Saga 状态为 CONFIRMED

失败场景下:

  • 支付失败
  • Saga 进入 COMPENSATING
  • 补偿执行成功后,订单状态为 CANCELLED
  • 库存恢复
  • Saga 状态为 CANCELLED

从示例走向生产:状态表设计建议

如果要真正上生产,建议至少有一张 Saga 实例表。

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,
    current_step VARCHAR(64),
    retries INT NOT NULL DEFAULT 0,
    last_error TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

再配一张步骤明细表:

CREATE TABLE saga_step (
    id BIGSERIAL PRIMARY KEY,
    saga_id VARCHAR(64) NOT NULL,
    step_name VARCHAR(64) NOT NULL,
    action_type VARCHAR(16) NOT NULL, -- FORWARD / COMPENSATE
    status VARCHAR(32) NOT NULL,      -- SUCCESS / FAILED / PENDING
    idempotency_key VARCHAR(128),
    error_message TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

为什么要拆两张表?

  • saga_instance 方便查当前流程
  • saga_step 方便排查哪个步骤失败、重试了几次、补偿做到哪一步

我个人经验是:
只存总状态不存步骤明细,线上排障会极其痛苦。


现象复现:线上最常见的 4 类故障

troubleshooting 文章不能只讲原理,下面我们直接按“事故现象”来拆。

故障 1:订单取消了,但库存没恢复

常见现象

  • 用户下单失败
  • 订单状态是 CANCELLED
  • 库存却少了
  • 后台需要人工加库存

可能原因

  1. 补偿顺序写错
  2. 释放库存接口调用失败但未重试
  3. 释放库存接口不是幂等,重试时抛异常
  4. 协调器先更新订单为取消,再去释放库存,中途崩了

定位路径

建议按下面顺序查:

  1. 查 Saga 实例状态
  2. 查 Saga 步骤表里 release_inventory 是否执行
  3. 查库存服务日志是否收到请求
  4. 查库存服务数据库有没有该 order_id 的预留记录
  5. 查重试任务是否覆盖到该状态

止血方案

  • 先做库存对账,把“订单取消但库存仍预留”的记录筛出来
  • 批量补发 release_inventory
  • 临时把卡在 COMPENSATING 的实例加入重试队列

故障 2:支付其实成功了,但订单显示失败

常见现象

  • 用户投诉扣款成功
  • 页面显示下单失败
  • 订单被取消
  • 财务对账发现多笔“有支付无订单”

根因往往是“超时误判”

协调器调用支付接口超时,于是触发补偿;但支付渠道后来实际成功了。

正确处理思路

对于支付类步骤,不要简单使用:

  • “调用失败 = 支付失败”
  • “超时 = 支付失败”

而应区分:

  • 明确失败:可直接补偿
  • 超时未知:进入 PAYMENT_UNKNOWN
  • 通过支付查询接口确认最终状态
  • 或等待异步支付回执再决策

推荐状态机补充

flowchart TD
    A[调用支付] --> B{返回结果}
    B -->|成功| C[标记支付成功]
    B -->|明确失败| D[触发补偿]
    B -->|超时/未知| E[进入待确认状态]
    E --> F{查询支付结果}
    F -->|已支付| G[继续后续流程]
    F -->|未支付| D
    F -->|仍未知| H[人工介入或延迟重试]

这类问题我真的踩过,教训就是:

对接第三方支付时,“未知”必须单独建模,别偷懒合并到“失败”。


故障 3:补偿执行了两次,库存被多加

常见现象

  • 一笔失败订单触发两次补偿
  • 库存比实际多
  • 日志里有重复的 release 请求

典型原因

  • 消息重复消费
  • 协调器重试
  • 定时任务和手工处理重复触发
  • 接口无幂等保护

排查重点

看补偿接口是否使用以下任一机制:

  • order_id 唯一业务键
  • 幂等表
  • 去重 token
  • 状态机 CAS 更新

如果没有,基本就能锁定问题。

止血方案

  • 先暂停重复消费任务
  • 用业务主键对补偿请求去重
  • 对已执行补偿的订单建立唯一约束或操作日志

故障 4:Saga 卡在处理中,永远不结束

常见现象

  • Saga 状态一直是 PENDING / COMPENSATING
  • 用户侧查不到明确结果
  • 数据库里堆积大量中间态

常见原因

  • 服务调用成功但状态未落库
  • 状态更新和消息发送不在同一原子操作里
  • 重试任务只扫部分状态
  • 某个补偿步骤失败后没有再次调度

推荐定位方法

按“三件事”查:

  1. 有没有执行
  2. 有没有记录
  3. 有没有继续推进

如果动作执行了但没记录,属于落库问题;
如果记录了但没推进,属于调度问题;
如果根本没执行,属于调用或消息问题。


常见坑与排查

这一节我不按理论讲,直接按“最容易写错”的点来列。

1. 把补偿当成简单反向操作

不是所有动作都有天然反操作。

例如:

  • 发券了,不能简单“删掉券”,可能券已被用了
  • 发短信了,无法撤回
  • 推送通知了,也无法回滚

这种场景下不能硬套 Saga 的“反向撤销”,而要改成:

  • 标记失效
  • 发冲正消息
  • 增加人工审核

边界条件非常重要:不是所有业务都适合 Saga。


2. 使用数据库事务包住远程调用

有些实现会这样做:

  1. 开本地数据库事务
  2. 写订单
  3. 调库存服务
  4. 调支付服务
  5. 最后提交数据库事务

这会导致:

  • 本地锁持有时间过长
  • 远程超时拖垮数据库连接
  • 失败后状态更难判断

正确做法是:

  • 本地事务只包本地状态更新
  • 远程调用前后用状态机衔接
  • 需要时配合 Outbox 模式保证消息可靠

3. 没有统一的幂等键

如果订单、库存、支付各服务都用不同主键,很容易乱。

建议统一使用:

  • saga_id:流程实例唯一标识
  • business_id:比如 order_id
  • step_name:步骤名
  • idempotency_key:例如 order-001:reserve

这样日志、接口、消息、数据库都能串起来。


4. 补偿失败后直接丢弃

补偿也可能失败,而且比前向操作更常见。

比如:

  • 库存服务宕机
  • 支付退款接口限流
  • 订单服务数据已被人工修改

所以补偿失败后至少要有:

  • 重试策略
  • 退避机制
  • 死信队列或失败表
  • 人工处理面板

5. 缺少可观测性,排障全靠猜

一个成熟的 Saga 系统,最少要能看到:

  • 每个 Saga 当前状态
  • 每步耗时
  • 每步请求参数摘要
  • 重试次数
  • 补偿次数
  • 最近错误信息
  • 关联日志 traceId

不然线上出问题时,只能“翻日志碰运气”。


安全/性能最佳实践

Saga 常被理解成“事务方案”,但它同样涉及安全和性能。

安全最佳实践

1. 补偿接口要有鉴权

补偿动作本质上能修改业务资产:

  • 释放库存
  • 发起退款
  • 取消订单

这些接口绝不能裸奔暴露。至少要做:

  • 服务间认证
  • 请求签名
  • 权限隔离
  • 审计日志

2. 避免敏感数据在 Saga 日志里明文落盘

不要把以下内容直接写到 Saga 步骤表:

  • 完整银行卡号
  • 用户身份证号
  • 完整支付凭证
  • 明文手机号

建议只保存:

  • 脱敏摘要
  • 外部流水号
  • 哈希或引用 ID

3. 人工补偿操作必须审计

后台“重试补偿”“强制完成”“手工取消”这类按钮,一定要记录:

  • 操作人
  • 时间
  • 目标对象
  • 原因
  • 前后状态

否则出了资损问题,很难回溯。


性能最佳实践

1. 避免 Saga 步骤过细

有些系统把一个下单流程拆成十几步,导致:

  • 状态复杂
  • 重试链路变长
  • 故障点增加

经验上建议:

  • 一步对应一个明确业务子事务
  • 不要把纯计算逻辑也拆成 Saga 步骤
  • 把真正需要跨服务一致性的动作纳入 Saga

2. 重试要有退避,不要风暴式轰炸

如果支付服务故障,成千上万的 Saga 同时重试,会把下游彻底打死。

建议:

  • 指数退避
  • 加随机抖动
  • 设置最大重试次数
  • 熔断期间暂停部分补偿

3. 热点状态表要控制写放大

Saga 步骤很多时,状态表写入会非常频繁。可以考虑:

  • 只记录关键状态变更
  • 明细日志异步落盘
  • 冷热数据分离
  • 按时间归档历史记录

4. 补偿任务要支持分片扫描

如果全量扫 COMPENSATING 状态,数据一大就很慢。建议:

  • 按时间窗口扫
  • 按分库分表键分片
  • 按状态 + 更新时间建索引

例如:

CREATE INDEX idx_saga_status_updated_at
ON saga_instance(status, updated_at);

一套实用的生产级落地清单

如果你准备在真实项目里用 Saga,我建议至少做到下面这些。

设计层

  • 明确每个步骤的前向动作和补偿动作
  • 识别哪些步骤存在“未知态”
  • 补偿是否真的可逆
  • 定义最终一致性的时间窗口

数据层

  • Saga 实例表
  • Saga 步骤表
  • 幂等键或去重表
  • 补偿失败记录表

工程层

  • 状态机驱动而不是 if/else 到处飞
  • 每步接口天然幂等
  • 支持重试和人工介入
  • 有 traceId 串联日志

运维层

  • 卡单监控
  • 补偿失败告警
  • 对账任务
  • 后台人工处理工具

总结

Saga 模式不是“分布式事务银弹”,但在微服务架构里,它是一个非常现实、可操作、能抗复杂网络环境的方案。

你真正要抓住的不是一句“最终一致性”,而是这几个落地关键点:

  1. 前向步骤和补偿步骤都要业务化设计
  2. 所有动作必须幂等
  3. 超时与失败要区分,未知态要单独建模
  4. 状态机必须完整,不能只记录成功和失败
  5. 补偿失败不是结束,必须可重试、可审计、可人工兜底

如果你的场景是订单、库存、支付这类核心链路,我的建议很明确:

  • 优先使用 编排式 Saga
  • 引入 状态表 + 步骤表
  • 给每个步骤设计 幂等键
  • 对支付、退款这类外部依赖加入 查询确认机制
  • 一开始就建设 卡单监控和人工补偿能力

最后给一个边界判断:

  • 如果业务步骤少、补偿清晰、允许短时间最终一致,Saga 很适合
  • 如果动作不可逆、需要严格实时强一致、且外部系统不可控,Saga 就不是最佳答案,应该考虑重新拆分业务边界或调整业务规则

一句话收尾:

分布式事务最难的,从来不是“提交”,而是“出故障后还能把系统收回来”。Saga 的价值,正体现在这里。


分享到:

上一篇
《Java 中使用 CompletableFuture 构建高并发异步任务编排的实战指南》
下一篇
《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:缓存穿透、击穿与雪崩治理方案》