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

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

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

背景与问题

只要系统一拆成微服务,事务问题几乎一定会冒出来。

最典型的场景是下单:

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

在单体应用里,这些步骤往往能放进一个数据库事务里,失败就 rollback。但到了微服务架构下,服务各自有自己的数据库,跨库、跨服务、跨网络调用同时出现,传统的本地事务就不够用了。

很多团队第一反应会想到 2PC/XA,但真上生产后常见问题也很直接:

  • 性能开销大
  • 锁持有时间长
  • 对数据库、中间件支持要求高
  • 一旦协调者或网络抖动,整个链路吞吐明显下降

所以,Saga 模式成了很多业务系统里更实际的选择:它不追求强一致下的“一把锁到底”,而是通过一组本地事务 + 对应补偿动作,把跨服务事务拆开处理,接受短暂不一致,再通过补偿恢复到业务可接受状态。

但 Saga 真正难的地方不是“知道这个概念”,而是:

  • 补偿到底怎么设计才不会越补越乱?
  • 服务超时、重复请求、消息重试时,状态怎么兜住?
  • 线上出问题时,如何快速判断卡在了哪一步?
  • 什么情况该自动补偿,什么情况必须人工介入?

这篇文章我会从排障和实战角度带你走一遍,重点不是讲定义,而是讲:怎么落地、怎么复现问题、怎么止血、怎么避免补偿失控


背景案例:一个最常见的订单 Saga

我们先固定一个业务流,后面所有代码和排查都围绕它展开。

  • 订单服务:创建订单
  • 库存服务:冻结库存
  • 支付服务:扣款
  • 订单服务:确认订单完成
  • 若任一步失败:按逆序执行补偿
flowchart TD
    A[创建订单 PENDING] --> B[冻结库存]
    B -->|成功| C[扣减余额]
    B -->|失败| X[取消订单]
    C -->|成功| D[确认订单 CONFIRMED]
    C -->|失败| E[解冻库存]
    E --> F[取消订单]

这个流程看起来不复杂,但线上真正出问题,往往是这些地方:

  • 库存冻结成功了,但支付服务超时,调用方不知道扣款是否真的成功
  • 补偿执行时,库存已经被人工修改,解冻逻辑出现负数
  • 消息重复投递,导致订单被重复取消
  • 编排器重启后,事务状态丢失,出现“订单卡死在处理中”

核心原理

1. Saga 的本质

Saga 可以理解为:

一个大事务,拆成多个可独立提交的本地事务;每个本地事务都要有对应的补偿动作。

例如:

正向动作补偿动作
创建订单取消订单
冻结库存解冻库存
扣减余额退款/冲正

注意,这里有个非常关键的认知:

补偿不是数据库层面的 rollback,而是新的业务操作。

也就是说,补偿也会失败,也需要重试,也要考虑幂等。

2. 两种常见实现方式

编排式(Orchestration)

由一个 Saga Coordinator 统一驱动流程:

  • 先调用订单服务
  • 再调用库存服务
  • 再调用支付服务
  • 失败时统一触发补偿

优点:

  • 流程清晰,便于排障
  • 状态集中管理
  • 中级团队更容易落地

缺点:

  • 编排器会成为核心组件
  • 流程变更可能集中在编排器

协同式(Choreography)

各服务通过事件自己接力:

  • 订单创建后发事件
  • 库存服务消费事件并冻结库存,再发成功/失败事件
  • 支付服务继续消费

优点:

  • 松耦合
  • 更符合事件驱动架构

缺点:

  • 故障排查难
  • 业务流分散在多个服务事件里
  • 很容易“谁都能改流程,最后没人说得清”

如果你问我在中级团队里更建议哪种,我通常会说:先编排式,后事件化。因为很多事务问题不是不会做,而是出了故障没人能快速定位。

3. 状态机思维比“接口串调用”更重要

落地 Saga 时,最容易犯的错误是把它写成一串 if/else 调接口。

