写在前面
上一篇把"分布式税"列了一遍,其中最重的一笔是数据一致性。这一篇就把它讲透。
单体的世界里,一个数据库事务就能保证 ACID,干净利落。一旦拆成微服务、每个服务一个库,跨库就再也没有 ACID 了。那数据一致性怎么保证?答案是:放弃强一致,用一系列模式把"最终一致"工程化。
前置:这篇假设你理解 ACID 和隔离级别。我在《数据库系列(四):事务与 ACID》《数据库系列(五):MVCC 与隔离级别》里讲过单机版。
一、问题:跨服务的"事务"怎么办
1
2
3
4
5
6
7
8
9
10
11
12
13
| 经典场景:下单 = 创建订单 + 扣库存 + 扣余额 + 加积分
单体:一个数据库事务
BEGIN;
insert 订单; update 库存; update 余额; insert 积分;
COMMIT;
→ 要么全成功,要么全回滚。ACID 保护你。
微服务:订单/库存/账户/积分 各自独立的库
insert 订单 ✅ → 扣库存 ✅ → 扣余额 ❌(账户服务挂了)
→ 订单建了、库存扣了、余额没扣。钱少货没了。灾难。
跨库没有 ACID,怎么办?
|
二、两条根本出路
1
2
3
4
5
6
7
8
| 出路 A:把强一致收敛在一个服务内(首选)
→ 核心数据合并到同一个库(甚至同一个服务)
→ 第三篇说过:核心交易该收敛在一个上下文里
→ 能不分布式,就不分布式
出路 B:接受最终一致,用模式工程化(本篇重点)
→ 实在拆开了,就放弃 ACID,保证"最终"一致
→ 下面四个模式:Saga / 事件驱动 / Outbox / CQRS
|
第一选择永远是 A。B 是"不得不拆"时的补救。不要为了用 Saga 而 Saga。
三、Saga 模式
Saga:把一个分布式事务拆成一串本地事务,每个本地事务有对应的补偿动作。任何一步失败,就反向执行已完成步骤的补偿,最终达到"一致"。
1
2
3
4
5
6
7
8
9
10
11
12
| 下单 Saga(每步都有补偿):
正向: 补偿(失败时反向执行):
1. 创建订单 → 取消订单
2. 扣库存 → 还库存
3. 扣余额 → 退余额
4. 加积分 → 扣积分
执行:
1 ✅ → 2 ✅ → 3 ❌(余额不足)
→ 触发补偿:还库存(2) → 取消订单(1)
→ 最终:订单取消、库存还原、余额没动。一致。
|
Saga 有两种协调方式:
1
2
3
4
5
6
7
8
9
10
11
12
| 编排(Orchestration)—— 有一个中心协调者
Orchestrator 按顺序调用各服务,跟踪状态,失败时发补偿命令
✓ 流程清晰、易监控、状态可控
✗ 协调者成了单点(需高可用)
适合:流程复杂、步骤多的核心业务(如下单全流程)
协同(Choreography)—— 无中心,事件驱动
每个服务订阅事件,完成自己的事后发新事件
订单服务发"订单已创建" → 库存服务听到后扣库存并发"库存已扣" → ...
✓ 去中心化、易扩展、无单点
✗ 流程隐式、难追踪(出问题不知道现在走到哪)
适合:流程简单、步骤少
|
Saga 的底子是分布式事务理论,我在《分布式系统学习笔记(四):分布式事务》里讲过 2PC/TCC/Saga 的对比,这里只讲架构落地。
Saga 的两个硬骨头
1
2
3
4
5
6
7
| 1. 补偿必须幂等
补偿动作可能被重试执行(网络抖动)
→ "扣库存"的补偿"还库存"必须能重复执行不出错
2. 不是所有操作都能补偿
"发短信""扣款给第三方"这类副作用无法撤销
→ 把不可补偿的步骤放到最后,或用"预占/确认"两阶段
|
四、事件驱动 + 最终一致性
1
2
3
4
5
6
7
8
9
10
11
| 核心理念:服务之间通过"事件"解耦,不强求同步一致
订单服务:创建订单后,发"订单已创建"事件(不关心谁消费)
→ 库存、积分、通知 各自订阅,异步处理
→ 订单服务不等待它们,立即返回成功
一致性保证:弱化了
订单成功了,但积分可能晚几秒才加(最终一致)
需要业务接受这个延迟窗口
收益:解耦、异步、高可用、可扩展
|
最终一致性的前提:业务能容忍短暂不一致。账号余额晚 3 秒更新,用户感知不到;但支付晚 3 秒就不行。核心资金强一致,周边副作用最终一致——这是通用法则。
五、Outbox 模式:解决"事件发了但没发出去"
事件驱动有个经典坑:业务操作和发事件不在一个事务里。
1
2
3
4
5
6
7
8
9
10
| 问题:
update 订单 ✅(提交了)
发"订单已创建"事件 ❌(发消息时网络挂了)
→ 订单建了,但下游永远收不到事件。
或者反过来:
发事件 ✅ → 写库 ❌
→ 下游收到事件,但订单其实没建。
根因:数据库事务和消息中间件是两个系统,没法一起 ACID。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| Outbox 模式(发件箱):
把"事件"当成一行记录,和业务数据写进同一个库:
BEGIN;
insert 订单;
insert into outbox(event) values('订单已创建'); -- 同库同事务
COMMIT;
→ 订单和事件要么都成功,要么都失败。ACID 保护。
然后一个独立进程(或 CDC 工具):
轮询 outbox 表 → 把事件投递到消息队列 → 标记已发送
常见:Debezium(监听 binlog)、自研轮询 worker
效果:业务操作和事件发布,获得"原子性"。
这是事件驱动架构的标准配置。
|
六、幂等性:分布式一致性的基石
1
2
3
4
5
6
7
8
9
10
11
12
| 为什么必须幂等:
网络会重试、消息会重复投递、补偿会重复执行
→ 同一个操作可能被执行多次
→ 必须保证"执行一次 = 执行多次"的结果相同
实现手段:
1. 唯一业务 ID + 去重表(处理过就跳过)
2. 状态机(只有特定状态才能流转)
3. 乐观锁版本号(update ... where version = x)
4. 数据库唯一约束(重复插入直接失败)
经验:所有跨服务、跨网络的写操作,默认就要按幂等设计。
|
七、CQRS:读写分离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| CQRS(Command Query Responsibility Segregation)
传统:一个模型既读又写
CQRS:写模型(命令)和读模型(查询)分开
写模型:面向业务一致性,规范化存储(如关系库)
读模型:面向查询性能,反规范化(如 ES、Redis、物化视图)
数据流:
写 → 写库 → 发事件 → 同步到读库(最终一致)
读 → 直接读读库(快、可定制)
价值:
✓ 读写各自优化(写要一致,读要快)
✓ 读侧可水平扩展、多形态查询
✓ 天然配合事件驱动
代价:
✗ 复杂度上升(要维护数据同步)
✗ 读写有延迟(最终一致)
适用:读多写少、查询复杂、读写负载差异大的场景。
不要在简单 CRUD 上用 CQRS。
|
八、一份选型清单
1
2
3
4
5
6
7
8
9
| 场景 推荐方案
──────────────────────────────────────────────────────
核心交易(下单、支付、扣款) 强一致,尽量收敛在一个服务/库
跨服务流程,步骤多,需补偿 Saga(编排式)
跨服务流程,步骤少,去中心 事件驱动(协同式)
副作用通知(积分、通知、统计) 事件驱动 + 最终一致
事件可靠投递 Outbox 模式
读多写少、查询复杂 CQRS
所有跨网络写操作 必须幂等
|
九、小结
- 第一原则:核心数据尽量收敛在一个库/服务,能不分布式就不分布式
- Saga:本地事务链 + 补偿;编排式(中心)适合复杂流程,协同式(事件)适合简单流程
- 事件驱动:服务间通过事件解耦,接受最终一致
- Outbox 模式:把事件写入业务库同事务,解决"事件丢失"
- 幂等性:所有跨网络写操作的基石
- CQRS:读写分离,读侧可极致优化,代价是复杂度和延迟
- 通用法则:核心资金强一致,周边副作用最终一致
下一篇讲服务之间怎么通信:同步的 RPC/REST,还是异步的消息?