分布式一致性原理(一):一致性图谱 —— 我们究竟在解决什么问题?

49 阅读9分钟

分布式系统里,"一致性"大概是被说得最多、也最容易被误解的词。这篇文章从用户体验和系统约束两个角度聊聊这个概念,以及为什么网络一出问题,一致性和可用性就只能二选一。

一、什么是"一致性"?

一个扎心的场景

你发了一条朋友圈,刷新后发现——没了。

再刷新,又出现了。反复横跳,像极了你的感情生活。

这就是一致性问题的日常体感。你的数据写进去了,但读的时候,系统不认账。

一致性,说到底就是一句话:系统对外表现得像只有一份数据

但这句话背后,藏着两个完全不同的视角。

用户视角:我只关心我的体验

作为用户,你不关心系统内部有几个副本、数据怎么同步。你只关心:

读己之写(Read Your Writes)

"我刚写的东西,我必须能读到。"

---
config:
  theme: forest
  look: neo
---
sequenceDiagram
    participant User as 👤 用户
    participant Node1 as 🖥️ 节点A
    participant Node2 as 🖥️ 节点B

    User->>Node1: 写入:"头像=猫猫.jpg"
    Node1-->>User: 写入成功 ✓
    Note over Node1,Node2: ☁️ 同步可能有延迟...
    User->>Node2: 读取头像
    Node2-->>User: 头像=旧图.jpg ✗
    
    Note right of User: 🧐 用户:???我刚改的呢?

这是最基本的用户预期。如果连这个都做不到,用户会觉得你的系统"有毛病"。

单调读(Monotonic Reads)

"时间不能倒流,我看到的数据不能越来越旧。"

---
config:
  theme: forest
  look: neo
---
sequenceDiagram
    participant User as 👤 用户
    participant Node1 as 🖥️ 节点A(已同步)
    participant Node2 as 🖥️ 节点B(未同步)
    
    User->>Node1: 第一次读取
    Node1-->>User: 余额=100元(最新)
    User->>Node2: 第二次读取
    Node2-->>User: 余额=80元(旧数据)
    Note right of User: 🧐 用户:我的20块钱呢?

用户第一次看到余额 100,第二次看到 80,会以为钱被盗了。实际上只是读到了不同副本的数据。

单调读要求:一旦读到某个值,后续读取只能看到相同或更新的值,不能倒退。


系统视角:全局时间线的约束

用户体验的背后,是系统工程师的噩梦。让我们换个视角,看看系统内部在纠结什么。

线性一致性 —— 最强的一致性

一句话定义:所有操作看起来是原子化的,且严格遵守一个全局同步的时间线。

graph LR
    subgraph Time [物理时间轴]
        direction LR
        A[T1: 客户端A写入 x=1] --> B[T2: 客户端B读取 x]
        Start((开始)) --- A
        B --> C[T3: 客户端A写入 x=2]
        C --> D[T4: 客户端C读取 x]
        D --- End((结束))
    end
    
    style A fill:#90EE90
    style C fill:#90EE90
    style B fill:#87CEEB
    style D fill:#87CEEB
    style Time fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5

线性一致性要求

  • T2 时刻读取,必须返回 x=1(因为写入已完成)
  • T4 时刻读取,必须返回 x=2(因为新写入已完成)
  • 任何操作一旦完成,所有后续操作都必须看到它

代价:每次操作都要和"全局真相"同步,性能很差。

顺序一致性 —— 稍弱一点

一句话定义:每个客户端内部的操作顺序保持不变,不同客户端之间的操作可以重排。

举个例子

  • 客户端 A 执行:先写 x=1,再写 y=2
  • 客户端 B 执行:先读 y,再读 x

假设物理时间上,A 的 写y=2 在 B 的 读y 之前就已经返回了。那么:

  • 线性一致性:B 读 y 必须返回 2(必须尊重物理时间)

  • 顺序一致性:B 读 y 返回 0 也行,只要 A 内部的顺序(先写x后写y)和 B 内部的顺序(先读y后读x)都保持不变。

实际场景例子

  • 线性一致性:你发完微信消息,对方立刻能看到
  • 顺序一致性:你发的多条消息,对方看到的顺序和你发送的顺序一致,但可能有延迟

因果一致性 —— 只管有因果关系的操作

一句话定义:有因果关系的操作必须按顺序被所有客户端看到,没有因果关系的操作顺序随意。

什么叫"有因果关系"?

  • A 写了 x=1;B 读到了 x=1,然后 B 写了 y=2 —— 这里 写x=1写y=2 就有因果关系
  • A 写了 x=1,C 写了 z=3,两者互不相干 —— 没有因果关系

举个例子

A: 写 x=1
B: 读 x=1,然后写 y=2
C: 观察 x 和 y
  • 因果一致性要求:C 如果看到了 y=2,就必须也能看到 x=1(因为 y=2 依赖于 x=1)
  • 但不要求:C 必须先看到 x=1 再看到 y=2,只要最终都能看到就行

