其他

分布式事务深度分析:Saga + Outbox

By karp 102 Views 43 MIN READ 0 Comments

2026-07-21T05:58:44.png

分布式事务深度分析:Saga + Outbox

文档定位:讲清「跨服务一致性」里最常用的组合拳——Saga(编排/补偿)+ Outbox(可靠投递)
刻意剥离:不绑定理财 / 资产 / 订单等具体域名;用抽象角色(发起方、参与方、资源方)说明。
阅读目标:知道各自解决什么问题、为何常一起用、失败怎么收、何时不该用。

目录

  1. 问题本质:分布式下没有免费的 ACID
  2. 先分清两层问题
  3. Saga:业务级长事务与补偿
  4. Outbox:本地提交与对外通知的原子性
  5. 组合拳:Saga + Outbox 如何配合
  6. 业务中真实出现的一致性场景(抽象)
  7. 失败模型与恢复策略
  8. 落地骨架(表 / 状态机 / Worker)
  9. 与其它分布式方案对照
  10. 选型决策树与反模式
  11. 设计检查清单

1. 问题本质:分布式下没有免费的 ACID

单体时代,一笔业务多表变更可以包在同一个数据库事务里:

BEGIN
  写业务单
  改库存/额度
  记流水
COMMIT   ← 全成或全败

拆成微服务后,每个服务只拥有自己的库。跨服务调用无法共享同一个 DB 事务。于是出现经典半成功:

现象含义
A 已提交,调 B 超时不知道 B 有没有做成
A、B 都成功,发 MQ 失败下游永远收不到事件
补偿做到一半进程挂了留下「中间态」

分布式事务在工程上通常不是「恢复 XA」,而是回答三件事:

  1. 前进:多步怎么按序做完?
  2. 后退:某步失败,已成功的步骤怎么撤销(或冲正)?
  3. 通知:本地真相变更后,怎么保证外部一定能感知?

Saga 主要覆盖 1+2;Outbox 主要覆盖 3。二者正交,经常叠加。


2. 先分清两层问题

flowchart TB subgraph L1["业务一致性层"] S["Saga / TCC / 同步收口 + 反查"] end subgraph L2["可靠通信层"] O["Outbox / 本地消息表 / 事务消息"] end subgraph L3["最终兜底层"] R["对账 / 冲正工单 / 人工介入"] end L1 --> L2 L2 --> L3
层级典型手段解决的问题不解决的问题
业务一致性Saga、TCC、同步原子服务多参与方「整体成功或可接受终态」事件是否发出
可靠通信Outbox、Inbox、事务消息「本地已提交 ⇒ 消息最终可达」对方业务是否成功
最终兜底对账、红冲、告警漏网差异被发现并抹平主路径实时正确

常见误解:以为上了 Outbox 就不需要补偿。
事实:Outbox 只保证「消息发出」;消息消费失败、下游拒绝,仍要 Saga/冲正/重试。


3. Saga:业务级长事务与补偿

3.1 定义

Saga = 一组本地事务按序执行;任一步失败,按逆序(或预定义策略)执行补偿事务,使系统进入可接受的终态(成功完成或明确失败,而非永久悬挂)。

注意:Saga 追求的是 业务语义上的一致性(最终一致),不是数据库级隔离级别下的全局原子性。执行过程中其它读者可能看到中间态——这是设计时必须接受并产品化处理的点(例如展示「处理中」)。

3.2 两种形态

编排式 Orchestration协作式 Choreography
谁推进中心协调器(服务 / 状态机 / Workflow 引擎)各服务听事件自行推进
优点补偿顺序清晰、易观测、易超时治理无单点协调器、解耦
缺点协调器成为关键路径链路难追、补偿顺序难控、易环依赖
推荐资金/账务类优先编排通知、积分、弱一致旁路可用协作

3.3 抽象时序(编排式)

sequenceDiagram participant C as Coordinator participant A as Participant_A participant B as Participant_B participant D as Participant_D C->>A: Step1 Do A-->>C: OK C->>B: Step2 Do B-->>C: OK C->>D: Step3 Do D-->>C: FAIL Note over C: 进入补偿分支 C->>B: Compensate Step2 B-->>C: OK C->>A: Compensate Step1 A-->>C: OK C->>C: saga_status = COMPENSATED

