bigywater.tech
分布式事务 · 8

Saga:编排式与协同式的区别

Saga 用一串本地事务加补偿来代替分布式事务。谁来决定下一步做什么?集中在一个编排器里,还是让服务自己看事件接力?这篇用三个服务和真实的 RabbitMQ,把两种写法的成功、失败、服务宕机、加一步的修改成本逐一跑出来。

21.3 ms vs 16.7 ms
同一笔成功订单,编排式与协同式从开始到订单确认的时间(编排式没有消息,协同式 3 条消息)
0.63 s vs 2.06 s
库存服务挂 2 秒:编排式重试用完取消订单的时刻 vs 协同式服务恢复后完成的时刻
1 行 vs 2 个服务
加一个积分步骤:编排式改步骤表的行数 vs 协同式要改订阅的已有服务数

成功的一单

实测 · 本机执行输出
  编排式(一个编排器按顺序调用三个服务)→ CONFIRMED:
        0.0 ms  [编排器] 调用订单服务:创建订单 o-orch-ok(PENDING)
       14.0 ms  [编排器] 调用 库存服务 stock_reserve → 成功
       21.1 ms  [编排器] 调用 支付服务 pay_charge → 成功
       21.3 ms  [编排器] 订单 → CONFIRMED
    订单 {'o-orch-ok': 'CONFIRMED'},库存 99(预留中 1),余额 999920(已扣款 1 笔)
  协同式(服务之间通过 RabbitMQ 事件接力)→ CONFIRMED:
        0.3 ms  [订单服务] 创建订单 o-choreo-ok(PENDING)
        0.4 ms  [订单服务] 发出 order.created
        1.0 ms  [库存服务] 收到 order.created
        8.1 ms  [库存服务] 发出 stock.reserved
        8.6 ms  [支付服务] 收到 stock.reserved
       16.3 ms  [支付服务] 发出 payment.succeeded
       16.7 ms  [订单服务] 收到 payment.succeeded
    订单 {'o-choreo-ok': 'CONFIRMED'},库存 99(预留中 1),余额 999920(已扣款 1 笔)
    协同式这一单在 RabbitMQ 上走了 3 条消息;编排式没有消息,编排器直接发起了 3 次调用(创建订单、库存、支付),最后一次订单状态更新是本地调用

编排式是一个编排器顺序调用服务;协同式是服务之间用 order.created、stock.reserved、payment.succeeded 事件接力。数据结果一样,区别是谁掌握整个流程。

支付失败的补偿

实测 · 本机执行输出
  编排式 → CANCELLED:
        0.0 ms  [编排器] 调用订单服务:创建订单 o-orch-poor(PENDING)
        0.4 ms  [编排器] 调用 库存服务 stock_reserve → 成功
        0.7 ms  [编排器] 调用 支付服务 pay_charge → 失败:用户 1 余额不足
        1.1 ms  [编排器] 补偿:调用 库存服务 stock_release
        1.6 ms  [编排器] 订单 → CANCELLED
    订单 {'o-orch-poor': 'CANCELLED'},库存 100(预留中 0),余额 50(已扣款 0 笔)
  协同式 → CANCELLED:
        0.2 ms  [订单服务] 创建订单 o-choreo-poor(PENDING)
        0.3 ms  [订单服务] 发出 order.created
        0.7 ms  [库存服务] 收到 order.created
        7.5 ms  [库存服务] 发出 stock.reserved
        7.8 ms  [支付服务] 收到 stock.reserved
       14.7 ms  [支付服务] 发出 payment.failed
       15.0 ms  [库存服务] 收到 payment.failed
       15.4 ms  [库存服务] 发出 stock.released
       15.6 ms  [订单服务] 收到 stock.released
    订单 {'o-choreo-poor': 'CANCELLED'},库存 100(预留中 0),余额 50(已扣款 0 笔)
    共 4 条消息;补偿由 payment.failed 事件触发库存服务自己释放,再由 stock.released 触发订单服务取消

编排式由编排器主动调用 stock_release;协同式由 payment.failed 事件触发库存服务自己释放。补偿链条隐含在订阅关系里。

第一步就失败

实测 · 本机执行输出
  编排式 → CANCELLED:
        0.0 ms  [编排器] 调用订单服务:创建订单 o-orch-empty(PENDING)
        0.5 ms  [编排器] 调用 库存服务 stock_reserve → 失败:sku-1 库存不足
        0.7 ms  [编排器] 订单 → CANCELLED
    订单 {'o-orch-empty': 'CANCELLED'},库存 0(预留中 0),余额 1000000(已扣款 0 笔)
  协同式 → CANCELLED:
        0.4 ms  [订单服务] 创建订单 o-choreo-empty(PENDING)
        0.6 ms  [订单服务] 发出 order.created
        1.3 ms  [库存服务] 收到 order.created
        9.3 ms  [库存服务] 发出 stock.failed
        9.8 ms  [订单服务] 收到 stock.failed
    订单 {'o-choreo-empty': 'CANCELLED'},库存 0(预留中 0),余额 1000000(已扣款 0 笔)