真正稳定的做法是:先定义状态机,再实现调用。

stateDiagram-v2
    [*] --> PENDING
    PENDING --> INVENTORY_RESERVED: 冻结库存成功
    PENDING --> CANCELLED: 创建后直接取消

    INVENTORY_RESERVED --> PAYMENT_DONE: 扣款成功
    INVENTORY_RESERVED --> COMPENSATING: 扣款失败/超时

    PAYMENT_DONE --> CONFIRMED: 确认订单
    PAYMENT_DONE --> COMPENSATING: 确认失败

    COMPENSATING --> CANCELLED: 补偿完成
    COMPENSATING --> MANUAL_REVIEW: 多次补偿失败

    CONFIRMED --> [*]
    CANCELLED --> [*]
    MANUAL_REVIEW --> [*]

这里我特别建议你把“人工介入”也视作合法终态。因为线上总会遇到自动补偿解决不了的情况,比如:

  • 支付已成功,但支付网关查单接口异常
  • 库存表被人工修过
  • 第三方退款接口连续失败

这时别硬自动重试到死,应该有一个 MANUAL_REVIEW 状态把问题显式暴露出来。


现象复现:为什么 Saga 事务会“看起来成功,实际上失败”?

先看一个真实感很强的时序图。

sequenceDiagram
    participant C as Coordinator
    participant O as OrderService
    participant I as InventoryService
    participant P as PaymentService

    C->>O: createOrder(txId)
    O-->>C: success
    C->>I: reserve(txId, orderId)
    I-->>C: success
    C->>P: charge(txId, orderId)
    Note over P: 实际扣款成功,但响应超时
    P--xC: timeout
    C->>I: release(txId, orderId)
    I-->>C: success
    C->>O: cancel(txId, orderId)
    O-->>C: success
    Note over C,P: 最终出现:订单取消,但用户可能已被扣款

这是 Saga 排障里最经典的一类问题:调用超时不等于业务失败。

也就是说,编排器看到超时,会按失败处理并触发补偿;但下游服务可能已经执行成功了。

因此,Saga 的设计里必须有两个能力:

  1. 幂等
  2. 可查询最终状态

否则一旦超时,就只能靠猜。


实战代码(可运行)

下面我用 Python 写一个简化的 Saga 编排示例。它不依赖外部框架,直接可运行,重点体现:

  • 状态持久化思路
  • 幂等事务号 tx_id
  • 补偿逆序执行
  • 超时/失败后的止血逻辑

说明:这是教学版,便于你本地跑通和理解。生产环境一般会接数据库、消息队列、可观测系统。

目录结构

saga_demo.py

完整代码

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


class SagaException(Exception):
    pass


@dataclass
class Order:
    order_id: str
    status: str = "PENDING"


@dataclass
class SagaLog:
    tx_id: str
    order_id: str
    steps_done: List[str] = field(default_factory=list)
    status: str = "RUNNING"
    reason: str = ""


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

    def create_order(self, tx_id: str, order_id: str):
        if ("create_order", tx_id) in self.processed_tx:
            return
        self.orders[order_id] = Order(order_id=order_id, status="PENDING")
        self.processed_tx.add(("create_order", tx_id))
        print(f"[Order] create_order success, order={order_id}")

    def confirm_order(self, tx_id: str, order_id: str):
        if ("confirm_order", tx_id) in self.processed_tx:
            return
        self.orders[order_id].status = "CONFIRMED"
        self.processed_tx.add(("confirm_order", tx_id))
        print(f"[Order] confirm_order success, order={order_id}")

    def cancel_order(self, tx_id: str, order_id: str):
        if ("cancel_order", tx_id) in self.processed_tx:
            return
        if order_id in self.orders:
            self.orders[order_id].status = "CANCELLED"
        self.processed_tx.add(("cancel_order", tx_id))
        print(f"[Order] cancel_order success, order={order_id}")


