后端架构实战(五):分布式数据一致性——ACID 之后怎么办

写在前面

上一篇把"分布式税"列了一遍,其中最重的一笔是数据一致性。这一篇就把它讲透。

单体的世界里,一个数据库事务就能保证 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,还是异步的消息?