3.4 补偿设计要点(深度)

  1. 补偿必须幂等
    网络重试会导致补偿被调用多次;Compensate(x) 第 2 次应返回成功且不产生副作用。
  2. 补偿不是「物理回滚」
    很多步骤不可撤销(已发短信、已对外付款)。常见做法是:

    • 冲正:记一笔反向业务(金额、状态、审计齐全)
    • 标记作废:原单 VOIDED,不再参与后续计算
    • 人工工单:技术无法自动撤销时升级
  3. 空补偿 / 跳过补偿
    若某步「只读校验」或「尚未产生外部副作用」,失败时无需补偿,避免假补偿逻辑。
  4. 中间态必须一等公民
    状态机至少区分:PENDINGRUNNINGSUCCEEDED | COMPENSATINGCOMPENSATED | FAILED
    「处理中」对用户可见时,要有超时与恢复 Job,否则会永久卡死。
  5. 隔离与并发
    Saga 执行期间,其它请求可能读到中间数据。手段:

    • 资源预留(接近 TCC 的 Try)
    • 业务锁(按主体维度串行)
    • 语义接受最终一致(展示延迟)

3.5 Saga 与「同步收口」的关系

若跨服务变更能收口到一个具备本地事务的原子服务(一次 RPC 内完成多账户变更),则可大幅缩短 Saga:

短 Saga:本地落单 → 调原子服务 → 本地终态
长 Saga:本地落单 → 调服务A → 调服务B → 调服务C → …

短 Saga 仍需要:超时反查、幂等键、卡住恢复——只是步骤更少。


4. Outbox:本地提交与对外通知的原子性

4.1 要解决的经典坑

错误写法:
  BEGIN; 写业务表; COMMIT;
  publish(Kafka);          ← 进程在此崩溃 ⇒ 事件丢失

或:
  publish(Kafka);
  BEGIN; 写业务表; COMMIT; ← 消息已发但本地回滚 ⇒ 幽灵事件

4.2 Outbox 模式

同一本地事务内:

  1. 写业务表(真相)
  2. outbox 行(待投递事件)
  3. COMMIT

另有 Relay / Worker(轮询或 CDC)把 PENDING 事件投递到 MQ,成功后标记 SENT(或删除)。

sequenceDiagram participant App as App participant DB as Local_DB participant Relay as Outbox_Relay participant MQ as Message_Broker participant Cons as Consumer rect rgba(251, 191, 36, 0.12) Note over App,DB: 本地事务 App->>DB: BEGIN App->>DB: INSERT business_row App->>DB: INSERT outbox PENDING App->>DB: COMMIT end Relay->>DB: SELECT PENDING FOR UPDATE SKIP LOCKED Relay->>MQ: Publish event MQ-->>Relay: ACK Relay->>DB: UPDATE outbox SENT Cons->>MQ: Consume Cons->>Cons: 幂等处理 + Inbox 可选

4.3 Outbox 不保证什么

Outbox 保证Outbox 不保证
本地提交后事件最终会被投递(at-least-once)恰好一次(exactly-once)语义
投递顺序在单聚合根内可设计保证全局全序
与业务行同生共死消费者一定处理成功

因此消费端必须:

  • 幂等(业务唯一键 / Inbox 去重表)
  • 可重试
  • 失败进 DLQ + 告警

4.4 实现变体

变体做法取舍
轮询 Outbox 表Worker SKIP LOCKED 拉取实现简单;有投递延迟
CDC(如 Debezium)听 WAL 变 outbox 行延迟低;运维复杂
事务消息(部分 MQ)半消息 + 回查少自建表;绑定 MQ 能力
Inbox消费前先落库去重防重复消费;多一张表

4.5 与「本地消息表」的关系

「本地消息表」与 Outbox 同构:都是「业务事务内写待发消息」。业界常把二者当同一模式的不同叫法;差别多在投递实现细节。


5. 组合拳:Saga + Outbox 如何配合

5.1 分工一句话

