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

《微服务架构中的分布式事务落地实践:基于 Saga 模式的设计与排错指南》

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

背景与问题

只要系统一拆成微服务,分布式事务几乎迟早会找上门。

一个最常见的业务链路是这样的:下单 -> 扣库存 -> 扣余额 -> 创建物流单
单体时代,我们可以把这几个步骤塞进一个本地事务里,成了就一起提交,失败就一起回滚。但到了微服务架构里,每个服务都有自己的数据库,甚至技术栈都不一样,这时候再想靠数据库事务“一把梭”基本不现实。

很多团队一开始会纠结:要不要上 2PC / XA?
现实通常是:

  • 性能开销大
  • 资源锁持有时间长
  • 云原生环境支持一般
  • 一旦链路长、参与方多,问题会非常难排查

所以在业务系统里,Saga 模式更常见。它的核心思路不是“全局强一致”,而是:

把一个长事务拆成多个本地事务,每个本地事务成功后继续向前;如果中途失败,就按相反顺序执行补偿操作,把系统拉回一个可接受状态。

这套思路听起来很顺,但真正上线后,问题往往不是“会不会写”,而是:

  • 为什么补偿没生效?
  • 为什么消息重复导致库存被扣两次?
  • 为什么订单状态卡在“处理中”?
  • 为什么重试后越修越乱?

这篇文章我会从落地设计 + 可运行代码 + 排错路径三个角度,把 Saga 模式讲透,尤其聚焦故障排查


背景中的典型问题场景

先看一个具体例子。

假设有 3 个服务:

  • OrderService:创建订单
  • InventoryService:冻结库存
  • PaymentService:冻结或扣减余额

目标流程:

  1. 创建订单
  2. 冻结库存
  3. 冻结余额
  4. 全部成功后,订单改为 CONFIRMED
  5. 如果任一步骤失败,则执行补偿:
    • 余额解冻
    • 库存解冻
    • 订单取消

这就是一个典型的 Saga。

为什么 Saga 容易“看起来没问题,上线后问题很多”?

因为它把一个原子问题,拆成了一组最终一致性问题。
而最终一致性最大的问题不在“业务逻辑”,而在“异常路径”。

比如:

  • 库存服务成功了,但响应丢了,编排器以为失败,于是触发补偿
  • 支付服务其实没收到请求,但消息系统返回成功
  • 补偿操作执行了两次,结果把已经恢复的库存又加了一遍
  • 某个服务本地事务提交成功,但事件没有发出去,链路断在半路

这些问题,如果设计时没留“幂等、状态机、可观测性”的位置,后面排查会非常痛苦。


核心原理

Saga 通常有两种实现方式:

  1. Choreography(事件编排/舞蹈式)

    • 各服务通过事件彼此驱动
    • 优点:解耦
    • 缺点:链路一长就难追踪,排错困难
  2. Orchestration(集中编排)

    • 由一个 Saga Orchestrator 统一驱动流程
    • 优点:流程清晰,便于排查
    • 缺点:编排器会成为核心组件

在 troubleshooting 类型的文章里,我更推荐先采用集中编排式 Saga。原因很简单:更容易定位问题

Saga 的状态流转

stateDiagram-v2
    [*] --> CREATED
    CREATED --> INVENTORY_RESERVED: 冻结库存成功
    INVENTORY_RESERVED --> PAYMENT_RESERVED: 冻结余额成功
    PAYMENT_RESERVED --> CONFIRMED: 订单确认
    INVENTORY_RESERVED --> COMPENSATING: 支付失败
    PAYMENT_RESERVED --> COMPENSATING: 下游确认失败
    CREATED --> FAILED: 创建订单失败
    COMPENSATING --> CANCELLED: 补偿完成
    COMPENSATING --> COMPENSATION_FAILED: 补偿异常

这个图里最重要的不是“成功路径”,而是两件事:

  • 每一步都必须有明确状态
  • 补偿失败也必须有状态承接

很多系统的问题恰恰在于:只定义了成功和失败,没有定义“补偿中”“补偿失败”“待人工处理”。

