7.7 KiB
tags, create time
| tags | create time | ||||||||
|---|---|---|---|---|---|---|---|---|---|
|
2026-04-29 12:03 |
数据一致性
概述
微服务的核心设计原则是 "每个服务拥有独立数据库",这带来了分布式事务和数据一致性的经典难题。本文梳理主要解决方案及其取舍。
为什么不能共享数据库
graph LR
S1[订单服务 DB]
S2[库存服务 DB]
subgraph BAD["反模式:共享数据库"]
S1 --- SHARED[(共享 DB)]
S2 --- SHARED
end
[!failure] 反模式警告 如果两个服务连接同一个数据库的同一张表,它们就不再是独立的微服务——你得到的是分布式单体。服务可以随意互相查询彼此的数据,失去了边界和自治性。
分布式事务方案概览
| 方案 | 一致性级别 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 本地事务 + 事件通知 | 最终一致 | 高 | 低 | 绝大多数场景 |
| Saga 模式 | 最终一致 | 中 | 中 | 长流程业务 |
| TCC | 强最终一致 | 中 | 高 | 对一致性要求较高的场景 |
| AT 模式 (Seata) | 伪强一致 | 中低 | 低 | 不想改业务代码时 |
[!info] 选择策略 先默认用 本地事务 + 异步事件(最简单、最高效),只有在业务明确需要 Saga 或 TCC 时才升级。
方案一:本地事务 + 消息队列
这是最常用的方案,核心思想是将"数据变更 + 发消息"合并到一个本地事务中。
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表 - 业务事务同时写入业务数据和消息记录
- 后台定时任务扫描未发送的消息推送出去
// 本地消息表方案示意
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 表建表示例
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 |
适用于金额调整类操作 |
// 推荐方案:利用 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 适用于跨多个服务的长流程操作,将大事务拆成一系列本地小事务,每个步骤都有对应的补偿操作。
graph LR
S1[① 创建订单<br/>→ 取消订单]
S2[② 扣减库存<br/>→ 恢复库存]
S3[③ 支付扣款<br/>→ 退款]
S4[④ 发货<br/>→ 无补偿]
S1 --> S2 --> S3 --> S4
编排式 vs 编舞式:
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] 决策树
graph TD A["跨几个服务"] -->|"1-2"| B["本地事务 + 事件通知"] A -->|"3+"| C["Saga 模式"] B -->|"强"| D["加幂等,必要时上 TCC"] B -->|"弱"| E["本地事务已够"] C -->|"不能"| F["考虑 AT 模式"] C -->|"能"| G["自行实现补偿"]经验法则:80% 的场景,本地事务 + MQ 就足够了。先别急着上复杂方案。