组件一句话
Saga管「多步业务做完或退干净」
Outbox管「某步本地成功后,下一步/旁路一定能被驱动」

5.2 典型组合拓扑

flowchart LR subgraph Coord["协调服务"] SM["Saga 状态机"] OB["outbox 表"] SM --> OB end OB -->|Relay| MQ["Broker"] MQ --> W1["Worker: 调参与方 A"] MQ --> W2["Worker: 调参与方 B"] W1 -->|结果回调/事件| SM W2 -->|结果回调/事件| SM

两种推进方式(可混用):

  1. 同步推进:协调器 RPC 调参与方;仅把「领域事件」经 Outbox 发给旁路(通知、审计、读模型)。
  2. 异步推进:协调器每完成一步只写状态 + Outbox;Worker 消费后调下一参与方,再回写 Saga 状态。

资金敏感路径更常见 同步推进 + Outbox 发领域事件;高吞吐批处理更常见 异步推进

5.3 组合后的端到端语义

用户请求
  → 协调器开启 Saga(本地事务:写 saga_instance + 业务草稿)
  → 执行 Step N(参与方本地事务)
  → 成功则:更新 saga 进度 + Outbox「StepN_DONE」(同事务)
  → Relay 投递 → 触发 Step N+1 或通知下游
  → 若 Step 失败:进入补偿链(每步补偿同样建议可 Outbox 驱动或同步调用)
  → 终态:SUCCEEDED / COMPENSATED / FAILED_NEED_MANUAL

5.4 为何「只 Saga 不够」

没有 Outbox 时,协调器常见写法:

更新 saga 状态为 STEP2_READY
publish("do_step2")   ← 崩溃则 Step2 永不触发,Saga 永久卡住

有 Outbox:状态与「待执行下一步」同事务落库,崩溃后由 Relay/恢复 Job 续跑。

5.5 为何「只 Outbox 不够」

Outbox 保证消息发出,但:

  • 参与方执行失败需要补偿编排
  • 多步依赖顺序与超时策略需要状态机
  • 半成功需要反查与冲正

这些都不是 Outbox 的职责。


6. 业务中真实出现的一致性场景(抽象)

下列场景刻意去品牌化,对应各域「跨服务改账/改单」的共性。

场景 A:双写资源(服务内缓存 + DB)

  • 问题:先改缓存成功、写库失败 → 缓存脏。
  • 常用解:同请求内顺序约束 + 失败回滚缓存;或 DB 为真相、缓存可重建;对账 Job 兜底。
  • Saga/Outbox 角色:通常不需要跨服务 Saga;Outbox 可用于「缓存失效事件」。

场景 B:本域落单 + 调外部账务服务

本域:创建业务单 PROCESSING
外部:扣减 / 冻结 / 入账
本域:业务单 SUCCESS + 写流水
故障点处理
外部明确失败本域标 FAILED;若已占本地资源则补偿释放
外部超时未知禁止盲补偿;用幂等键反查外部状态,再补做或补偿
外部成功、本域崩溃恢复 Job:反查成功 → 补完成本域终态;写 Outbox 通知

这是 短 Saga + 幂等反查 +(可选)Outbox 的高频形态。

场景 C:多参与方链式变更

校验 → 预留资源 → 账务变更 → 写业务终态 → 通知

任一步失败需逆序释放/冲正 → 编排 Saga
每步成功对外广播 → Outbox

场景 D:批处理(计费 / 结算 / 派发)

生成待处理行(幂等唯一键)
  → Outbox / 队列异步执行外部入账
  → 回写成功凭证
  → 失败重试 / DLQ / 次日补偿

批处理更依赖:幂等键 + Outbox/队列 + 对账;Saga 可简化为「单行状态机」。

场景 E:已对外生效后的纠错

技术补偿窗口已过(用户已消费、报表已出)→ 业务冲正单,而不是回头跑 Saga。
冲正本身仍建议:本地冲正单 + Outbox 事件 + 外部 Reverse 接口(幂等)。


7. 失败模型与恢复策略

7.1 故障分类