和顺序一致性的区别:顺序一致性要求所有客户端看到的全局顺序一致,因果一致性只要求有因果关系的那部分顺序一致,其他的可以不一致。

最终一致性 —— 最弱但最实用

一句话定义:如果没有新的写入,最终所有客户端都会读到相同的值。

注意这个"最终"——没有时间承诺,可能是 1 秒,也可能是 1 小时。

举个例子

你改了头像,你朋友刷新后还是旧头像,过了几秒再刷才看到新的。这就是最终一致性。

为什么要用这么弱的一致性? 因为快、可用性高、实现简单。对于很多业务场景(朋友圈、商品评论、DNS),"晚几秒看到"根本不是问题。

一致性强度谱系图

graph LR
    subgraph ConsistencyGroup ["一致性强度:由强到弱"]
        L["线性一致性<br/>Linearizability"] --> S["顺序一致性<br/>Sequential"]
        S --> C["因果一致性<br/>Causal"]
        C --> E["最终一致性<br/>Eventual"]
    end
    
    L -.- L1(["单机数据库"])
    S -.- S1(["ZooKeeper"])
    C -.- C1(["部分 NoSQL"])
    E -.- E1(["DNS / CDN"])
    
    %% 主流程样式
    style L fill:#ff6b6b,color:#fff,stroke:#e66767
    style S fill:#ffa502,color:#fff,stroke:#eccc68
    style C fill:#2ed573,color:#fff,stroke:#7bed9f
    style E fill:#1e90ff,color:#fff,stroke:#70a1ff

    %% 实例节点样式 (柔和色系)
    style L1 fill:#fff5f5,stroke:#ff6b6b,stroke-width:1px
    style S1 fill:#fff9f0,stroke:#ffa502,stroke-width:1px
    style C1 fill:#f0fff4,stroke:#2ed573,stroke-width:1px
    style E1 fill:#f0f7ff,stroke:#1e90ff,stroke-width:1px

    %% 背景颜色
    style ConsistencyGroup fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5

区别总结

特性线性一致性顺序一致性因果一致性最终一致性
物理时间必须尊重可以忽略可以忽略可以忽略
全局顺序唯一且实时唯一但可重排只保证因果链不保证
性能最差较差较好最好
典型场景分布式锁多副本数据库协作编辑DNS / CDN

选择原则:没有最好的一致性,只有最适合业务场景的一致性。

二、物理世界的枷锁

看完上一节,你可能会想:既然线性一致性最强,那我全都要线性一致性不就完了?

很遗憾,物理世界不允许。

FLP 不可能性定理:异步网络的诅咒

1985 年,Fischer、Lynch 和 Paterson 三位大神证明了一个令人绝望的结论:

在异步网络中,只要有一个节点可能崩溃,就无法在有限时间内让所有节点对某个值达成一致。

为什么?问题出在"异步网络"这四个字上。

graph LR
    classDef base fill:#e3f2fd,stroke:none,color:#1565c0;
    classDef highlight fill:#fff8e1,stroke:none,color:#f57f17;
    classDef critical fill:#ffcdd2,stroke:none,color:#b71c1c;

    N1[节点1]:::base -->|发送消息| N2[节点2]:::base
    N2 -.->|"迟迟没有回应..."| N1
    N1 --> Q{"节点2 怎么了?"}:::highlight
    Q --"消息还在路上?"--> R[无法区分]:::critical
    Q --"节点2 挂了?"--> R

    linkStyle default stroke:#90a4ae,stroke-width:2px;
    linkStyle 1 stroke:#e57373,stroke-width:2px,color:#e57373,stroke-dasharray: 5 5;
  • 如果你选择等:可能等到天荒地老
  • 如果你选择不等:可能误判(对方其实没挂)

这就是 FLP 的核心结论:在异步网络中,你永远无法在有限时间内确定对方的状态,因此无法保证所有节点达成一致。

FLP 告诉我们:完美的一致性在理论上不可能,工程上只能做到"足够好"。怎么做到?后面讲 Paxos、Raft 时会详细展开。

CAP 定理:三选二的永恒困境

FLP 说的是"达成一致很难",CAP 说的是"就算能达成一致,你也得做选择"。

2000 年,Eric Brewer 提出了著名的 CAP 定理:

一个分布式系统,最多只能同时满足以下三项中的两项。

这三项是:

字母含义解释
CConsistency(一致性)所有节点看到的数据相同
AAvailability(可用性)每个请求都能得到响应(不会被拒绝或超时)
PPartition Tolerance(分区容错)网络分区(节点之间失联)时系统仍能运行

为什么不能三个都要?

因为在分布式系统中,网络分区(P)一定会发生——光缆被挖断、交换机故障、机房断网……

