--- tags: [microservice, distributed-transactions, saga, tcc, outbox, seata, idempotency, dlq] create time: 2026-05-17 15:00 --- # 分布式事务 ## 概述 每个服务拥有独立数据库,跨服务的"一次操作"实际上涉及**多个本地事务**。如何保证这些本地事务要么全部成功、要么全部回滚,就是分布式事务要解决的问题。 ```mermaid graph LR A["用户下单"] --> B["订单服务
写库"] B --> C["库存服务
扣减"] C --> D["支付服务
扣款"] style A fill:#e3f2fd style B fill:#fff3e0 style C fill:#fff3e0 style D fill:#fff3e0 ``` ## 分布式事务方案全景 | 方案 | 一致性级别 | 性能 | 复杂度 | 适用场景 | |------|-----------|------|--------|---------| | **本地事务 + MQ 事件** | 最终一致 | ⭐⭐⭐⭐⭐ | ⭐ | 绝大多数场景 | | **Saga 模式** | 最终一致 | ⭐⭐⭐ | ⭐⭐ | 长流程业务 | | **TCC** | 强最终一致 | ⭐⭐⭐ | ⭐⭐⭐⭐ | 对一致性要求较高的场景 | | **AT 模式 (Seata)** | 伪强一致 | ⭐⭐ | ⭐ | 不想改业务代码时 | > [!tip] 选择策略 > **先默认用本地事务 + 异步事件(最简单、最高效)**,只有在业务明确需要 Saga 或 TCC 时才升级。80% 的场景,本地事务 + MQ 就足够了。 ## 方案一:本地事务 + 消息队列 核心思想:**将"数据变更 + 发消息"合并到一个本地事务中。** ### Outbox 模式(推荐) ```mermaid sequenceDiagram participant App as 应用服务 participant DB as 数据库 participant Outbox as Outbox 表 participant MQ as 消息队列 participant Sub as 订阅方 App->>DB: BEGIN 事务 App->>DB: 写业务数据 App->>Outbox: 写入待发送消息 App->>DB: COMMIT loop 定时任务 MQ->>Outbox: 扫描 status='pending' Outbox-->>MQ: 返回消息列表 MQ->>Sub: 投递消息 MQ->>Outbox: 更新为 'sent' end Note right of Sub: 至少一次投递 + 消费者幂等 ``` #### Outbox 表建表示例 ```sql CREATE TABLE outbox ( id BIGSERIAL PRIMARY KEY, topic VARCHAR(255) NOT NULL, -- 消息主题 payload JSONB NOT NULL, -- 消息体 status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / sent / failed error_msg TEXT, -- 失败原因 created_at TIMESTAMP NOT NULL DEFAULT NOW(), sent_at TIMESTAMP ); -- 加速定时扫描查询 CREATE INDEX idx_outbox_pending ON outbox (status, created_at) WHERE status = 'pending'; ``` ```go // Go 示例:出事务内同时写业务数据和 outbox 记录 tx, _ := db.Begin() tx.Exec("INSERT INTO orders (user_id, total) VALUES ($1, $2)", userID, total) tx.Exec(`INSERT INTO outbox (topic, payload, status) VALUES ('order.created', $1, 'pending')`, jsonPayload) tx.Commit() // 后台 goroutine 轮询并推送 func outboxWorker(ctx context.Context, ticker *time.Ticker) { for { select { case <-ctx.Done(): return case <-ticker.C: sendPendingMessages(ctx) } } } ``` ### 消费幂等性 > [!warning] 关键保障 > 消息可能重复投递(网络超时、MQ 重投),消费者必须做到**幂等**——处理一次和处理多次的结果完全相同。 **三种常见策略:** | 策略 | 实现方式 | 适用场景 | |------|---------|---------| | **数据库唯一约束** | `msg_id` 做 `UNIQUE` | 最可靠,推荐首选 | | **Redis 去重键** | `SET dedup:{msg_id} 1 NX EX 86400` | 高吞吐场景 | | **乐观锁版本控制** | `UPDATE SET qty = qty - N WHERE version = V` | 金额调整类操作 | ```go // 推荐方案:利用 UNIQUE 约束做幂等保障 _, err := db.Exec(` INSERT INTO order_events (msg_id, order_id, action, amount) VALUES ($1, $2, $3, $4) ON CONFLICT (msg_id) DO NOTHING `, msgID, orderID, action, amount) if !isUniqueViolation(err) { log.Error("process message failed", err) return } // 执行业务逻辑——到这里说明消息是新到达的 ``` #### 事务消息 vs Outbox > [!question] 思考一下 > Outbox 模式有一个天然缺点:定时轮询有延迟(通常秒级)。如果你需要**毫秒级的延迟敏感型通知**(如支付结果实时推送到用户端),该怎么办? 如果使用的是 RocketMQ,可以绕过 Outbox 模式直接用事务消息: ```go // 发送事务消息 txMsg := rocketmq.NewTransactionMessage("order-created", payload) localTx := &MyLocalTxChecker{} // half 消息发送 → 本地事务执行 → 提交/回查 res, _ := producer.SendMessageInTransaction(txMsg, localTx) ``` RocketMQ 事务消息的完整交互流程: ```mermaid sequenceDiagram participant Producer as 生产者 participant Broker as MQ Broker participant DB as 业务数据库 participant Consumer as 消费者 Producer->>Broker: ① 发送 Half Message (消费者不可见) Broker-->>Producer: ACK Producer->>DB: ② 执行本地事务 alt 本地事务成功 Producer->>Broker: ③a Commit (消息对消费者可见) else 本地事务失败 Producer->>Broker: ③b Rollback (消息丢弃) end Note over Producer,Broker: ④ 如果步骤 2 超时未回复 Broker->>Producer: ④ 回查请求 (CheckLocalTx) Producer->>DB: 查询本地事务状态 DB-->>Producer: 返回 COMMIT / ROLLBACK Producer->>Broker: 回查结果 ``` > [!important] 回查机制的意义 > 当生产者执行本地事务过程中发生崩溃或网络中断,Broker 收不到 Commit/Rollback 指令,就会通过回查主动询问生产者。这确保了 **half 消息最终一定会收敛到可见或被丢弃**,不会出现悬空状态。 **可靠性要点清单:** - ✅ 业务数据库和 Outbox 必须在**同一个本地事务**中写入 - ✅ 消费者必须有幂等保护(否则至少一次投递 = 无限次重复) - ✅ 定时扫描任务需要设置最大重试次数,超过后进入人工处理 - ✅ 生产环境建议配置 **DLQ(死信队列)**,将连续消费失败的消息归档 ## 方案二:Saga 模式 Saga 适用于**跨多个服务的长流程操作**,将大事务拆成一系列本地小事务,每个步骤都有对应的补偿操作。 ### 编排式 vs 编舞式 ```mermaid graph TB subgraph ORCHESTRATION["编排式 — Coordinator 中心化"] CO[Coordinator] --> S1[OrderSvc: Create] CO --> S2[InventorySvc: Reserve] CO --> S3[PaymentSvc: Charge] S1 -.->|失败→Cancel| CO S2 -.->|失败→Cancel| CO S3 -.->|失败→Cancel| CO end subgraph CHOREOGRAPHY["编舞式 — 事件驱动"] E1[OrderCreated] --> S11[OrderService] S11 --> E2[StockReserved] E2 --> S12[InventoryService] S12 --> E3[PaymentCharged] E3 --> S13[PaymentService] S13 -.-> E4[PaidFailed] -.-> S11 end ``` | 维度 | 编排式 | 编舞式 | |------|--------|--------| | **控制流** | 中心化 Coordinator | 各服务通过事件自发响应 | | **可观测性** | ✅ 集中管理全流程 | ❌ 流程散布在各服务 | | **耦合度** | 依赖 Coordinator | 服务间仅感知事件 | | **适合规模** | 5~15 步的 Saga | 简单链路 (< 5 步) | ### Saga 补偿设计原则 每个正向操作必须有对应的**反向补偿**: | 正向操作 | 补偿操作 | |---------|---------| | 创建订单 | 取消订单 | | 预留库存 | 释放库存 | | 扣款 | 退款 | | 发送通知 | 撤销通知(一般不需要) | > [!warning] 补偿操作的幂等性 > 补偿操作也必须幂等——CancelOrder 可能被触发多次。用订单状态的流转来保证(如只有 PENDING 才能转到 CANCELLED)。 ### 编排式实现示例 (Go) 编排式的核心是一个 **Saga Coordinator**,它维护着当前步骤的执行状态和已完成的正向/反向操作列表: ```go // Step 定义一个 Saga 步骤 type Step struct { Name string Execute func(ctx context.Context) error // 正向操作 Compensation func(ctx context.Context) error // 补偿操作 } // Orchestrator 编排器 type Orchestrator struct { steps []Step executed []string // 已完成步骤名,用于补偿时逆向遍历 } func (o *Orchestrator) AddStep(name string, exec, compens func(context.Context) error) { o.steps = append(o.steps, Step{Name: name, Execute: exec, Compensation: compens}) } // Execute 按顺序执行所有步骤,任一步骤失败则逆向补偿 func (o *Orchestrator) Execute(ctx context.Context) error { for i, step := range o.steps { if err := step.Execute(ctx); err != nil { log.Error("step failed", z.String("step", step.Name), z.Err(err)) // 逆向补偿:从后往前执行已完步骤的补偿操作 o.rollback(ctx, i-1) return fmt.Errorf("saga failed at %s: %w", step.Name, err) } o.executed = append(o.executed, step.Name) } return nil } func (o *Orchestrator) rollback(ctx context.Context, upTo int) { for i := len(o.executed) - 1; i >= 0 && i <= upTo; i-- { // 找到对应步骤的补偿函数并执行 step := o.steps[i] if err := step.Compensation(ctx); err != nil { log.Warn("compensation also failed — need manual intervention", z.String("step", step.Name)) // 补偿失败必须告警!人工介入处理 } } } ``` 使用方式——构建一个下单 Saga: ```go saga := &Orchestrator{} saga.AddStep("create_order", func(ctx context.Context) error { return orderSvc.Create(ctx, req) }, // 正向 func(ctx context.Context) error { return orderSvc.Cancel(ctx, orderID) }, // 反向 ) saga.AddStep("reserve_stock", func(ctx context.Context) error { return stockSvc.Reserve(ctx, productIDs) }, func(ctx context.Context) error { return stockSvc.Release(ctx, productIDs) }, ) saga.AddStep("charge_payment", func(ctx context.Context) error { return paySvc.Charge(ctx, amount) }, func(ctx context.Context) error { return paySvc.Refund(ctx, orderID) }, ) err := saga.Execute(ctx) ``` > [!note] 关键理解:补偿的局限性 > > - **补偿不是撤销**:取消订单不会把商品退回到原始状态,而是创建一条反向业务记录 > - **补偿可能失败**:如果退款接口也挂了,你需要重试 + 告警 + 人工介入机制 > - **时间窗口问题**:如果正向前向到了第 5 步、补偿到了第 2 步时第 2 步也失败了,第 3~4 步的正向操作已经发生且无法撤销 ## 方案三:TCC (Try-Confirm-Cancel) TCC 在每个事务步骤中实现三个接口: ``` Try: 预留资源(冻结余额 / 锁定库存) Confirm: 确认使用资源(正式扣减 / 正式锁定) Cancel: 释放资源(解冻余额 / 解锁库存) ``` ### TCC 时序图 ```mermaid sequenceDiagram participant Orch as Coordinator participant O as Order Service participant I as Inventory Service participant P as Payment Service Orch->>O: Try(CreateOrder) O-->>Orch: OK Orch->>I: Try(ReserveStock) I-->>Orch: OK Orch->>P: Try(ChargeBalance) P-->>Orch: OK Orch->>O: Confirm Orch->>I: Confirm Orch->>P: Confirm Note over Orch,P: 全部 Confirm → 事务完成 ``` ### TCC vs Saga 对比 | 维度 | TCC | Saga | |------|-----|------| | 一致性强度 | 较强(资源被占用期间不允许其他事务使用) | 较弱(中间态数据可见) | | 开发成本 | 高(每个业务方法实现 Try/Confirm/Cancel) | 低(只需正向 + 反向操作) | | 性能 | 中(需要两阶段提交) | 中高(单阶段本地事务) | | 适用场景 | 资金、库存等高敏感业务 | 订单流程、审批流等业务链 | ## 方案四:AT 模式 (Seata) > [!question] 什么时候该用 Seata? > > 如果你的团队**不愿或没有能力为每个业务方法编写 TCC 接口**,但又有跨库事务需求——Seata 的 AT 模式就是为此设计的。它是"零侵入"的最强候选方案。 Seata AT 模式的核心原理是 **全局锁 + 二阶段提交**,业务代码不需要任何改动: ``` 第一阶段(Global Lock): 1. 拦截 SQL → 解析前后镜像 2. 申请全局锁(锁住被修改的行) 3. 本地事务执行(含 undo_log 写入) 4. 提交本地事务(全局锁暂不释放) 第二阶段(Global Commit/Rollback): Commit: 异步删除 undo_log,释放全局锁 Rollback: 利用 undo_log 生成反向 SQL,回滚并释放全局锁 ``` ### AT 模式时序图 ```mermaid sequenceDiagram participant TC as Transaction Coordinator
(Seata Server) participant TM as Transaction Manager
(应用 A) participant RS1 as Resource 1
(订单 DB) participant RS2 as Resource 2
(库存 DB) TM->>TC: 开启全局事务 (xid) TM->>RS1: BEGIN; UPDATE orders SET ... Note over RS1: 自动捕获 BEFORE/AFTER 镜像
写入 undo_log TM->>RS2: BEGIN; UPDATE stock SET qty = qty - 1 Note over RS2: 同样写 undo_log TM->>RS1: COMMIT (本地事务完成) TM->>RS2: COMMIT (本地事务完成) TM->>TC: 报告二阶段提交完成 TC-->>RS1: 异步清理 undo_log TC-->>RS2: 异步清理 undo_log ``` ### AT 与 XA 的区别 很多人混淆 AT 和 XA——它们都是两阶段提交,但实现方式完全不同: | 维度 | XA 模式 | AT 模式 (Seata) | |------|---------|----------------| | **锁粒度** | 数据库级(长事务锁) | 行级(短期全局锁) | | **隔离级别** | 需要 SERIALIZABLE | 基于脏读 + 全局锁 | | **性能** | 较差(锁持有时间长) | 较好(本地事务快速提交后异步清理) | | **侵入性** | 需配置 DataSource Proxy | 零侵入,自动代理 JDBC | ### 使用注意事项 ```go // Seata Go SDK — 几乎零侵入 import "github.com/seata/seata-sdk-go-client/tx" func CreateOrder(ctx context.Context, req *Request) error { // 只需添加这一个注解 tx.GlobalTransaction() // 下面的代码完全不变——Seata 自动代理数据源 orderRepo.Create(ctx, &order) stockRepo.Decrease(ctx, productID, qty) return nil } ``` > [!warning] 生产环境避坑清单 > > 1. **undo_log 表必须建**:`seata_undo_log` 是 Seata 自动创建的,用于存储前后镜像。务必保证这个表可用(通常和业务库在同一实例) > 2. **不要混用本地事务和全局事务**:同一个方法里同时出现 `@Transactional` 和 `@GlobalTransactional` 会导致行为不可预测 > 3. **异常处理**:全局事务中抛出的异常会被 Seata 捕获并触发回滚,确保异常能正确向上传递 > 4. **性能考量**:全局锁虽然比 XA 短,但在高并发场景下仍然是瓶颈。**优先用 MQ 最终一致,实在做不到再上 AT** > 5. **只支持部分数据库**:MySQL、PostgreSQL、Oracle 支持良好;TiDB 等 NewSQL 数据库兼容性有限 > [!tip] 选型建议 > > AT 模式最适用的场景:**遗留系统微服务化改造**。当原有单体拆分成多个服务时,原有的 `@Transactional` 跨库调用突然失效,引入 AT 模式可以快速过渡——等业务稳定后再逐步迁移到 MQ 事件驱动架构。 ## 补充保障:重试与死信队列 分布式系统中,**没有哪个组件永远可靠**。消息消费失败、网络闪断、下游超时——这些都不可避免。你需要一套完整的错误处理链路: ```mermaid flowchart LR MQ["消息队列"] --> C["消费者"] C --> FAIL{消费成功} FAIL -- 是 --> OK["正常结束"] FAIL -- 否 --> RETRY{重试次数未满} RETRY -- 是 --> DELAY["延迟重试
指数退避"] DELAY --> C RETRY -- 否 --> DLQ["进入死信队列
人工介入"] DLQ --> ALERT["告警通知"] DLQ --> REPLAY["手动重放或修复后补发"] ``` ### 三种重试策略对比 | 策略 | 适用场景 | 示例 | |------|---------|------| | **立即重试** | 瞬时故障(连接池未就绪) | 等 100ms 再试 3 次 | | **延迟重试** | 需要时间窗口等待恢复 | 1s → 2s → 4s → 8s | | **定时任务补偿** | 批量漏掉的消息 | 每 5 分钟扫描 pending 表 | ### 死信队列 (DLQ) 设计 ```go // 消费者主循环:带重试和 DLQ 保护 func (c *consumer) Process(ctx context.Context, msg Message) error { for attempt := 0; attempt < c.maxRetries; attempt++ { err := c.handle(ctx, msg) if err == nil { return nil // 成功 } if isRetryable(err) { time.Sleep(time.Duration(attempt+1) * time.Second) continue // 重试 } // 非可重试错误,直接进 DLQ return c.sendToDLQ(msg, err) } // 超过最大重试次数,进 DLQ return c.sendToDLQ(msg, fmt.Errorf("exhausted %d retries", c.maxRetries)) } ``` > [!quote] 运维心态 > > "一个没有死信队列的消息消费者,就像一辆没有备胎的车——任何一次不可预期的故障都会让你抛锚在路上。" ## 如何选择分布式事务方案? > [!question]- 决策树 > > 面对跨服务的数据一致性问题,你不需要每次都重新选型。按照下面的思路逐层判断即可: ```mermaid flowchart TD START["跨服务写操作"] --> Q1{核心资金或资产} Q1 -- 是且要求强一致 --> TCC["TCC"] Q1 -- 否 --> Q2{流程步数超过5} Q2 -- 是长流程 --> SAGA["Saga"] Q2 -- 否简单链路 --> Q3{容忍秒级延迟} Q3 -- 可以 --> MQ["本地事务+MQ(Outbox)"] Q3 -- 不可以毫秒级 --> TXMSG["RocketMQ事务消息"] Q3 -- 无法接受最终一致 --> SEATA["Seata AT模式"] TCC --> END["完成选择"] SAGA --> END MQ --> END TXMSG --> END SEATA --> WARNING["过渡方案,后续应迁移至事件驱动架构"] WARNING --> END style TCC fill:#e8f5e9,stroke:#4caf50,stroke-width:2px style SAGA fill:#fff3e0,stroke:#ff9800,stroke-width:2px style MQ fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style TXMSG fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style SEATA fill:#fce4ec,stroke:#e91e63,stroke-width:2px ``` ### 决策速查矩阵 | 你的业务特征 | 推荐方案 | 理由 | |-------------|---------|------| | 日订单量百万级,通知类场景 | Outbox + MQ | 性能最好,开发成本最低 | | 支付/清算/账务核心链路 | TCC | 资源占用期间不允许其他事务修改 | | 审批流、物流状态流转(多步) | Saga 编排式 | 流程清晰,补偿机制完备 | | 已有 RocketMQ,对延迟敏感 | 事务消息 | 绕过 Outbox 轮询延迟 | | 遗留系统快速拆分 | Seata AT | 零侵入,快速过渡 | | 只需要"最终一致" | 本地事务 + MQ | 80% 的场景这就是答案 | > [!tip] 黄金法则 > > **"能用异步事件解决的,不要用两阶段提交;能本地事务 + MQ 解决的,不要上框架。"** > > 复杂度越高,出问题的概率越大。每一层抽象都引入了新的故障点——协调器挂了怎么办?补偿逻辑写错了怎么发现?回滚又失败了谁来做?保持简单是最好的工程纪律。 ## 关联笔记 - [[03-数据一致性/01-数据库拆分]] — 数据库拆分是分布式事务的前提 - [[02-服务治理/06-容错模式]] — 熔断器和重试在分布式事务中的作用 - [[02-服务治理/05-服务间通信]] — 消息投递的一致性保障