类型例子策略
确定性业务失败余额不足、状态非法立即补偿或拒绝;不要无限重试
瞬时基础设施失败超时、连接重置有限次重试 + 退避
未知结果请求已发出,响应丢失反查,禁止假设失败就补偿
进程崩溃停在任意步骤恢复 Job 按状态机续跑
投递失败Outbox 长期 PENDING老化告警 + 重试 + 人工
消费失败下游持续报错DLQ + 熔断 + 工单

7.2 「未知结果」是资金类最高危点

flowchart TD T["RPC 超时 / 连接断开"] --> Q{"能否用业务幂等键反查?"} Q -->|能| S["GetStatus / 查原单"] S --> A{"外部状态"} A -->|SUCCESS| Fix["补齐本地终态 · 发 Outbox"] A -->|FAILED / NOT_FOUND| Comp["走补偿或安全重放"] A -->|PROCESSING| Wait["退避再查 · 超时告警"] Q -->|不能| Manual["禁止自动乱补偿 · 告警人工"]

铁律:对「可能已成功」的外部写操作,补偿前必须先反查;否则可能造成重复入账或重复扣款

7.3 恢复 Job 的职责

与 Outbox Relay 互补:

组件扫什么做什么
Outbox Relayoutbox.status=PENDING投递到 Broker
Saga Recoversaga 超时未终态反查参与方、续跑或补偿
Reconcile双边流水/余额差异告警、自动抹平或工单

8. 落地骨架(表 / 状态机 / Worker)

以下为领域无关的最小骨架,落地时改 schema 名即可。命名对齐团队惯例:uid_at TIMESTAMPTZ(6)、软删 deleted_at、金额带业务前缀。

8.1 Saga 实例表(示意)

-- [skill: go-team-standards · 数据库设计] saga 实例表示意(非某域正式 DDL)
CREATE TABLE demo.saga_instances (
    id              BIGINT PRIMARY KEY,
    saga_type       VARCHAR(64)  NOT NULL,  -- 业务编排类型
    biz_key         VARCHAR(128) NOT NULL,  -- 业务幂等键
    current_step    VARCHAR(64)  NOT NULL,
    status          SMALLINT     NOT NULL,  -- 1运行 2成功 3补偿中 4已补偿 5失败待人工
    payload_json    JSONB        NOT NULL,
    created_at      TIMESTAMPTZ(6) NOT NULL,
    updated_at      TIMESTAMPTZ(6) NOT NULL,
    deleted_at      TIMESTAMPTZ(6)
);
-- UNIQUE (saga_type, biz_key) WHERE deleted_at IS NULL

8.2 Outbox 表(示意)

CREATE TABLE demo.outbox (
    id              BIGINT PRIMARY KEY,
    aggregate_type  VARCHAR(64)  NOT NULL,
    aggregate_id    VARCHAR(64)  NOT NULL,
    event_type      VARCHAR(64)  NOT NULL,
    payload_json    JSONB        NOT NULL,
    status          SMALLINT     NOT NULL,  -- 1待投递 2已投递 3投递失败
    retry_count     INT          NOT NULL DEFAULT 0,
    next_retry_at   TIMESTAMPTZ(6),
    created_at      TIMESTAMPTZ(6) NOT NULL,
    sent_at         TIMESTAMPTZ(6),
    deleted_at      TIMESTAMPTZ(6)
);

8.3 同事务写入伪代码

// [skill: go-team-standards · 技术方案] 本地事务内业务行 + outbox
err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&bizRow).Error; err != nil {
        return fmt.Errorf("insert biz: %w", err)
    }
    if err := tx.Create(&outboxRow).Error; err != nil {
        return fmt.Errorf("insert outbox: %w", err)
    }
    return nil
})

8.4 状态机最小集合

NEW
  → RUNNING
      → SUCCEEDED
      → COMPENSATING → COMPENSATED
      → FAILED_RETRYABLE  →(恢复 Job)回到 RUNNING 或 COMPENSATING
      → FAILED_TERMINAL / NEED_MANUAL

8.5 可观测性必打点