Saga 的执行与补偿顺序

sequenceDiagram
    participant Client
    participant Orchestrator
    participant OrderService
    participant InventoryService
    participant PaymentService

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

    Orchestrator->>InventoryService: reserveInventory(orderId)
    InventoryService-->>Orchestrator: success

    Orchestrator->>PaymentService: reservePayment(orderId)
    PaymentService-->>Orchestrator: failed(balance insufficient)

    Orchestrator->>InventoryService: compensateReleaseInventory(orderId)
    InventoryService-->>Orchestrator: success

    Orchestrator->>OrderService: compensateCancelOrder(orderId)
    OrderService-->>Orchestrator: success

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

这里有个很关键的工程原则:

补偿不是回滚。补偿是一个新的、显式的业务动作。

比如:

  • 扣库存的补偿不是“数据库 rollback”
  • 而是“把冻结库存释放回来”
  • 支付的补偿不是“事务撤销”
  • 而是“解除冻结”或“发起退款”

这意味着补偿逻辑必须像正向逻辑一样认真设计,而不是顺手写个反操作。


设计落地:先把边界划清楚

我通常会让团队先回答 4 个问题,再开始写代码。

1. 每个本地事务的提交点在哪里?

例如:

  • 订单服务:订单记录落库即视为成功
  • 库存服务:冻结库存写入冻结表即成功
  • 支付服务:账户冻结记录落库即成功

提交点必须明确,否则你永远说不清“到底该不该补偿”。

2. 补偿动作是否天然可逆?

不是所有动作都适合 Saga。

适合 Saga 的动作通常是:

  • 冻结/解冻
  • 预占/释放
  • 待确认/取消

不太适合直接做 Saga 的动作通常是:

  • 发短信、发邮件
  • 调用不可逆的第三方接口
  • 已经产生法律/财务效力的动作

如果业务里存在不可逆操作,建议:

  • 把不可逆动作放到 Saga 最后
  • 或引入人工审核
  • 或用对账机制兜底

3. 是否具备幂等能力?

正向操作和补偿操作都要幂等。
这是分布式事务里最不能省的一条。

幂等至少要覆盖:

  • 请求重复投递
  • 超时后重试
  • 补偿重复触发
  • 消息乱序到达

4. 链路是否可观测?

至少要有:

  • sagaId
  • orderId
  • stepName
  • stepStatus
  • retryCount
  • lastError

否则线上一出问题,你只能在日志里“盲人摸象”。


实战代码(可运行)

下面我用一个Python Flask 单文件示例模拟一个最小可运行的 Saga 编排器。
它不是生产级框架,但足够把核心逻辑说明白:状态流转、幂等、补偿、故障注入

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

from flask import Flask, request, jsonify
from uuid import uuid4
import threading

app = Flask(__name__)

lock = threading.Lock()

# 模拟数据库
orders = {}
inventory = {"item-1": 10}
inventory_reservations = {}
accounts = {"user-1": 100}
payment_reservations = {}
sagas = {}

def idempotent_step_done(store, key):
    return store.get(key) is True

def mark_step_done(store, key):
    store[key] = True

