Files
cs-note/hhs/MS/03-数据一致性/02-分布式事务.md
T
2026-05-24 11:42:38 +08:00

549 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [microservice, distributed-transactions, saga, tcc, outbox, seata, idempotency, dlq]
create time: 2026-05-17 15:00
---
# 分布式事务
## 概述
每个服务拥有独立数据库,跨服务的"一次操作"实际上涉及**多个本地事务**。如何保证这些本地事务要么全部成功、要么全部回滚,就是分布式事务要解决的问题。
```mermaid
graph LR
A["用户下单"] --> B["订单服务<br/>写库"]
B --> C["库存服务<br/>扣减"]
C --> D["支付服务<br/>扣款"]
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<br/>(Seata Server)
participant TM as Transaction Manager<br/>(应用 A)
participant RS1 as Resource 1<br/>(订单 DB)
participant RS2 as Resource 2<br/>(库存 DB)
TM->>TC: 开启全局事务 (xid)
TM->>RS1: BEGIN; UPDATE orders SET ...
Note over RS1: 自动捕获 BEFORE/AFTER 镜像<br/>写入 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["延迟重试<br/>指数退避"]
DELAY --> C
RETRY -- 否 --> DLQ["进入死信队列<br/>人工介入"]
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-服务间通信]] — 消息投递的一致性保障