class InventoryService:
    def __init__(self):
        self.stock = {"item-1": 10}
        self.reserved: Dict[str, int] = {}
        self.processed_tx = set()

    def reserve(self, tx_id: str, order_id: str, item_id: str, qty: int):
        if ("reserve", tx_id) in self.processed_tx:
            return
        if self.stock.get(item_id, 0) < qty:
            raise SagaException("库存不足")
        self.stock[item_id] -= qty
        self.reserved[order_id] = self.reserved.get(order_id, 0) + qty
        self.processed_tx.add(("reserve", tx_id))
        print(f"[Inventory] reserve success, order={order_id}, qty={qty}")

    def release(self, tx_id: str, order_id: str, item_id: str):
        if ("release", tx_id) in self.processed_tx:
            return
        qty = self.reserved.get(order_id, 0)
        self.stock[item_id] += qty
        self.reserved[order_id] = 0
        self.processed_tx.add(("release", tx_id))
        print(f"[Inventory] release success, order={order_id}, qty={qty}")


class PaymentService:
    def __init__(self, fail_mode="none"):
        self.balance = {"user-1": 1000}
        self.charged: Dict[str, int] = {}
        self.refunded = set()
        self.processed_tx = set()
        self.fail_mode = fail_mode

    def charge(self, tx_id: str, order_id: str, user_id: str, amount: int):
        if ("charge", tx_id) in self.processed_tx:
            return

        if self.fail_mode == "timeout":
            # 模拟“可能已扣款但调用超时”
            self.balance[user_id] -= amount
            self.charged[order_id] = amount
            self.processed_tx.add(("charge", tx_id))
            raise TimeoutError("支付服务超时")

        if self.fail_mode == "fail":
            raise SagaException("支付失败")

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

        self.balance[user_id] -= amount
        self.charged[order_id] = amount
        self.processed_tx.add(("charge", tx_id))
        print(f"[Payment] charge success, order={order_id}, amount={amount}")

    def refund(self, tx_id: str, order_id: str, user_id: str):
        if ("refund", tx_id) in self.processed_tx:
            return
        amount = self.charged.get(order_id, 0)
        self.balance[user_id] += amount
        self.refunded.add(order_id)
        self.processed_tx.add(("refund", tx_id))
        print(f"[Payment] refund success, order={order_id}, amount={amount}")

    def query_payment(self, order_id: str):
        if order_id in self.charged:
            return "SUCCESS"
        return "NOT_FOUND"


class SagaCoordinator:
    def __init__(self, order_svc, inventory_svc, payment_svc):
        self.order_svc = order_svc
        self.inventory_svc = inventory_svc
        self.payment_svc = payment_svc
        self.logs: Dict[str, SagaLog] = {}

    def execute(self, order_id: str, user_id: str, item_id: str, qty: int, amount: int):
        tx_id = str(uuid.uuid4())
        log = SagaLog(tx_id=tx_id, order_id=order_id)
        self.logs[tx_id] = log

        try:
            self.order_svc.create_order(tx_id, order_id)
            log.steps_done.append("create_order")

            self.inventory_svc.reserve(tx_id, order_id, item_id, qty)
            log.steps_done.append("reserve_inventory")

            try:
                self.payment_svc.charge(tx_id, order_id, user_id, amount)
                log.steps_done.append("charge_payment")
            except TimeoutError as e:
                print(f"[Coordinator] payment timeout, query final state, order={order_id}")
                payment_status = self.payment_svc.query_payment(order_id)
                if payment_status == "SUCCESS":
                    print(f"[Coordinator] payment actually succeeded, continue")
                    log.steps_done.append("charge_payment")
                else:
                    raise e

            self.order_svc.confirm_order(tx_id, order_id)
            log.steps_done.append("confirm_order")

            log.status = "SUCCESS"
            print(f"[Coordinator] saga success, tx_id={tx_id}")
            return tx_id

        except Exception as e:
            print(f"[Coordinator] saga failed: {e}, start compensation")
            self.compensate(log, user_id, item_id)
            log.status = "FAILED"
            log.reason = str(e)
            return tx_id

    def compensate(self, log: SagaLog, user_id: str, item_id: str):
        tx_id = log.tx_id
        order_id = log.order_id

        # 按已完成步骤逆序补偿
        if "charge_payment" in log.steps_done:
            self.payment_svc.refund(tx_id, order_id, user_id)

        if "reserve_inventory" in log.steps_done:
            self.inventory_svc.release(tx_id, order_id, item_id)

        if "create_order" in log.steps_done:
            self.order_svc.cancel_order(tx_id, order_id)