@app.route("/saga/order", methods=["POST"])
def create_order_saga():
    payload = request.json or {}
    user_id = payload.get("userId", "user-1")
    item_id = payload.get("itemId", "item-1")
    quantity = int(payload.get("quantity", 1))
    amount = int(payload.get("amount", 10))
    inject = payload.get("injectFailureAt")  # inventory / payment / confirm

    saga_id = str(uuid4())
    order_id = str(uuid4())

    sagas[saga_id] = {
        "sagaId": saga_id,
        "orderId": order_id,
        "status": "STARTED",
        "steps": [],
        "lastError": None
    }

    try:
        # Step 1: 创建订单
        create_order(order_id, user_id, item_id, quantity, amount)
        record_step(saga_id, "create_order", "DONE")

        # Step 2: 冻结库存
        reserve_inventory(order_id, item_id, quantity, inject == "inventory")
        record_step(saga_id, "reserve_inventory", "DONE")

        # Step 3: 冻结余额
        reserve_payment(order_id, user_id, amount, inject == "payment")
        record_step(saga_id, "reserve_payment", "DONE")

        # Step 4: 确认订单
        confirm_order(order_id, inject == "confirm")
        record_step(saga_id, "confirm_order", "DONE")

        sagas[saga_id]["status"] = "COMPLETED"
        return jsonify({
            "success": True,
            "sagaId": saga_id,
            "orderId": order_id,
            "status": "COMPLETED"
        })

    except Exception as e:
        sagas[saga_id]["lastError"] = str(e)
        sagas[saga_id]["status"] = "COMPENSATING"

        compensation_errors = []

        # 按逆序补偿
        try:
            compensate_payment(order_id)
            record_step(saga_id, "compensate_payment", "DONE")
        except Exception as ce:
            compensation_errors.append(f"compensate_payment: {ce}")
            record_step(saga_id, "compensate_payment", "FAILED")

        try:
            compensate_inventory(order_id)
            record_step(saga_id, "compensate_inventory", "DONE")
        except Exception as ce:
            compensation_errors.append(f"compensate_inventory: {ce}")
            record_step(saga_id, "compensate_inventory", "FAILED")

        try:
            cancel_order(order_id)
            record_step(saga_id, "cancel_order", "DONE")
        except Exception as ce:
            compensation_errors.append(f"cancel_order: {ce}")
            record_step(saga_id, "cancel_order", "FAILED")

        if compensation_errors:
            sagas[saga_id]["status"] = "COMPENSATION_FAILED"
            return jsonify({
                "success": False,
                "sagaId": saga_id,
                "orderId": order_id,
                "status": "COMPENSATION_FAILED",
                "error": str(e),
                "compensationErrors": compensation_errors
            }), 500

        sagas[saga_id]["status"] = "CANCELLED"
        return jsonify({
            "success": False,
            "sagaId": saga_id,
            "orderId": order_id,
            "status": "CANCELLED",
            "error": str(e)
        }), 400

@app.route("/saga/<saga_id>", methods=["GET"])
def get_saga(saga_id):
    saga = sagas.get(saga_id)
    if not saga:
        return jsonify({"error": "not found"}), 404
    return jsonify(saga)

def record_step(saga_id, step_name, status):
    sagas[saga_id]["steps"].append({
        "step": step_name,
        "status": status
    })

def create_order(order_id, user_id, item_id, quantity, amount):
    with lock:
        if order_id in orders:
            return
        orders[order_id] = {
            "orderId": order_id,
            "userId": user_id,
            "itemId": item_id,
            "quantity": quantity,
            "amount": amount,
            "status": "CREATED"
        }

def confirm_order(order_id, fail=False):
    if fail:
        raise Exception("confirm order failed")
    with lock:
        if order_id not in orders:
            raise Exception("order not found")
        orders[order_id]["status"] = "CONFIRMED"

def cancel_order(order_id):
    with lock:
        if order_id not in orders:
            return
        if orders[order_id]["status"] == "CANCELLED":
            return
        orders[order_id]["status"] = "CANCELLED"

def reserve_inventory(order_id, item_id, quantity, fail=False):
    with lock:
        if idempotent_step_done(inventory_reservations, order_id):
            return
        if fail:
            raise Exception("inventory reservation failed")
        if inventory.get(item_id, 0) < quantity:
            raise Exception("inventory not enough")
        inventory[item_id] -= quantity
        mark_step_done(inventory_reservations, order_id)

def compensate_inventory(order_id):
    with lock:
        if not idempotent_step_done(inventory_reservations, order_id):
            return
        order = orders.get(order_id)
        if not order:
            return
        inventory[order["itemId"]] += order["quantity"]
        inventory_reservations[order_id] = False

def reserve_payment(order_id, user_id, amount, fail=False):
    with lock:
        if idempotent_step_done(payment_reservations, order_id):
            return
        if fail:
            raise Exception("payment reservation failed")
        if accounts.get(user_id, 0) < amount:
            raise Exception("balance not enough")
        accounts[user_id] -= amount
        mark_step_done(payment_reservations, order_id)

