
分布式事务深度分析:Saga + Outbox
文档定位:讲清「跨服务一致性」里最常用的组合拳——Saga(编排/补偿)+ Outbox(可靠投递)。
刻意剥离:不绑定理财 / 资产 / 订单等具体域名;用抽象角色(发起方、参与方、资源方)说明。
阅读目标:知道各自解决什么问题、为何常一起用、失败怎么收、何时不该用。
目录
- 问题本质:分布式下没有免费的 ACID
- 先分清两层问题
- Saga:业务级长事务与补偿
- Outbox:本地提交与对外通知的原子性
- 组合拳:Saga + Outbox 如何配合
- 业务中真实出现的一致性场景(抽象)
- 失败模型与恢复策略
- 落地骨架(表 / 状态机 / Worker)
- 与其它分布式方案对照
- 选型决策树与反模式
- 设计检查清单
1. 问题本质:分布式下没有免费的 ACID
单体时代,一笔业务多表变更可以包在同一个数据库事务里:
BEGIN
写业务单
改库存/额度
记流水
COMMIT ← 全成或全败拆成微服务后,每个服务只拥有自己的库。跨服务调用无法共享同一个 DB 事务。于是出现经典半成功:
| 现象 | 含义 |
|---|---|
| A 已提交,调 B 超时 | 不知道 B 有没有做成 |
| A、B 都成功,发 MQ 失败 | 下游永远收不到事件 |
| 补偿做到一半进程挂了 | 留下「中间态」 |
分布式事务在工程上通常不是「恢复 XA」,而是回答三件事:
- 前进:多步怎么按序做完?
- 后退:某步失败,已成功的步骤怎么撤销(或冲正)?
- 通知:本地真相变更后,怎么保证外部一定能感知?
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 = COMPENSATED3.4 补偿设计要点(深度)
- 补偿必须幂等
网络重试会导致补偿被调用多次;Compensate(x)第 2 次应返回成功且不产生副作用。 补偿不是「物理回滚」
很多步骤不可撤销(已发短信、已对外付款)。常见做法是:- 冲正:记一笔反向业务(金额、状态、审计齐全)
- 标记作废:原单
VOIDED,不再参与后续计算 - 人工工单:技术无法自动撤销时升级
- 空补偿 / 跳过补偿
若某步「只读校验」或「尚未产生外部副作用」,失败时无需补偿,避免假补偿逻辑。 - 中间态必须一等公民
状态机至少区分:PENDING→RUNNING→SUCCEEDED|COMPENSATING→COMPENSATED|FAILED。
「处理中」对用户可见时,要有超时与恢复 Job,否则会永久卡死。 隔离与并发
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 模式
在同一本地事务内:
- 写业务表(真相)
- 写
outbox行(待投递事件) 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两种推进方式(可混用):
- 同步推进:协调器 RPC 调参与方;仅把「领域事件」经 Outbox 发给旁路(通知、审计、读模型)。
- 异步推进:协调器每完成一步只写状态 + 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_MANUAL5.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 Relay | outbox.status=PENDING | 投递到 Broker |
| Saga Recover | saga 超时未终态 | 反查参与方、续跑或补偿 |
| 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 NULL8.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_MANUAL8.5 可观测性必打点
| 指标 | 用途 |
|---|---|
saga_running_age_seconds | 卡住发现 |
outbox_pending_age_seconds | 投递老化 |
saga_compensate_total | 补偿频率异常 |
outbox_publish_fail_total | Broker/权限问题 |
日志字段 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 当分布式事务;超时盲补偿 │
└─────────────────────────────────────────────────────────┘