def print_state(order_svc, inventory_svc, payment_svc, order_id):
    print("\n===== FINAL STATE =====")
    order = order_svc.orders.get(order_id)
    print("Order:", order)
    print("Inventory stock:", inventory_svc.stock)
    print("Reserved:", inventory_svc.reserved)
    print("Balance:", payment_svc.balance)
    print("Charged:", payment_svc.charged)
    print("=======================\n")


if __name__ == "__main__":
    print("=== CASE 1: 正常成功 ===")
    order_svc = OrderService()
    inventory_svc = InventoryService()
    payment_svc = PaymentService(fail_mode="none")
    coordinator = SagaCoordinator(order_svc, inventory_svc, payment_svc)
    order_id = "order-1001"
    coordinator.execute(order_id, "user-1", "item-1", 2, 100)
    print_state(order_svc, inventory_svc, payment_svc, order_id)

    print("=== CASE 2: 支付失败,触发补偿 ===")
    order_svc = OrderService()
    inventory_svc = InventoryService()
    payment_svc = PaymentService(fail_mode="fail")
    coordinator = SagaCoordinator(order_svc, inventory_svc, payment_svc)
    order_id = "order-1002"
    coordinator.execute(order_id, "user-1", "item-1", 2, 100)
    print_state(order_svc, inventory_svc, payment_svc, order_id)

    print("=== CASE 3: 支付超时,但实际扣款成功,靠查单避免误补偿 ===")
    order_svc = OrderService()
    inventory_svc = InventoryService()
    payment_svc = PaymentService(fail_mode="timeout")
    coordinator = SagaCoordinator(order_svc, inventory_svc, payment_svc)
    order_id = "order-1003"
    coordinator.execute(order_id, "user-1", "item-1", 2, 100)
    print_state(order_svc, inventory_svc, payment_svc, order_id)

运行方式

python saga_demo.py

你应该重点观察什么

这个示例故意做了三种情况:

  1. 正常成功
  2. 支付失败,触发补偿
  3. 支付超时,但通过查单发现其实已成功,因此继续确认订单

第三种最关键,因为它对应线上高频问题:超时语义不清导致误补偿。


设计要点:为什么这份代码能跑,但离生产还差几步?

很多教程到“能跑”就结束了,但真正做 troubleshooting,必须知道它的边界。

1. tx_id 必须全链路唯一

每个正向动作和补偿动作都要绑定同一个事务上下文,比如:

  • tx_id
  • order_id
  • step_name

否则服务重试时根本没法做幂等判断。

2. 补偿动作必须天然幂等

比如 release_inventory 不能因为消息重复而把库存加两次。

所以设计时要尽量使用状态驱动而不是“纯增减”:

  • 错误做法:收到补偿就 stock += qty
  • 更稳做法:根据冻结记录释放尚未释放的数量

3. 日志不是“打印出来就算了”,而是事务账本

生产里的 Saga Log 至少要记录:

  • tx_id
  • 业务主键 order_id
  • 当前步骤
  • 当前状态
  • 重试次数
  • 最近一次错误原因
  • 最后更新时间

如果没有这些字段,排查基本靠猜。


常见坑与排查

下面这部分是这篇文章最核心的内容。我按“现象 -> 定位路径 -> 止血方案”的方式来写。