def compensate_payment(order_id):
    with lock:
        if not idempotent_step_done(payment_reservations, order_id):
            return
        order = orders.get(order_id)
        if not order:
            return
        accounts[order["userId"]] += order["amount"]
        payment_reservations[order_id] = False

if __name__ == "__main__":
    app.run(debug=True)

运行方式

先安装依赖:

pip install flask
python app.py

正常执行

curl -X POST http://127.0.0.1:5000/saga/order \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "user-1",
    "itemId": "item-1",
    "quantity": 2,
    "amount": 20
  }'

注入支付失败,观察补偿

curl -X POST http://127.0.0.1:5000/saga/order \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "user-1",
    "itemId": "item-1",
    "quantity": 2,
    "amount": 20,
    "injectFailureAt": "payment"
  }'

查询 Saga 状态

curl http://127.0.0.1:5000/saga/<sagaId>

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

下面这部分是我觉得最有价值的:不是只讲原理,而是告诉你线上一般怎么坏

场景 1:订单卡在处理中,迟迟不结束

现象

  • 用户侧看到“下单中”
  • Saga 状态停在 STARTEDCOMPENSATING
  • 下游某一步没有最终状态

常见原因

  • 编排器调用下游超时,但没有明确重试策略
  • 本地事务成功了,但响应丢失
  • 编排状态更新失败,业务做完了但 saga 表没写进去

典型误区

最容易犯的错误是:
“超时 == 失败”

在分布式系统里,超时只代表:你不知道它成功还是失败
这时最正确的动作通常不是立刻补偿,而是先做查询确认

建议做法

为每个步骤设计 3 个接口:

  • execute
  • query
  • compensate

如果 execute 超时:

  1. 先调用 query
  2. 如果确认已成功,继续下一个步骤
  3. 如果确认未执行,再重试
  4. 如果状态未知,进入人工或延迟恢复队列

场景 2:补偿执行了,但数据越来越乱

现象

  • 库存被多加
  • 余额被多退
  • 订单状态与库存状态不一致

常见原因

  • 补偿接口不是幂等的
  • 补偿顺序写错
  • 同一个 saga 被并发补偿多次
  • 补偿依据“当前状态”而不是“原始操作记录”

我踩过的一个坑

有一次库存补偿是直接:

inventory += quantity

看起来没问题,但如果补偿消息重复投递两次,就会多加一次库存。
后来改成基于冻结记录来补偿,只有存在冻结记录时才释放,而且释放后把冻结标记改掉,这才真正稳下来。

正确原则

补偿必须满足:

  • 幂等
  • 可审计
  • 基于操作日志/冻结记录
  • 不依赖模糊的当前值推断

场景 3:业务明明成功了,但用户看到失败

现象

  • 实际订单已确认
  • 库存和余额都已处理
  • 前端却提示“下单失败”

常见原因

  • 最后一跳响应丢失
  • 编排器在返回前崩溃
  • 客户端超时过短

这种场景很危险,因为用户可能再次发起下单,导致重复业务。

止血方案

短期止血最有效的是两件事:

  1. 给客户端引入业务幂等键

    • requestId
    • 同一个 requestId 多次提交返回同一订单结果
  2. 前端失败后先查单再重试

    • 不是直接再次下单
    • 而是查询“这次请求到底有没有成功”

定位路径:排错不要靠猜

排查 Saga 问题,我建议固定按下面这条路径走。

flowchart TD
    A[收到故障现象] --> B[确认 sagaId / orderId / requestId]
    B --> C[查看编排器状态机记录]
    C --> D{卡在哪个步骤?}
    D -->|执行前| E[查上一步是否真的完成]
    D -->|执行中| F[查下游服务日志与超时情况]
    D -->|补偿中| G[查补偿幂等记录与重试次数]
    E --> H[核对消息/调用链追踪]
    F --> H
    G --> H
    H --> I[确认是重复执行、状态丢失还是补偿失败]
    I --> J[临时止血]
    J --> K[补监控与修复设计]

