218 lines
7.7 KiB
Markdown
218 lines
7.7 KiB
Markdown
---
|
||
tags: [microservice, distributed-system, database, data-consistency, saga-pattern, tcc, idempotency, outbox-pattern]
|
||
create time: 2026-04-29 12:03
|
||
---
|
||
|
||
# 数据一致性
|
||
|
||
## 概述
|
||
|
||
微服务的核心设计原则是 **"每个服务拥有独立数据库"**,这带来了分布式事务和数据一致性的经典难题。本文梳理主要解决方案及其取舍。
|
||
|
||
## 为什么不能共享数据库
|
||
|
||
```mermaid
|
||
graph LR
|
||
S1[订单服务 DB]
|
||
S2[库存服务 DB]
|
||
|
||
subgraph BAD["反模式:共享数据库"]
|
||
S1 --- SHARED[(共享 DB)]
|
||
S2 --- SHARED
|
||
end
|
||
```
|
||
|
||
> [!failure] 反模式警告
|
||
> 如果两个服务连接同一个数据库的同一张表,它们就不再是独立的微服务——你得到的是**分布式单体**。服务可以随意互相查询彼此的数据,失去了边界和自治性。
|
||
|
||
## 分布式事务方案概览
|
||
|
||
| 方案 | 一致性级别 | 性能 | 复杂度 | 适用场景 |
|
||
|------|-----------|------|--------|---------|
|
||
| 本地事务 + 事件通知 | 最终一致 | 高 | 低 | 绝大多数场景 |
|
||
| Saga 模式 | 最终一致 | 中 | 中 | 长流程业务 |
|
||
| TCC | 强最终一致 | 中 | 高 | 对一致性要求较高的场景 |
|
||
| AT 模式 (Seata) | 伪强一致 | 中低 | 低 | 不想改业务代码时 |
|
||
|
||
> [!info] 选择策略
|
||
> 先默认用 **本地事务 + 异步事件**(最简单、最高效),只有在业务明确需要 Saga 或 TCC 时才升级。
|
||
|
||
## 方案一:本地事务 + 消息队列
|
||
|
||
这是**最常用**的方案,核心思想是将"数据变更 + 发消息"合并到一个本地事务中。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant O as 订单服务
|
||
participant MQ as 消息队列
|
||
participant I as 库存服务
|
||
|
||
U->>O: 创建订单
|
||
O->>O: BEGIN 事务
|
||
O->>O: 插入订单记录
|
||
O->>MQ: 发送订单创建消息
|
||
O->>O: COMMIT 事务
|
||
MQ-->>I: 投递消息
|
||
I->>I: 扣减库存
|
||
I-->>O: 返回结果(可选)
|
||
```
|
||
|
||
### 实战:电商订单创建流程
|
||
|
||
以「用户下单」为例,这个流程涉及 **订单服务**(创建订单)和 **库存服务**(扣减库存),是典型的跨服务数据一致性问题:
|
||
|
||
| 步骤 | 动作 | 一致性保障点 |
|
||
|------|------|-------------|
|
||
| 1 | 用户点击"提交订单" | 前端防重复提交(按钮置灰 / Token 机制) |
|
||
| 2 | 订单服务写入订单表 + 发送 `order.created` 消息 | **事务边界**:两件事必须在同一个本地事务中完成 |
|
||
| 3 | 消息队列投递给库存服务 | 保证消息至少一次投递(At-Least-Once) |
|
||
| 4 | 库存服务扣减库存 | **幂等性**:防止消息重投导致库存被扣两次 |
|
||
| 5 | 库存不足时回滚订单 | 通过 Sagas 补偿:已创建的订单标记为"取消" |
|
||
|
||
> [!question] 思考
|
||
> 如果步骤 2 的数据库写成功了,但 MQ 发消息失败了,会发生什么?用户看到下单成功但实际上库存没被锁定。这说明为什么必须用事务消息或本地消息表,而不是直接调 MQ API。
|
||
|
||
---
|
||
|
||
### 如何保证"事务内既写库又发消息"?
|
||
|
||
**方案 A:事务消息**(推荐)
|
||
- RocketMQ /阿里云 MSE 原生支持事务消息
|
||
- MQ 回查机制确保消息不丢
|
||
|
||
**方案 B:本地消息表**
|
||
- 在业务库里建一张 `outbox` 表
|
||
- 业务事务同时写入业务数据和消息记录
|
||
- 后台定时任务扫描未发送的消息推送出去
|
||
|
||
```go
|
||
// 本地消息表方案示意
|
||
tx, _ := db.Begin()
|
||
// 1. 业务操作
|
||
tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
|
||
// 2. 记录待发送消息(带状态字段)
|
||
tx.Exec("INSERT INTO outbox (topic, payload, status, created_at) VALUES (?, ?, 'pending', NOW())", "order.created", jsonPayload)
|
||
tx.Commit()
|
||
// 后台进程轮询 outbox 表中 status='pending' 的记录,发送到 MQ 后更新为 'sent'
|
||
```
|
||
|
||
#### 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';
|
||
```
|
||
|
||
### 消费幂等性
|
||
|
||
消息可能重复投递(网络超时、MQ 重投),消费者必须做到**幂等**——处理一次和处理多次的结果完全相同。
|
||
|
||
**三种常见策略:**
|
||
|
||
| 策略 | 实现方式 | 适用场景 |
|
||
|------|---------|---------|
|
||
| 数据库唯一约束 | 用 `msg_id` 或业务主键做 `UNIQUE` | 最可靠,推荐首选 |
|
||
| Redis 去重键 | SET 一个 TTL Key(如 `dedup:{msg_id}` 过期 24h) | 高吞吐场景,需考虑 Redis 可用性 |
|
||
| 乐观锁版本控制 | UPDATE 时加 `WHERE version = old_version` | 适用于金额调整类操作 |
|
||
|
||
```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 err != nil {
|
||
// 其他错误才需要处理,UNIQUE 冲突直接忽略
|
||
if !isUniqueViolation(err) {
|
||
log.Error("process message failed", err)
|
||
}
|
||
}
|
||
// 执行业务逻辑...
|
||
```
|
||
|
||
> [!tip] 设计要点
|
||
> `ON CONFLICT DO NOTHING` 比先查后插更可靠——它消除了竞态窗口。即使用两个实例同时收到同一条消息,只会有一个成功写入。
|
||
|
||
> [!question] 思考
|
||
> 为什么要在生产者侧保证消息可靠投递,而不是靠消费者反复拉取重试?
|
||
|
||
## 方案二:Saga 模式
|
||
|
||
Saga 适用于**跨多个服务的长流程操作**,将大事务拆成一系列本地小事务,每个步骤都有对应的补偿操作。
|
||
|
||
```mermaid
|
||
graph LR
|
||
S1[① 创建订单<br/>→ 取消订单]
|
||
S2[② 扣减库存<br/>→ 恢复库存]
|
||
S3[③ 支付扣款<br/>→ 退款]
|
||
S4[④ 发货<br/>→ 无补偿]
|
||
|
||
S1 --> S2 --> S3 --> S4
|
||
```
|
||
|
||
**编排式 vs 编舞式**:
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph ORCHESTRATION["编排式 — 中心协调器"]
|
||
CO[Coordinator] --> S1[OrderSvc]
|
||
CO --> S2[InventorySvc]
|
||
CO --> S3[PaymentSvc]
|
||
end
|
||
|
||
subgraph CHOREOGRAPHY["编舞式 — 事件驱动"]
|
||
E1[订单创建] --> S11[订单服务]
|
||
S11 --> E2[库存不足事件]
|
||
E2 --> S12[库存服务]
|
||
S12 --> E3[扣减完成事件]
|
||
E3 --> S13[支付服务]
|
||
end
|
||
```
|
||
|
||
| | 编排式 | 编舞式 |
|
||
|---|--------|--------|
|
||
| 控制流 | 中心化 Coordinator | 各服务通过事件自发响应 |
|
||
| 可观测性 | ✅ 集中管理 | ❌ 流程散布在各服务 |
|
||
| 耦合度 | 依赖 Coordinator | 服务间仅感知事件 |
|
||
|
||
## 方案三:TCC 与 AT 模式
|
||
|
||
> [!abstract] 进阶了解
|
||
>
|
||
> **TCC** (Try-Confirm-Cancel):每个事务步骤实现三个接口。适合对一致性要求严格且能承受开发成本的业务。
|
||
>
|
||
> **AT 模式** (Seata):框架层自动处理两阶段提交,对业务透明。本质上是基于 undo_log 的增强型 XA,性能低于 Saga。
|
||
|
||
## 总结选型指南
|
||
|
||
> [!summary] 决策树
|
||
>
|
||
> ```mermaid
|
||
> graph TD
|
||
> A["跨几个服务"] -->|"1-2"| B["本地事务 + 事件通知"]
|
||
> A -->|"3+"| C["Saga 模式"]
|
||
> B -->|"强"| D["加幂等,必要时上 TCC"]
|
||
> B -->|"弱"| E["本地事务已够"]
|
||
> C -->|"不能"| F["考虑 AT 模式"]
|
||
> C -->|"能"| G["自行实现补偿"]
|
||
> ```
|
||
>
|
||
> **经验法则**:80% 的场景,本地事务 + MQ 就足够了。先别急着上复杂方案。
|
||
|
||
## 关联笔记
|
||
|
||
- [[01-基础概念]] — 微服务拆分与独立数据库原则
|
||
- [[02-服务治理]] — 服务调用链路上的超时、重试、熔断
|