坑 1:订单长期停留在 PENDING

现象

  • 用户投诉订单一直处理中
  • 后台查询订单状态卡在 PENDING
  • 库存可能已冻结,用户也可能已扣款

常见原因

  1. 编排器调用中断,未继续后续步骤
  2. 本地事务成功,但 Saga 状态未落库
  3. 编排器重启后,未恢复未完成事务
  4. 消息丢失或消费失败无重试

定位路径

建议按这个顺序查:

  1. 查 Saga 日志表:tx_id/order_id 当前停在哪一步
  2. 查订单服务本地状态
  3. 查库存冻结记录
  4. 查支付扣款记录或支付网关查单
  5. 对照时间线,看是“执行没发生”,还是“执行发生但状态没记上”

止血方案

  • 增加定时扫描任务,扫描超时未完成事务
  • 超时后不要直接补偿,优先查下游最终状态
  • 对长时间未决事务转 MANUAL_REVIEW

示例扫描逻辑:

def scan_timeout_sagas(logs, timeout_seconds=60):
    # 教学版示例:实际应比较数据库中的更新时间
    timeout_list = []
    for tx_id, log in logs.items():
        if log.status == "RUNNING":
            timeout_list.append((tx_id, log.order_id, log.steps_done))
    return timeout_list

坑 2:补偿执行了,但数据越补越错

现象

  • 库存变成负数或异常增加
  • 一个订单重复退款
  • 订单状态反复在 CANCELLED/CONFIRMED 之间跳

根因

补偿没有做幂等。

这是我见过最多的实现问题。很多人会认为“补偿只会执行一次”,但线上一定会遇到:

  • MQ 重投
  • 编排器重试
  • 服务超时后再次调用
  • 运维人工重复触发

定位路径

重点查这三类信息:

  1. 同一个 tx_id 是否出现重复调用
  2. 同一个 order_id 是否存在多条补偿记录
  3. 补偿逻辑是否基于当前状态判断

止血方案

  • 给每个动作建立去重表或幂等键
  • 补偿前先判断资源当前状态
  • 对外部支付退款使用“退款单号”保证唯一

示例幂等保护:

def idempotent_execute(processed_set, action_name, tx_id, fn):
    key = (action_name, tx_id)
    if key in processed_set:
        return
    fn()
    processed_set.add(key)

坑 3:支付超时后误补偿,导致“已扣款但订单取消”

现象

  • 用户余额已扣
  • 订单却显示取消
  • 客服无法快速判断是扣款成功还是失败

根因

调用方把“网络超时”当成“业务失败”。

定位路径

  1. 查调用日志,确认是否发生超时
  2. 查支付服务本地扣款记录
  3. 查支付网关查单结果
  4. 对比补偿时间点是否早于最终成功回写

止血方案

  • 任何超时场景先查单,再决定是否补偿
  • 查单仍未知时,进入 PAYMENT_UNKNOWNMANUAL_REVIEW
  • 不要把超时直接映射为失败

建议引入一个中间状态:

flowchart LR
    A[调用支付] --> B{返回结果}
    B -->|成功| C[继续流程]
    B -->|失败| D[触发补偿]
    B -->|超时/未知| E[查单]
    E -->|成功| C
    E -->|失败| D
    E -->|仍未知| F[人工复核/延迟重试]

坑 4:补偿顺序错误

现象

  • 先取消订单,再退款失败
  • 先解冻库存,但支付未退款
  • 用户看到订单关闭,但钱没回来

根因

补偿不是随便做的,一般应按正向步骤逆序执行

因为后面的动作通常依赖前面的动作而成立。

定位路径

看 Saga 定义表或编排代码,确认:

  • 正向顺序
  • 补偿顺序
  • 每一步是否需要“条件补偿”

止血方案

  • 显式维护步骤栈
  • 只补偿已成功步骤
  • 逆序执行补偿