第一步:先拿到关联 ID

线上排查最怕没有统一标识。
建议至少串起来这几个字段:

  • requestId:客户端请求幂等键
  • sagaId:一次 Saga 实例
  • orderId:业务实体 ID
  • traceId:调用链跟踪 ID

如果日志里只有 orderId,没有 sagaId,排查效率会差很多。

第二步:确认“步骤真实执行状态”

不要只看编排器日志,也不要只看下游服务日志。
要交叉确认:

  • 编排器认为这一步执行了吗?
  • 下游服务实际上执行了吗?
  • 执行记录是否持久化?
  • 响应有没有丢?

这是判断“该继续还是该补偿”的核心。

第三步:核对消息与重试行为

如果你是通过消息队列驱动步骤,还要看:

  • 消息是否重复消费
  • 是否发生堆积
  • 消费成功前是否提前 ack
  • 死信队列是否有残留
  • 重试间隔是否过短导致雪崩

常见坑与排查

下面把我见过最常见的一些坑收敛成清单。

1. 只做正向幂等,不做补偿幂等

问题表现

  • 正向接口安全
  • 一到补偿就重复退款、重复释放库存

排查点

  • 补偿表里是否有唯一键
  • 是否存在“已补偿”标记
  • 是否通过业务流水判断补偿是否执行过

建议

补偿接口要像正向接口一样,单独设计幂等键。
不要把补偿当成附属逻辑。


2. 用数据库当前值直接回滚

问题表现

  • 高并发下补偿结果不稳定
  • 数值型资源出现偏差

错误示例

UPDATE inventory SET stock = stock + 1 WHERE item_id = 'item-1';

如果你不知道最初到底扣了多少、扣了哪一笔,这种补偿就不可靠。

建议

冻结表 / 预占表 / 操作流水表做依据,例如:

CREATE TABLE inventory_reservation (
  order_id VARCHAR(64) PRIMARY KEY,
  item_id VARCHAR(64) NOT NULL,
  quantity INT NOT NULL,
  status VARCHAR(32) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

补偿时根据这张表释放,而不是凭空“加回去”。


3. 没有中间状态,只有成功/失败

问题表现

  • 出故障时只能看到“失败”
  • 不知道是执行失败、等待重试,还是补偿失败

建议状态

至少包括:

  • STARTED
  • STEP_DONE
  • COMPENSATING
  • CANCELLED
  • COMPENSATION_FAILED
  • COMPLETED

中间状态不是为了好看,是为了运维和排障。


4. 编排状态和业务状态分离,但更新无原子保障

问题表现

  • 业务做完了,Saga 状态没更新
  • Saga 状态显示完成,实际业务没做完

根因

本地事务提交与消息发送/状态更新不是一个原子动作。

建议

这是经典问题,通常用 Outbox Pattern 处理更稳。

flowchart LR
    A[本地业务事务] --> B[写业务表]
    A --> C[写Outbox事件表]
    C --> D[后台投递器]
    D --> E[消息队列/下游服务]

也就是说:

  • 在同一个本地事务里
    • 写业务数据
    • 写待发送事件
  • 再由后台任务异步投递事件

这样能避免“业务成功但事件丢失”。


5. 补偿顺序写反

问题表现

  • 已取消订单但库存没释放
  • 资金先退了,但库存仍冻结
  • 产生临时不一致且无法继续补偿

原则

补偿顺序应与执行顺序相反。

执行顺序:

  1. 创建订单
  2. 冻结库存
  3. 冻结余额

补偿顺序:

  1. 释放余额
  2. 释放库存
  3. 取消订单

如果顺序错了,常常会把后续补偿依赖的上下文一并删掉。


安全/性能最佳实践

Saga 通常不只是一道业务题,也是一道稳定性题。

安全最佳实践

1. 补偿接口要鉴权

很多团队默认“内部接口就安全”,这非常危险。
补偿接口本质上能修改关键业务状态,必须有:

  • 服务间身份认证
  • 最小权限控制
  • 防重放机制
  • 操作审计日志

尤其是退款、解冻、取消类操作,权限要比普通查询严格。

2. 敏感信息不要打全量日志

排查分布式事务确实需要日志,但别把这些直接写出去:

  • 用户完整支付信息
  • 身份证号
  • 银行卡号
  • 完整请求体中的敏感字段

建议:

  • 只记录必要字段
  • 对敏感字段脱敏
  • 用 traceId 关联,而不是把所有数据直接打日志

3. 人工兜底入口要可控

当 Saga 进入 COMPENSATION_FAILED 时,通常需要人工介入。
这时候后台系统要支持:

  • 查看完整步骤
  • 单步重试
  • 强制补偿
  • 挂起处理
  • 审计操作人和时间

而不是让研发直接上数据库改状态。


性能最佳实践

1. 缩短 Saga 链路

步骤越多,故障概率越高。
如果某个步骤不是强依赖,不要硬塞进主链路。

例如:

  • 主链路:订单、库存、支付
  • 异步链路:积分、优惠券通知、短信

2. 避免长时间资源占用

Saga 虽然不持有数据库全局锁,但仍可能通过“冻结资源”间接占用业务资源。
所以要为冻结资源设置:

  • 过期时间
  • 自动清理任务
  • 异常恢复策略

否则会出现大量“幽灵冻结”。

3. 重试要有限制

重试不是越多越好。
建议明确:

  • 最大重试次数
  • 指数退避
  • 熔断策略
  • 失败转人工队列

否则在下游故障时,编排器的重试会把整个系统拖垮。

4. 关键步骤尽量本地化决策

如果一个简单动作必须跨多个服务才能确认,就会徒增延迟和不确定性。
能在本地服务内完成校验的,尽量别拆成多个远程调用。


一套可执行的排错清单

如果线上已经出问题了,可以按这个清单快速处理。

止血方案

  1. 暂停新流量或按租户限流
  2. 关闭高风险自动补偿,避免越补越乱
  3. 提高查询接口优先级,先让用户能查到真实状态
  4. 开启失败 Saga 的隔离队列
  5. 导出 COMPENSATION_FAILED 实例,人工逐条核对

定位清单

  • 是否存在重复请求?
  • sagaId 是否唯一?
  • 当前卡在哪个 step?
  • step 的本地事务是否实际提交?
  • 是否发生超时但真实成功?
  • 补偿是否重复执行?
  • 是否有 outbox 未投递?
  • 消息队列是否重复消费或堆积?
  • 是否缺少中间状态导致误判?

修复清单

  • 给步骤加幂等键
  • 给补偿加独立幂等保障
  • 增加 query 接口用于超时确认
  • 引入 outbox 保障业务与事件一致性
  • 给 Saga 增加人工处理状态
  • 补齐 traceId / sagaId 全链路日志

总结

Saga 不是“分布式事务的银弹”,但它是微服务场景下最务实的一种落地方式

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

Saga 的难点不在成功流程,而在失败后的可恢复性。

真正可上线、可运维、可排障的 Saga,至少要做到这几点:

  • 本地事务边界清晰
  • 正向与补偿都幂等
  • 状态机完整,不省中间态
  • 超时不等于失败,要支持查询确认
  • 关键链路有 sagaIdtraceId
  • 补偿失败可转人工,而不是硬重试到天荒地老
  • 业务数据与事件发送之间用 outbox 之类的机制兜住

最后给一个很实际的边界建议:

  • 如果你的业务动作天然可逆、允许短暂不一致,Saga 很适合
  • 如果你的业务动作强一致要求极高、且不可逆,不要勉强套 Saga,应该重新设计业务顺序或引入更强的约束机制

分布式事务真正考验的,不是“把成功流程跑通”,而是“出问题时你有没有能力把系统拉回来”。
而这,恰恰是 Saga 设计与排错的核心。


分享到:

上一篇
《集群架构实战:基于 Kubernetes 的高可用服务部署与故障自动恢复设计》
下一篇
《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南》