指标用途
saga_running_age_seconds卡住发现
outbox_pending_age_seconds投递老化
saga_compensate_total补偿频率异常
outbox_publish_fail_totalBroker/权限问题
日志字段 trace_id / saga_id / biz_key串联排查

9. 与其它分布式方案对照

方案一致性强度锁/阻塞业务改造典型用途
本地事务单服务单库
2PC / XA极少;同资源管理器
TCC较强(预约)中(预留)高(三接口)强预留场景
Saga最终 + 补偿中(补偿逻辑)跨服务长流程
Outbox通信可靠事件必达
事务消息通信可靠绑定特定 MQ
同步原子服务 + 反查边界内强资金收口
对账 + 冲正最终低~中一切方案的兜底

Saga + Outbox 的定位:在放弃全局 2PC 的前提下,用可补偿的业务流程 + 可靠的状态推进/通知,换取可扩展的微服务架构。


10. 选型决策树与反模式

10.1 决策树

变更是否只在一个服务的一个库?
  ├─ 是 → 本地事务(不要 Saga)
  └─ 否
       能否收口到一个原子写服务?
         ├─ 是 → 同步调用 + 幂等 + 超时反查(短链路);旁路事件用 Outbox
         └─ 否
              参与方是否支持「预留 → 确认/取消」?
                ├─ 是 → 优先 TCC
                └─ 否 → 编排 Saga + 补偿
                     每步成功是否需要驱动下一步或通知外部?
                       └─ 是 → 叠加 Outbox(或等价可靠投递)
务必保留:对账 / 冲正 / 人工终态

10.2 反模式

反模式为何有害正确做法
先发 MQ 再写库幽灵消息Outbox:先同事务落库
超时即补偿可能重复冲正已成功外部操作先反查
补偿不可幂等重试放大资金事故补偿与正向共用幂等键
无中间态 / 无恢复 Job永久卡单状态机 + Recover
用 Outbox 替代业务补偿消息到了业务仍可能失败分层设计
协作式 Saga 做资金主路径难追责、难补偿排序资金用编排
忽略对账静默资金漂移定期双边核对

11. 设计检查清单

写某域技术方案涉及跨服务写时,逐项打勾:

Saga

  • [ ] 步骤列表与成功条件写清
  • [ ] 每步补偿动作写清(含「无需补偿」)
  • [ ] 补偿与正向均幂等
  • [ ] 超时 / 未知结果走反查,不盲补偿
  • [ ] 状态机含中间态与人工终态
  • [ ] 有 Recover Job 与告警阈值

Outbox

  • [ ] 业务行与 outbox 同行同事务提交
  • [ ] Relay 使用 SKIP LOCKED 或等价租约,防多实例抢同一行
  • [ ] at-least-once + 消费端幂等
  • [ ] PENDING 老化告警、失败重试上限、DLQ
  • [ ] payload 含足够溯源字段(biz_key / trace_id

兜底

  • [ ] 对账口径与差异阈值
  • [ ] 冲正 / 人工路径
  • [ ] 禁止用 float 金额;时间 UTC _at

附录 A:概念速查

术语含义
本地事务单库 ACID 事务
补偿抵消已成功步骤副作用的业务操作
冲正记账意义上的反向单据(常用于不可物理回滚)
幂等同一业务键执行任意次,效果与一次相同
at-least-once至少投递一次,允许重复
Inbox消费端去重落库
DLQ死信队列,承接反复失败消息

附录 B:一页纸结论

┌─────────────────────────────────────────────────────────┐
│  Saga     = 多步业务的前进 + 失败补偿(业务一致性)      │
│  Outbox   = 本地提交与对外事件的绑定(通信可靠性)      │
│  组合     = 状态机推进不丢、旁路通知不丢                │
│  仍需要   = 幂等 · 反查 · 恢复 Job · 对账 · 冲正        │
│  不要用   = 把 Outbox 当分布式事务;超时盲补偿          │
└─────────────────────────────────────────────────────────┘

本文由 karp 原创

采用 CC BY-NC-SA 4.0 协议进行许可

转载请注明出处:https://ikarp.top/index.php/archives/863.html

标签: 无标签

相关推荐

  • 暂无相关推荐,看看别的吧。

0 评论