分区发生时,节点之间无法通信,数据没法同步。这时候你只有两个选择:

  1. 保 C 舍 A:拒绝服务,等网络恢复、数据同步完再响应。数据是一致的,但用户等不到响应。
  2. 保 A 舍 C:继续响应,但返回的可能是旧数据。用户有响应,但数据可能不一致。

P 是前提,不是选项。CAP 的本质是:当网络分区发生时,你选 C 还是选 A?

mermaid

---
config:
  theme: forest
  look: neo
---
sequenceDiagram
    participant Client as 客户端
    participant Node1 as 节点1
    participant Node2 as 节点2
    
    Note over Node1,Node2: 网络分区!节点间无法通信
    
    Client->>Node1: 写入 x=1
    Node1-->>Client: 写入成功
    
    rect rgb(255, 200, 200)
        Note over Node1,Node2: 数据无法同步到节点2
    end
    
    Client->>Node2: 读取 x
    
    alt 选择 C(一致性)
        Node2->>Client: 拒绝服务
        Note right of Client: 宁可不可用,也不返回旧数据
    else 选择 A(可用性)
        Node2->>Client: x=旧值
        Note right of Client: 宁可返回旧数据,也要响应
    end

常见系统的选择

系统选择理由
ZooKeeper / EtcdCP配置中心、分布式锁,数据必须一致
Redis Cluster / MongoDBAP高可用优先,允许短暂的数据不一致

CAP 不是一次性的架构决策,而是每次网络分区时的实时抉择。

三、两条路线的选择

理论讲完了,我们来看看工程实践中,两大阵营是怎么落地的。

ACID:数据库人的执念

传统数据库出身的架构师,骨子里信奉一个字:

ACID 是数据库事务的四个特性:

特性含义白话翻译
原子性(Atomicity)事务要么全成功,要么全失败转账不能钱扣了对面没收到
一致性(Consistency)事务前后数据库状态都合法不能出现负库存
隔离性(Isolation)并发事务互不干扰你读的时候我改不影响你
持久性(Durability)提交后数据不丢失断电重启数据还在

ACID 的核心信条:宁可慢,不能错。

典型代表:MySQL、PostgreSQL、Oracle

适用场景:金融交易、库存扣减、订单状态——任何"错一条都是生产事故"的场景。

⚠️ ACID 的 C 和 CAP 的 C 不是一回事!

  • ACID 的 C(一致性):数据库约束不被破坏,比如转账前后总金额不变
  • CAP 的 C(一致性):分布式副本之间数据相同,即线性一致性

BASE:互联网人的妥协

互联网业务的特点是什么?量大、高并发、允许短暂不一致

于是 eBay 的架构师们提出了 BASE:

特性含义白话翻译
基本可用(Basically Available)出故障时保证核心功能可用双十一退货功能可以挂,但下单必须能用
软状态(Soft State)数据副本之间允许暂时不同步你在节点A改了数据,节点B可以晚几秒才同步到
最终一致(Eventually Consistent)经过一段时间后达成一致不保证立刻一致,但最终会一致

BASE 的核心信条:先活下来,再慢慢对。

典型代表:Redis Cluster、MongoDB、消息队列

适用场景:朋友圈、商品评论、用户行为日志——"晚一秒看到问题不大"的场景。

ACID vs BASE:不是对立,是互补

ACID 和 BASE 不是非此即彼的选择,而是根据业务场景灵活组合。

核心问题就一个:数据错了,后果有多严重?

业务场景一致性要求推荐思路
用户余额扣减强一致ACID 事务
订单创建 + 库存扣减最终一致先创建订单,异步扣库存
用户头像更新最终一致异步更新,允许短暂不一致
日志采集弱一致允许少量丢失

一句话总结:钱相关的用 ACID,体验相关的用 BASE。

总结

mindmap
  root((一致性图谱))
    什么是一致性
      用户视角
        读己之写
        单调读
      系统视角
        线性一致性
        顺序一致性
        因果一致性
        最终一致性
    物理限制
      FLP 不可能性
        异步网络无法保证一致
      CAP 定理
        P 是前提
        C vs A 的抉择
    工程路线
      ACID
        强一致
        宁慢不错
      BASE
        最终一致
        先活再对

三句话总结

  • 一致性的本质:让分布式系统表现得像单机一样,但代价各不相同

  • 物理定律的限制:FLP 说完美不可能,CAP 说分区时必须在 C 和 A 之间选择

  • 工程的智慧:ACID 和 BASE 不是非此即彼,而是根据业务场景灵活组合

下篇预告

这篇讲了一致性的"是什么"和"为什么难",下一篇我们看看前人是怎么尝试解决这个问题的。

最直观的思路是:让所有节点投票,全票通过才提交。这就是 2PC(两阶段提交)。

听起来很美好,但有个致命问题……

下一篇《原子提交的演进 —— 2PC、3PC 与网络分区的难题》,我们来看看这条路为什么走不通。