坑 5:本地事务与消息发送不一致

现象

  • 订单已创建,但“订单创建成功”事件没发出去
  • 或事件发出去了,但本地订单实际上没写成功

根因

这是典型的“双写不一致”。

定位路径

  1. 查本地业务表
  2. 查消息表/outbox 表
  3. 查消息队列投递状态
  4. 查消费者是否消费成功

止血方案

生产环境建议用 Outbox Pattern

  • 业务数据和待发送消息写在同一本地事务里
  • 再由独立投递程序异步发送 MQ

示例 SQL:

CREATE TABLE orders (
  order_id VARCHAR(64) PRIMARY KEY,
  status VARCHAR(32) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE outbox_events (
  event_id VARCHAR(64) PRIMARY KEY,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload TEXT NOT NULL,
  status VARCHAR(16) NOT NULL DEFAULT 'NEW',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

定位路径:线上排障我通常怎么查

如果线上真的出问题,我一般按下面这个顺序走,比较稳。

第一步:先定性,是“执行失败”还是“状态丢失”

你要先搞清楚:

  • 下游服务真的没执行?
  • 还是执行了,但编排器不知道?
  • 还是执行和补偿都发生过,只是最终状态不一致?

这决定后面是查调用链、查数据库,还是查消息系统。

第二步:围绕业务主键串时间线

建议统一所有日志都打印:

  • tx_id
  • order_id
  • user_id
  • step
  • status

这样你能很快按订单维度拉出时间线。

例如:

tx=abc order=1001 step=create_order status=success
tx=abc order=1001 step=reserve_inventory status=success
tx=abc order=1001 step=charge_payment status=timeout
tx=abc order=1001 step=query_payment status=success
tx=abc order=1001 step=confirm_order status=success

如果没有统一日志字段,排查会非常痛苦。

第三步:核对“资源事实”而不是只看编排状态

编排状态只能告诉你“系统以为怎样了”,但真正排障时要看资源事实:

  • 订单表里到底是什么状态
  • 库存冻结记录还在不在
  • 用户余额实际扣没扣
  • 退款单是否生成
  • MQ 消息是否消费

第四步:决定止血策略

常见止血动作有三种:

  1. 延迟重试:适合短暂网络抖动
  2. 自动补偿:适合状态明确的失败
  3. 人工介入:适合支付未知、第三方状态不明确

这里的原则很简单:

如果你无法确认资源真实状态,就不要盲目补偿。


安全/性能最佳实践

Saga 讨论里经常只讲一致性,但生产环境里,安全和性能同样重要。

1. 安全:补偿接口不能“谁都能调”

补偿接口如果裸露出来,风险很大:

  • 恶意重复请求触发退款
  • 越权取消订单
  • 内部系统误调用导致大量补偿

建议至少做到:

  • 内部接口鉴权
  • 请求签名或服务间 mTLS
  • 幂等校验
  • 审计日志记录调用人、调用源、参数快照

2. 安全:敏感字段不要全量打日志

支付、用户、地址相关信息,排障日志里不要直接裸打:

  • 用户手机号脱敏
  • 支付账户信息脱敏
  • 退款结果日志不要带完整卡号、证件号

3. 性能:缩短本地事务时间

Saga 的每一步虽然是本地事务,但如果本地事务很重,仍会拖慢整体链路。

建议:

  • 本地事务里只做必要写入
  • 避免长 SQL、全表扫描
  • 外部调用不要放进数据库事务里

4. 性能:补偿风暴要限流

一旦某个下游服务故障,可能触发大量事务补偿,进而把库存、支付、订单服务再次打爆。

建议:

  • 补偿任务限速
  • 按租户/业务线隔离重试队列
  • 熔断下游不稳定服务
  • 设置最大重试次数,超过后转人工

5. 性能:状态存储要支持恢复

编排器如果只把状态放内存里,重启后事务上下文全没了。

至少要落盘:

  • Saga 实例表
  • Saga 步骤表
  • 错误表
  • 重试任务表

示例表设计:

CREATE TABLE saga_instances (
  tx_id VARCHAR(64) PRIMARY KEY,
  order_id VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  current_step VARCHAR(64),
  reason TEXT,
  retry_count INT NOT NULL DEFAULT 0,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE saga_steps (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  tx_id VARCHAR(64) NOT NULL,
  step_name VARCHAR(64) NOT NULL,
  step_status VARCHAR(32) NOT NULL,
  compensation_status VARCHAR(32),
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

6. 可观测性:把事务做成可搜索、可追踪、可告警

建议至少有三类监控:

  • 成功率:Saga 成功率、补偿率
  • 时延:事务完成平均时长、超时比例
  • 堵塞:长时间 RUNNING 的事务数量

常见告警阈值可先这样设:

  • 同一事务运行超过 5 分钟告警
  • 补偿失败率超过 1% 告警
  • 某一步超时率骤增告警

什么时候不适合用 Saga?

虽然 Saga 很实用,但也不是银弹。

以下场景要谨慎:

1. 无法定义补偿动作

比如某些不可逆业务:

  • 已经发出的外部通知无法撤回
  • 已完成的线下履约无法回滚
  • 第三方动作不可撤销

如果补偿本身无法定义,就别强行套 Saga。

2. 业务不能接受短暂不一致

例如极少数金融核心账务场景,如果要求严格强一致,Saga 可能不合适,需要更严格的事务方案和账务建模。

3. 团队尚未建立基础设施

如果连这些都没有:

  • 幂等机制
  • 统一日志
  • 可观测链路
  • 定时恢复任务
  • 手工补偿后台

那直接上 Saga,最后大概率是“代码看着像分布式事务,线上靠人工兜底”。


一份可执行的落地清单

如果你准备在项目里上 Saga,我建议先完成下面这份清单。

设计阶段

  • 明确定义正向步骤和补偿步骤
  • 每个步骤都定义成功、失败、超时、未知四种结果
  • 设计状态机,而不是直接写串行调用
  • 明确哪些状态允许人工介入

开发阶段

  • 每个动作有幂等键
  • 每个补偿动作也有幂等键
  • 超时后支持查最终状态
  • 状态持久化到数据库
  • 失败支持重试和最大重试次数

测试阶段

  • 模拟库存不足
  • 模拟支付失败
  • 模拟支付超时但实际成功
  • 模拟消息重复投递
  • 模拟编排器重启恢复
  • 模拟补偿接口重复调用

生产阶段

  • 有超时扫描和恢复任务
  • 有手工补偿或人工复核后台
  • 有成功率、补偿率、超时率监控
  • 有按 tx_id/order_id 查询全链路日志能力

总结

Saga 模式真正的价值,不是让分布式事务“像本地事务一样简单”,而是让你在接受最终一致性的前提下,仍然能把复杂业务做得可恢复、可观测、可止血

如果只记住三句话,我建议你记这三句:

  1. 补偿不是回滚,而是新的业务动作。
  2. 超时不是失败,未知状态必须查证。
  3. 幂等和状态机,比接口调用顺序更重要。

落地上,我给一个比较务实的建议:

  • 中级团队优先选编排式 Saga
  • 先把状态持久化、幂等、查单、人工介入这四件事做好
  • 再考虑事件化、异步化、流程引擎化

最后说个很现实的边界条件:
如果你的业务链路里存在“不可补偿、不可查状态、不可人工介入”的关键步骤,那 Saga 就不是最优解,至少不是可以单独承担一致性责任的解法。

把这些前提想清楚,再上生产,很多坑其实是能提前绕过去的。


分享到:

上一篇
《微服务架构下的分布式事务实战:基于 Saga 模式实现订单与库存一致性》
下一篇
《分布式架构中基于一致性哈希与服务发现的动态扩缩容实战指南》