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

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

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

背景与问题

在单体时代,事务这件事通常没那么“折磨人”。一条 BEGIN,几条 SQL,最后 COMMIT,心里很踏实。可一旦拆成微服务,订单、库存、支付、积分各自有库,各自部署,各自扩缩容,原来那个本地事务的安全感就没了。

很多团队第一次碰到分布式事务时,会下意识去找“全局锁”和“强一致”方案,比如 2PC/XA。但在微服务里,这类方案常常会把系统拖进另一个坑:性能下降、长事务、资源锁持有时间过长、故障放大。于是,Saga 模式成了很多业务系统更现实的选择。

但 Saga 也不是“用了就稳”。我自己踩过的坑里,最典型的几个是:

  • 订单创建成功了,库存也扣了,但支付超时,最后订单取消了,库存没加回来
  • 补偿接口写得像普通回滚,结果重复调用时把数据补“穿了”
  • 消息丢了,或者消费了两次,流程状态彻底乱掉
  • 线上排查时只看到“事务失败”,却不知道是哪个子步骤卡住了

这篇文章不打算只讲概念,而是从实战落地和故障排查的角度,带你把 Saga 的设计、补偿机制、代码实现,以及常见坑的定位路径串起来。


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

我们先用一个典型链路作为主线:

  1. 订单服务创建订单
  2. 库存服务冻结库存
  3. 支付服务扣款
  4. 订单服务确认成功

如果任一步失败,就按相反顺序执行补偿:

  • 支付成功后订单确认失败:退款
  • 库存已冻结但支付失败:释放库存
  • 订单已创建但库存失败:取消订单

这就是 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_id
  • business_id,比如 order_id
  • current_step
  • status
  • last_error
  • retry_count
  • updated_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_namerefund_request_id 做唯一约束
  • 是否存在“请求超时但服务端已成功”的双写错觉
  • 是否做了 outbox/inbox 去重

3. 现象三:Saga 一直卡在处理中

常见原因

  • 某个步骤成功后没有上报状态
  • 协调器状态机更新失败
  • 异步消息丢失
  • 下游服务已成功,但响应网络抖动导致上游误判失败

典型排查点

  • 对照业务表和 Saga 表,看谁是事实来源
  • 查每一步的操作日志和消息发送日志
  • 看是否有超时扫描任务接管悬挂 Saga

4. 现象四:补偿失败后无人处理

常见原因

  • 团队只实现了正向流程,补偿失败后没有二次恢复机制
  • 失败任务没有进入重试队列
  • 没有人工干预后台

典型排查点

  • 是否有 FAILED / COMPENSATION_FAILED 状态
  • 是否有定时重试任务
  • 是否有管理后台支持人工重放/人工终止

定位路径:我建议按这条线查

线上真的出问题时,不要一上来就看一堆零散日志。
我一般按下面这个顺序查,效率最高。

第一步:先锁定 Saga ID / 业务单号

没有统一关联 ID,排查几乎没法做。
最少要能通过 order_id 查到:

  • saga_id
  • 当前步骤
  • 当前状态
  • 最近一次异常

第二步:对照“流程状态”和“业务事实”

例如一个订单失败了,不要只看 Saga 状态,还要看:

  • 订单表:是 CREATEDCONFIRMED 还是 CANCELLED
  • 库存表:到底冻结了没有
  • 支付表:到底扣款了没有,退款了没有

经验上,很多“逻辑 bug”其实是状态记录和业务事实不一致

第三步:确认消息链路有没有断

如果你用了 MQ,这一步特别关键:

  • 消息是否成功写入 broker
  • 是否进入死信队列
  • 消费组是否堆积
  • 消费失败是否无限重试

第四步:判断该“重试”还是“人工修复”

不是所有失败都适合自动重试。

适合重试的:

  • 网络超时
  • 临时资源不足
  • 依赖服务短时不可用

不适合盲目重试的:

  • 参数错误
  • 业务状态冲突
  • 数据已被人工修改

常见坑与排查

1. 把补偿接口写成“反向操作”,但没考虑状态边界

例如库存补偿直接 available = available + count,看起来没问题。
但如果冻结根本没成功,或者补偿被执行两次,就会把库存加多。

正确做法

  • 记录冻结流水
  • 补偿基于冻结记录释放
  • 同一冻结流水只能释放一次

2. 没有幂等键,只靠“业务感觉不会重复”

这是我见过最多的线上事故来源之一。
只要有重试、消息队列、超时,就一定可能重复。

建议

每个正向动作、补偿动作都要有幂等键,例如:

  • saga_id + step_name
  • business_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 里一个很现实的问题是:
你还没定位完,但故障已经在扩散。

止血优先级建议

  1. 暂停新的事务入口
  2. 关闭自动重试,避免重复副作用
  3. 隔离故障服务
  4. 导出异常 Saga 清单
  5. 人工按规则补偿或修复

我当时遇到过一次支付服务抖动,协调器不断重试,结果把少量超时请求放大成大面积重复扣减告警。那次之后我就特别强调:
自动重试不是万能药,出问题时先止血。


安全/性能最佳实践

Saga 经常只被当成业务架构问题,但其实它也有安全和性能边界。

1. 不要在 Saga 日志里泄露敏感信息

尤其是:

  • 支付账号
  • 身份证号
  • 手机号
  • 完整卡号
  • 完整请求报文

建议:

  • 关键字段脱敏
  • 日志中只保留必要摘要
  • 敏感补偿操作走审计链路

2. 给补偿接口做权限与签名校验

补偿接口往往“威力很大”:

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

如果没有鉴权,风险很高。建议至少做到:

  • 服务间 mTLS 或内部鉴权
  • 请求签名
  • 防重放 token
  • 操作审计

3. 为 Saga 状态查询建索引

常见查询条件通常是:

  • saga_id
  • business_id
  • status
  • updated_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 状态、业务事实、消息链路

如果你让我给一个最可执行的建议,那就是:

  1. 先选编排式 Saga
  2. 先把幂等和状态表做对
  3. 再加 Outbox、重试、人工修复后台
  4. 最后用故障演练验证补偿链路

边界条件也要说清楚:
如果你的业务要求极强一致、无法接受短暂中间态,Saga 可能就不是最佳答案;但如果你更看重可用性、扩展性和现实可维护性,Saga 往往是更适合微服务的落地路径。

一句话收尾:
Saga 真正难的不是“设计流程”,而是“出错之后还能收得回来”。


分享到:

上一篇
《Docker Compose 到 Kubernetes 迁移实战:中型项目的容器编排改造与避坑指南》
下一篇
《从 Prompt 到 Pipeline:中级开发者实战构建可迭代优化的 AI 应用工作流》