库存不足时没有任何东西要补偿,两种写法都直接取消订单。

服务挂了

实测 · 本机执行输出
  编排式(每步最多重试 2 次、间隔 0.3 秒,用完就补偿并取消):订单状态变化 0.05s → PENDING,  0.63s → CANCELLED
        0.1 ms  [编排器] 调用订单服务:创建订单 o-down-orch(PENDING)
        7.8 ms  [编排器] 调用 库存服务 stock_reserve → 临时错误(1/3)
      312.7 ms  [编排器] 调用 库存服务 stock_reserve → 临时错误(2/3)
      617.7 ms  [编排器] 调用 库存服务 stock_reserve → 临时错误(3/3)
      618.5 ms  [编排器] 重试用完,订单 → CANCELLED
  协同式(库存服务的消费者暂停 2 秒,消息留在队列里):订单状态变化 0.00s → PENDING,  2.06s → CONFIRMED
    协同式里没有谁“知道”这单卡住了:订单一直 PENDING,直到库存服务回来、消息被处理;编排式里编排器在第一次失败时就知道,并按自己的重试 / 补偿策略做决定

编排器知道失败,按重试与补偿策略决定;协同式靠消息队列缓冲,服务回来后自动完成,但没有谁知道这单卡住了。

查状态

实测 · 本机执行输出
  协同式:要分别去 3 个服务的库里查: {'订单服务': 'CONFIRMED', '库存服务': 'RESERVED', '支付服务': 'CHARGED'}
  编排式:编排器如果把每个 saga 的当前步骤存在自己的表里,查 1 个地方就够(本篇的编排器没有持久化步骤,只在内存里;持久化与观察放到第 30 篇)

协同式要拼三个服务的状态;编排式可以把当前步骤放在自己的表里(本篇没有持久化,留到第 30 篇)。

延迟

实测 · 本机执行输出
  写法            | 中位数    | p99   
  --------------+--------+-------
  编排式           | 0.5 ms | 1.0 ms
  协同式(RabbitMQ) | 1.4 ms | 3.0 ms
  协同式慢在每一跳都是“发布 → 经 broker → 被消费”,同时它不要求调用方在线,下游挂了消息还在

协同式每一跳多一次 broker 往返,中位数 0.5 ms 对 1.4 ms,换来调用方不必在线。

加一步的修改成本

实测 · 本机执行输出
  编排式:流程定义是一张步骤表。改动 1 行,全部在编排器里,库存、支付服务的代码不动:
     +积分 points_grant points_release
  协同式:已有的 库存服务要改它订阅的事件:-payment.failed;+points.released
  协同式:已有的 支付服务要改它订阅的事件:-stock.reserved;+points.granted
  协同式:新增 积分服务 订阅 ['stock.reserved', 'payment.failed'];已有服务里有 2 个必须改订阅(库存的补偿触发源、支付的触发源);订单服务不变
  四个服务的协同式,支付失败 → CANCELLED,事件链:
        0.3 ms  [订单服务] 发出 order.created
        7.6 ms  [库存服务] 发出 stock.reserved
       14.6 ms  [积分服务] 发出 points.granted
       22.1 ms  [支付服务] 发出 payment.failed
       22.7 ms  [积分服务] 发出 points.released
       23.3 ms  [库存服务] 发出 stock.released

编排式只改步骤表;协同式里已有服务通过事件名耦合,2 个已有服务要改订阅。

分步演示

Saga 的两种写法
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇三个服务的库是同一个 MySQL 26.7.0 实例里的三个 schema(服务之间没有跨库事务);协同式用真实的 RabbitMQ 4.3.6(topic 交换机、每个服务一个独占队列、pika 1.4.4);“服务挂了”是让订单状态轮询期间库存服务返回临时错误(编排式)或暂停消费者(协同式);编排器与服务是进程内的函数 / 线程,不是真正分布在多台机器上
    • 没有验证:编排器自己崩溃后怎么恢复(没有持久化步骤,第 9 篇和第 30 篇再做);协同式里事件丢失与重复(第 15 篇);Seata、Camunda、Temporal 等产品的实际表现;更多服务、分支更多的流程下两种写法的复杂度差别;“加一步需要改几处”只是对我自己这份实现的计数,不代表所有框架
    京ICP备2026067502号-1京公网安备11010502063487号