土法炼钢兴趣小组的算法知识备份

【分布式系统百科】一致性模型全景:从线性一致性到最终一致性的光谱

文章导航

分类入口
distributed
标签入口
#consistency-models#linearizability#sequential-consistency#causal-consistency#eventual-consistency#distributed-systems

目录

本文和 混合逻辑时钟 属于同一系列。时钟解决”事件的先后”,一致性模型定义”读到什么值才算正确”。建议搭配阅读。

你有一个三副本的键值存储。客户端 A 写入 x = 1,收到成功响应。紧接着客户端 B 去读 x,读到的是 0

这是 bug 吗?

答案取决于你的系统承诺了哪种一致性模型(Consistency Model)。如果承诺的是线性一致性(Linearizability),这就是 bug。如果承诺的是最终一致性(Eventual Consistency),这完全合法——只要 B 最终能读到 1 就行。

工程师经常把一致性简化成”强一致 vs 最终一致”两档。实际上,一致性模型是一条连续的光谱,中间有很多实际可用的档位。选错了档位,要么为不需要的保证付出延迟和可用性的代价,要么拿到的保证不够强、在生产环境踩坑。

这篇文章做三件事:

  1. 把线性一致性、顺序一致性、因果一致性、最终一致性及其变体逐级拆开,用反例说清楚每一级到底少了什么保证。
  2. 把 ZooKeeper、Spanner、DynamoDB、Cassandra 的一致性选择映射到这条光谱上。
  3. 给出一个 Go 实现的操作历史检测工具,判断一组读写操作是否满足各级一致性模型。

一、一致性模型到底在定义什么

先把概念边界划清楚。

“一致性”这个词在分布式系统中至少有三种含义,搞混了后面全白谈:

含义 上下文 例子
ACID 中的 C 数据库事务 外键约束、唯一性约束
CAP 中的 C 分布式系统可用性权衡 线性一致性
副本一致性 复制协议 最终一致性、因果一致性

本文讨论的是第三种:副本一致性(Replication Consistency),也叫数据一致性模型。它定义的问题是:一个数据对象在多个副本上被并发读写时,每次读操作允许返回哪些值。

形式化地说,一致性模型是对操作历史(history)的合法性约束。给定一组客户端发出的读写操作(包含调用时间、返回时间、操作内容),一致性模型判定这组操作历史是否合法。模型越强,合法的历史越少;模型越弱,合法的历史越多,系统实现的自由度也越大。

单对象 vs 多对象

一个容易混淆的区别:

Herlihy 和 Wing 在 1990 年的论文中定义线性一致性时,明确限定了讨论范围是单个并发对象。后来工业界把”线性一致性”宽泛地用在整个系统上,经常导致概念模糊。本文在讨论线性一致性和顺序一致性时,保持 Herlihy-Wing 的原始范围:单对象语义。

二、强一致性:线性一致性与顺序一致性

线性一致性(Linearizability)

线性一致性由 Herlihy 和 Wing 在 1990 年提出(“Linearizability: A Correctness Condition for Concurrent Objects”)。定义只有一句话:

每个操作看起来像是在它的调用时刻和返回时刻之间的某个瞬间原子地完成的,并且所有操作构成一个全序,这个全序和每个操作的实时顺序一致。

拆开来看,两个核心约束:

  1. 原子性:每个操作都可以被”压缩”成时间轴上的一个点(线性化点,Linearization Point)。
  2. 实时序(Real-time Order):如果操作 A 在操作 B 开始之前就已经完成,那么在全序中 A 必须排在 B 前面。

实时序是线性一致性的关键特征。如果操作 A 和操作 B 在时间上重叠(即 A 还没返回,B 就已经调用),那么它们的顺序可以任意排列。但如果它们不重叠——A 先完成,B 后开始——那么 A 必须在 B 前面。

用一个具体例子说明。下面这组操作历史是否满足线性一致性?

客户端 C1:  |--- write(x, 1) ---|
客户端 C2:      |--- read(x) -> 0 ---|
客户端 C3:                              |--- read(x) -> 1 ---|

时间轴从左到右。C1 的写和 C2 的读在时间上重叠,所以 C2 读到 01 都合法。C3 的读在 C1 的写完成之后才开始,所以 C3 必须读到 1。这组历史合法。

再看一组:

客户端 C1:  |--- write(x, 1) ---|
客户端 C2:                         |--- read(x) -> 1 ---|
客户端 C3:                                  |--- read(x) -> 0 ---|

C2 在 C1 完成后读到 1,合法。C3 在 C2 完成后(或重叠期间)读到 0。问题来了:C1 的写在 C3 开始之前就已经完成,所以任何合法的线性化顺序中 write(x,1) 都必须排在 C3 的 read 前面。C3 读到 0 意味着在线性化顺序中 C3 的读排在写之前,矛盾。这组历史不满足线性一致性。

顺序一致性(Sequential Consistency)

顺序一致性由 Lamport 在 1979 年提出(“How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs”)。定义:

所有操作构成一个全序,这个全序和每个进程内部的程序序一致。

和线性一致性的区别:顺序一致性不要求全序和实时顺序一致。

只要存在一种全局排列方式,使得(a)每个进程内部的操作顺序不变,(b)每次读都返回最近一次写的值,这组历史就满足顺序一致性。

看一组线性一致性不合法但顺序一致性合法的例子:

客户端 C1:  |--- write(x, 1) ---| 完成时刻 t1
客户端 C2:                          |--- write(x, 2) ---| 完成时刻 t2
客户端 C3:                                                 |--- read(x) -> 1 ---|

在实时序中,write(x,1) 先于 write(x,2)write(x,2) 先于 read(x)。线性一致性要求 C3 读到 2(因为最后完成的写是 write(x,2))。

但在顺序一致性下,我们可以构造一个全序:write(x,2)write(x,1)read(x) -> 1。虽然这个顺序和实时序不一致,但每个进程内部只有一个操作,不违反任何进程内的程序序。读到 1 合法。

异常模式速查:每级模型到底少了什么

下面用一组具体的读写序列,展示”违反高级模型但满足低级模型”的典型异常。这些异常在生产系统中以 stale read、因果断裂、写回退等形式出现。

flowchart TD
    A["Stale Read<br/>读到已被覆盖的旧值"] -->|"违反线性一致性<br/>满足顺序一致性"| B["无实时序保证"]
    C["因果断裂<br/>看到结果却看不到原因"] -->|"违反因果一致性<br/>满足最终一致性"| D["无因果序保证"]
    E["读回退<br/>先读到新值再读到旧值"] -->|"违反单调读<br/>满足最终一致性"| F["无会话保证"]
    G["Write Skew<br/>两个事务各读各写产生矛盾"] -->|"违反可串行化<br/>满足快照隔离"| H["无全序事务保证"]
    style A fill:#f9f,stroke:#333
    style C fill:#f9f,stroke:#333
    style E fill:#f9f,stroke:#333
    style G fill:#f9f,stroke:#333

图中列出四种典型异常模式。Stale Read 是线性一致性与顺序一致性的分界线:系统放弃实时序约束后,客户端可能读到已被后续写入覆盖的旧值。因果断裂是因果一致性的标志性违反:观察者看到了某个操作的结果,却看不到导致该结果的前驱操作。读回退和 Write Skew 分别对应会话保证和事务隔离层面的缺失。

异常实例 1:违反线性一致性,满足顺序一致性(Stale Read)

时间轴 →
C1:  |--- write(x, 1) ---|  完成于 t=10
C2:                         |--- write(x, 2) ---|  完成于 t=20
C3:                                                |--- read(x) -> 1 ---|  开始于 t=22

线性一致性要求 C3 的读排在 write(x,2) 之后(实时序:t=22 > t=20),必须读到 2。但 C3 读到了 1——这是一次 stale read。在顺序一致性下,可以构造全序 write(x,2) -> write(x,1) -> read(x)->1,虽然违反实时序,但不违反任何进程内程序序,合法。

异常实例 2:违反顺序一致性,满足因果一致性(并发写的观察分歧)

P1: write(x, 1)                           (独立写入,与 P2 无因果关系)
P2: write(x, 2)                           (独立写入,与 P1 无因果关系)
P3: read(x) -> 1    read(x) -> 2          (先看到 1,再看到 2)
P4: read(x) -> 2    read(x) -> 1          (先看到 2,再看到 1)

顺序一致性要求存在一个所有进程都同意的全序。P3 的观察顺序暗示 write(x,1)write(x,2) 之前,P4 的观察顺序暗示相反——矛盾,无法构造合法全序。但因为 P1 和 P2 的写没有因果关系,因果一致性允许不同观察者看到不同顺序。

异常实例 3:违反因果一致性,满足最终一致性(因果断裂)

P1: write(x, 1)
P2:               read(x) -> 1    write(y, 2)     (P2 在看到 x=1 之后写了 y=2)
P3:                                           read(y) -> 2    read(x) -> 0

P2 读到 x=1 后写了 y=2,建立因果链 write(x,1) -> write(y,2)。P3 看到了 y=2,因果一致性要求 P3 也必须看到 write(x,1)。但 P3 读 x 却得到 0(初始值)——因果链被打断。在最终一致性下,只要 P3 最终能读到 x=1,这种中间状态合法。

下面这张时序图用同一组操作对比线性一致性和顺序一致性的差异:

sequenceDiagram
    participant C1 as 客户端 C1
    participant C2 as 客户端 C2
    participant Reg as 寄存器 x

    Note over C1,Reg: 场景:两个写 + 一个读,相同操作不同合法性
    C1->>Reg: write(x, 1) [t=0..10]
    C2->>Reg: write(x, 2) [t=12..20]
    C2->>Reg: read(x) [t=22..30]
    Reg-->>C2: 返回 1

    Note over C1,Reg: 线性一致性判定
    Note right of Reg: write(x,2) 在 t=20 完成<br/>read(x) 在 t=22 开始<br/>实时序要求 read 排在 write(x,2) 之后<br/>必须返回 2,返回 1 违反

    Note over C1,Reg: 顺序一致性判定
    Note right of Reg: 不要求实时序<br/>可构造全序 write(x,2)->write(x,1)->read(x)->1<br/>C2 的程序序 write(x,2) 在 read(x) 之前?<br/>不,write(x,2) 是 C2 发起的<br/>但 read(x) 也是 C2 的<br/>C2 程序序 write->read 保持,合法

图中展示了同一组操作在两种模型下的不同判定结果。关键差异在于实时序约束:线性一致性将操作锚定在物理时间轴上,不重叠的操作必须按实时顺序排列;顺序一致性只关心每个进程内部的操作顺序是否保持,允许全局排列偏离实时序。

线性一致性 vs 顺序一致性:核心差异

维度 线性一致性 顺序一致性
提出者 Herlihy & Wing, 1990 Lamport, 1979
实时序约束 有:非重叠操作必须保持实时顺序 无:只保持进程内程序序
直觉含义 “看起来像只有一个副本” “看起来像一个全局顺序执行”
可组合性 是:每个对象独立满足即全局满足 否:单个对象满足不保证全局满足
性能代价 每次读写可能需要多数派通信 比线性一致性低

可组合性(Composability)是一个经常被忽视的差异。Herlihy 和 Wing 证明了线性一致性是可组合的:如果对象 x 的操作历史满足线性一致性,对象 y 的操作历史也满足线性一致性,那么 xy 合在一起的历史也满足线性一致性。

顺序一致性不可组合。考虑下面的例子:

进程 P1: write(x, 1)  →  read(y) -> 0
进程 P2: write(y, 1)  →  read(x) -> 0

单独看对象 x:可以构造全序 read(x)->0, write(x,1),满足顺序一致性(但需要把 P1 的 write(x,1) 排在 P2 的 read(x) 后面)。不过这要求 P2 的操作在全局序中先于 P1 的写。

单独看对象 y:同理可以构造合法的全序。

但合在一起,P1 要求自己的 write(x,1)read(y)->0 之前(程序序),read(y)->0 要求 P2 的 write(y,1) 还没发生,所以 P1 的所有操作在 P2 的 write(y,1) 之前。同理 P2 的所有操作在 P1 的 write(x,1) 之前。环形依赖,不存在合法全序。合在一起不满足顺序一致性。

Jepsen 如何检测线性一致性

Jepsen 是 Kyle Kingsbury 开发的分布式系统正确性测试框架。它的核心思路:

  1. 对被测系统并发执行一系列读写操作。
  2. 记录每个操作的调用时间和返回时间。
  3. 把操作历史交给线性一致性检查器(Knossos 或 Porcupine),判断是否存在合法的线性化顺序。

检查线性一致性是 NP 完全问题(Gibbons & Korach, 1997)。实际的检查器用搜索加剪枝:尝试构造一个合法的线性化顺序,如果搜索空间穷尽仍找不到,就判定违反。Porcupine(Go 实现)在大多数实际场景下能在几秒内完成检查。

Jepsen 发现过大量真实系统的线性一致性违反。几个典型案例:

这些不是理论问题——这些 bug 都在生产系统中出现过。

三、因果一致性:“最强的可用一致性”

定义

因果一致性(Causal Consistency)的要求:如果操作 A 因果先于操作 B,那么所有进程都必须先看到 A 再看到 B。对于没有因果关系的并发操作,不同进程可以看到不同的顺序。

什么是”因果先于”(Causally Precedes)?三条规则:

  1. 程序序:同一进程中,先执行的操作因果先于后执行的操作。
  2. 读写因果:如果进程 P 读到了进程 Q 写入的值,那么 Q 的写因果先于 P 的读。
  3. 传递性:如果 A 因果先于 B,B 因果先于 C,那么 A 因果先于 C。

这三条规则直接对应 Lamport 的 happens-before 关系。因果一致性就是要求系统尊重这种 happens-before 关系。

一个例子说明因果一致性允许什么、不允许什么:

进程 P1: write(x, 1)
进程 P2:               read(x) -> 1    write(y, 2)
进程 P3:                                             read(y) -> 2    read(x) -> ?

P2 读到了 P1 写的 x=1,然后 P2 写了 y=2。这建立了因果链:write(x,1)read(x)->1write(y,2)

P3 读到了 y=2,说明 P3 看到了 write(y,2)。因果一致性要求 P3 也必须看到 write(y,2) 之前的因果前驱——包括 write(x,1)。所以 P3 读 x 必须读到 1

下面的依赖图可视化了上述因果链的结构:

graph LR
    W1["write(x, 1)<br/>P1"] -->|"读写因果"| R1["read(x)->1<br/>P2"]
    R1 -->|"程序序"| W2["write(y, 2)<br/>P2"]
    W2 -->|"读写因果"| R2["read(y)->2<br/>P3"]
    R2 -->|"传递性要求"| W1
    W1 -.->|"P3 必须也看到"| R3["read(x)->?<br/>P3 必须返回 1"]
    style W1 fill:#e1f5fe,stroke:#0288d1
    style W2 fill:#e1f5fe,stroke:#0288d1
    style R1 fill:#fff3e0,stroke:#f57c00
    style R2 fill:#fff3e0,stroke:#f57c00
    style R3 fill:#ffebee,stroke:#c62828

图中实线箭头表示显式因果依赖(程序序或读写因果),虚线箭头表示传递性推导出的隐式依赖。P3 读到了 write(y,2) 的结果,而 write(y,2) 因果依赖于 write(x,1),因此 P3 必须也能看到 write(x,1)。这就是因果一致性的核心约束:依赖图中所有前驱必须可见。

但如果两个写操作之间没有因果关系:

进程 P1: write(x, 1)
进程 P2: write(y, 2)
进程 P3: read(x) -> 1   read(y) -> 0     // P3 先看到 x=1,还没看到 y=2
进程 P4: read(y) -> 2   read(x) -> 0     // P4 先看到 y=2,还没看到 x=1

P1 和 P2 的写没有因果关系(并发),所以 P3 和 P4 看到不同的顺序完全合法。这在线性一致性和顺序一致性下都不合法,但因果一致性允许。

“最强的可用一致性”

2011 年,Mahajan、Alvisi 和 Dahlin 发表了 “Consistency, Availability, and Convergence”,证明了一个关键结论:

因果一致性是网络分区期间仍然可用的最强一致性模型。

这个结论的含义:CAP 定理告诉我们,网络分区时不能同时满足强一致性和可用性。但 CAP 中的”强一致性”指的是线性一致性。如果把一致性降低到因果一致性,就可以在分区期间继续提供服务,而且因果一致性是能做到这一点的最强模型——任何比因果一致性更强的模型都无法在分区期间保证可用。

这个结论让因果一致性变成了一个重要的工程选项:如果你的业务不需要线性一致性(很多业务确实不需要),因果一致性是一个性能和可用性都好得多的选择。

COPS:因果一致性的工程实现

COPS(Clusters of Order-Preserving Servers)是 2012 年 Lloyd 等人在 SOSP 上发表的系统(“Don’t Settle for Eventual: Scalable Causal Consistency for Wide-Area Storage with COPS”),是因果一致性在跨数据中心场景下的代表性实现。

核心设计:

依赖追踪:每个写操作都携带一个依赖集(dependency set),记录这次写因果依赖的所有前驱操作。具体来说,如果客户端先读了 x 的某个版本,然后写了 y,那么 write(y) 的依赖集中会包含 x 的那个版本。

写入时检查:当一个数据中心收到远端复制过来的写操作时,不是立即应用,而是先检查依赖集中的所有前驱操作是否已经在本地生效。如果有前驱还没到,这个写就等待,直到所有依赖满足后才应用。

客户端维护因果上下文:每个客户端维护一个因果上下文(causal context),记录它已经看到的操作版本。每次读操作返回时更新上下文,每次写操作把上下文作为依赖集发送出去。

数据中心 DC1                          数据中心 DC2
┌─────────────┐                    ┌─────────────┐
│ write(x, 1) │ ──复制──────────→ │ 收到 write(x,1) │
│   版本 v1    │                    │   直接应用        │
│             │                    │                  │
│ write(y, 2) │ ──复制──────────→ │ 收到 write(y,2) │
│ 依赖: {x:v1}│                    │ 检查 x:v1 是否    │
│             │                    │ 已在本地? 是→应用 │
└─────────────┘                    └─────────────┘

COPS 的代价是依赖集的大小。极端情况下,一个写操作可能间接依赖大量前驱,依赖集爆炸。COPS-GT(Get Transactions)版本通过只追踪最近的依赖来缓解这个问题,但本质的元数据开销无法完全消除。

因果一致性的实际挑战

因果一致性的理论定义很清晰,但工程实现要面对几个问题:

  1. 因果关系追踪的开销:准确追踪因果关系需要向量时钟或依赖集,在大规模系统中开销大。这和 上一篇 讨论的向量时钟膨胀问题是同一个。
  2. 外部因果(External Causality):用户在系统 A 上看到一个值,然后打电话告诉另一个用户去系统 B 查。这种因果关系发生在系统之外,系统无法追踪。
  3. 垃圾回收:依赖元数据需要回收,但回收太快会导致因果关系丢失。

四、最终一致性及其变体

最终一致性的精确定义

最终一致性(Eventual Consistency)的定义看似简单:

如果没有新的写入,所有副本最终会收敛到同一个值。

但这个定义非常弱。它只保证了收敛性,对收敛之前的行为几乎没有约束。在收敛之前,读操作可以返回任何曾经写入的值,不同副本可以返回不同的值,甚至同一个客户端连续两次读可以先读到新值再读到旧值。

“最终”到底是多久?定义不说。可能是毫秒,可能是几小时。

正因如此,纯粹的最终一致性在实际应用中几乎不可用。工程师需要的是各种加强版的最终一致性,即会话保证(Session Guarantees)。

四种会话保证

Terry、Demers、Petersen 等人在 1994 年的论文 “Session Guarantees for Weakly Consistent Replicated Data” 中定义了四种会话保证,每一种都在最终一致性的基础上加了一个约束:

1. 单调读(Monotonic Reads)

如果一个进程读到了 x 的某个值 v,那么该进程后续对 x 的读操作返回的值不能比 v 更旧。

违反单调读的例子:

进程 P1: read(x) -> 3    read(x) -> 1

P1 先读到 x=3,然后读到 x=1。如果 1 是比 3 更早的版本,这就违反了单调读。这种情况在客户端请求被负载均衡到不同副本时很容易出现——第一次读到的是已经收到最新写入的副本,第二次读到的是一个落后的副本。

2. 单调写(Monotonic Writes)

如果一个进程先执行了写操作 w1,然后执行了写操作 w2,那么所有副本都必须先应用 w1 再应用 w2

违反单调写的例子:假设 P1 在副本 A 上写了 x=1,然后在副本 B 上写了 x=2。如果副本 C 先收到了 x=2 再收到了 x=1,最终 x 的值变成了 1,但用户期望的是 2

3. 读己之写(Read Your Writes / Read My Writes)

一个进程写入的值,该进程后续的读操作一定能读到(或更新的值)。

违反读己之写的例子:

进程 P1: write(x, 5)    read(x) -> 3

P1 刚写了 x=5,紧接着读到 x=3(一个旧值)。这在写操作被发往主副本、读操作被路由到从副本的架构中非常常见。

这大概是用户感知最强烈的一致性违反。用户在电商网站修改了收货地址,刷新页面发现地址没变——这就是读己之写被打破。

4. 写跟随读(Writes Follow Reads)

如果一个进程先读到了某个值,然后执行了一个写操作,那么这个写操作在所有副本上的顺序都必须在那个读操作所依赖的写之后。

这个保证比前三个更微妙。它保证的是:如果你因为看到了某个值才做了某个写入决策,那么其他进程看到你的写入时,也一定能看到你当时看到的那个值。

前缀一致性(Consistent Prefix / Prefix Consistency)

前缀一致性的保证:

如果一个进程看到了某个写操作序列的某个前缀,那么这个前缀中的操作顺序和原始写入顺序一致。

换句话说,观察者不会看到”因果倒序”的情况。如果写入顺序是 A → B → C,观察者可能只看到 A,也可能看到 A→B,也可能看到 A→B→C,但不会看到 B→A 或 C→A。

一个实际的例子:社交网络上,用户 A 发了一条帖子,用户 B 回复了这条帖子。如果系统满足前缀一致性,其他用户不会看到”B 的回复存在但 A 的帖子不存在”的情况。

Azure Cosmos DB 显式地提供了 Consistent Prefix 作为一个一致性级别选项。

组合与层级

这些保证不是互斥的,可以组合。PRAM 一致性(Pipelined Random Access Memory)就是单调读 + 单调写 + 读己之写的组合。

完整的层级关系(从强到弱):

模型 约束
严格可串行化 线性一致性 ∩ 可串行化
线性一致性 实时序 + 原子性
顺序一致性 全序 + 程序序
因果一致性 尊重因果序
PRAM 单调读 + 单调写 + 读己之写
前缀一致性 写入前缀可见
最终一致性 只保证收敛

每一级都严格弱于上一级:满足上一级的操作历史一定满足下一级,反过来不成立。

下图是完整的一致性模型层级:

一致性模型层级图

图中从上到下保证递减。严格可串行化(Strict Serializability)是线性一致性和可串行化的交集,位于最顶部。最终一致性位于最底部,只保证所有副本最终收敛。中间的因果一致性是一个关键分界线——它是网络分区下仍然可用的最强模型。真实系统的映射标注在底部:Spanner 提供严格可串行化,ZooKeeper 写操作提供线性一致性,COPS 提供因果一致性,DynamoDB 默认提供最终一致性(可选强一致读),Cassandra 在 ONE 一致性级别下是最终一致性。

五、真实系统的一致性选择

理论讲完了,看看工业界的系统怎么选。

ZooKeeper:写线性一致,读顺序一致

ZooKeeper 的一致性模型经常被误解。它的写操作通过 ZAB 协议(类似 Raft)走 Leader 复制,提供线性一致性。但读操作默认从 Follower 本地读取,不经过 Leader。

这意味着 ZooKeeper 的读操作只提供顺序一致性:每个客户端看到的读结果都和某个全局序列一致,但这个序列可能滞后于最新的写入。具体来说,ZooKeeper 保证同一个客户端的读操作是单调的(不会读到更旧的值),但客户端可能读到过期数据。

如果需要线性一致读,ZooKeeper 提供 sync 操作:客户端先调用 sync 强制和 Leader 同步,然后再读。sync + read 等价于线性一致读,但代价是额外的一次 Leader 通信。

ZooKeeper 一致性模型:
┌──────────────────────────────────────┐
│ 写操作:线性一致性(通过 ZAB/Leader) │
│ 读操作:顺序一致性(本地 Follower 读)│
│ sync+read:线性一致性(强制同步)     │
└──────────────────────────────────────┘

Google Spanner:严格可串行化,靠 TrueTime 买单

Spanner 提供严格可串行化——这是最强的一致性保证。它同时满足线性一致性(单对象实时序)和可串行化(多对象事务隔离)。

代价:TrueTime 的硬件成本(每个数据中心部署原子钟和 GPS 接收器),以及 commit-wait 的延迟开销。每次写事务提交时,Spanner 需要等待 TrueTime 的不确定性窗口过去(通常 1-7 毫秒),才能确保后续事务能看到这次写入。

只读事务不需要 commit-wait,但仍然需要查询 TrueTime 来选择一个读时间戳。

Spanner 的选择对应的场景:金融交易、库存管理等对正确性要求极高、愿意为延迟和硬件成本买单的业务。

DynamoDB:默认最终一致,可选强一致读

DynamoDB 对每个读请求提供两种选择:

DynamoDB 的强一致读保证的是”读己之写 + 单调读”,在单对象语义下接近线性一致性。但 DynamoDB 不提供跨 item 的事务一致性(DynamoDB Transactions 是另一个独立的功能,使用乐观并发控制)。

实际使用中,大多数 DynamoDB 用户使用默认的最终一致读。只在对数据新鲜度敏感的场景(比如读刚写入的用户配置)才切换到强一致读。

Cassandra:可调一致性

Cassandra 的一致性最灵活。每个读写操作都可以指定一致性级别(Consistency Level):

一致性级别 含义 保证
ONE 只需一个副本确认 最终一致性
QUORUM 需要多数副本确认 强一致(读写都 QUORUM 时)
ALL 需要所有副本确认 最强,但可用性最低
LOCAL_QUORUM 本地数据中心多数副本确认 本地强一致

关键公式:如果 R + W > N(R = 读副本数,W = 写副本数,N = 总副本数),则读一定能读到最新写入的值。典型配置 N=3, R=2, W=22+2=4 > 3,读写 quorum 有交集,保证强一致。

但 Cassandra 的”强一致”和线性一致性是有区别的。Cassandra 不保证实时序——两个并发的 QUORUM 写入的最终顺序由 timestamp 决定(Last-Write-Wins),而不是由请求的实际到达顺序决定。如果两个客户端的时钟不同步,LWW 策略可能选择”物理时间更晚但因果上更早”的写入,导致数据丢失。

Cassandra LWW 的陷阱:
Client A (时钟快 2s):  write(x, "A") at T+2   ← 物理时间戳更大
Client B (时钟准):      write(x, "B") at T      ← 实际上更晚发出

结果:x = "A"(选了时间戳更大的),但 B 的写实际上更晚

系统对比总表

系统 默认一致性 最强可选 CAP 权衡 实现机制
Spanner 严格可串行化 严格可串行化 CP TrueTime + 2PC + Paxos
ZooKeeper 顺序一致性(读) 线性一致性(sync+read) CP ZAB(类 Raft)
CockroachDB 可串行化 可串行化 CP HLC + Raft + MVCC
DynamoDB 最终一致性 强一致读(单 item) AP(默认) Quorum + Leader
Cassandra 可调(ONE~ALL) Quorum(接近线性) AP 或 CP Quorum + LWW

六、Go 实现:操作历史一致性检测

下面用 Go 实现一个简化的一致性模型检测工具。给定一组对单个寄存器的读写操作历史,判断这组历史是否满足各级一致性模型。

核心思路:

package consistency

import (
    "fmt"
    "sort"
)

// Op 表示一次读或写操作
type Op struct {
    Process  int
    Type     string // "read" 或 "write"
    Key      string
    Value    int
    CallTime int64 // 调用时刻
    RetTime  int64 // 返回时刻
}

type History []Op

type CheckResult struct {
    Linearizable, SeqConsistent           bool
    ReadYourWrites, MonotonicReads        bool
    MonotonicWrites, EventualConsistent   bool
}

func Check(h History) CheckResult {
    return CheckResult{
        Linearizable:       checkLinearizability(h),
        SeqConsistent:      checkSequentialConsistency(h),
        ReadYourWrites:     checkReadYourWrites(h),
        MonotonicReads:     checkMonotonicReads(h),
        MonotonicWrites:    checkMonotonicWrites(h),
        EventualConsistent: true,
    }
}

// checkLinearizability 回溯搜索合法线性化顺序(NP-complete,适用于小规模历史)。
// 核心:每个操作的线性化点必须在 [CallTime, RetTime] 区间内,
// 且读操作必须返回当前寄存器值。
func checkLinearizability(h History) bool {
    if len(h) == 0 {
        return true
    }
    ops := make(History, len(h))
    copy(ops, h)
    sort.Slice(ops, func(i, j int) bool { return ops[i].CallTime < ops[j].CallTime })
    return linSearch(ops, make([]bool, len(ops)), 0, 0)
}

func linSearch(ops History, done []bool, count, regVal int) bool {
    if count == len(ops) {
        return true
    }
    // 剪枝:未线性化操作中最小 RetTime——CallTime 超过此值的操作不能先选
    minRet := int64(1 << 62)
    for i, op := range ops {
        if !done[i] && op.RetTime < minRet {
            minRet = op.RetTime
        }
    }
    for i, op := range ops {
        if done[i] || op.CallTime > minRet {
            continue
        }
        if op.Type == "write" {
            done[i] = true
            if linSearch(ops, done, count+1, op.Value) {
                return true
            }
            done[i] = false
        } else if op.Value == regVal {
            done[i] = true
            if linSearch(ops, done, count+1, regVal) {
                return true
            }
            done[i] = false
        }
    }
    return false
}

// checkSequentialConsistency 放松实时序,只保持进程内程序序。
func checkSequentialConsistency(h History) bool {
    if len(h) == 0 {
        return true
    }
    procOps := groupByProcess(h)
    nextIdx := make(map[int]int)
    for pid := range procOps {
        nextIdx[pid] = 0
    }
    return seqSearch(procOps, nextIdx, 0, 0, len(h))
}

func seqSearch(procOps map[int]History, nextIdx map[int]int, regVal, placed, total int) bool {
    if placed == total {
        return true
    }
    for pid, ops := range procOps {
        idx := nextIdx[pid]
        if idx >= len(ops) {
            continue
        }
        op := ops[idx]
        nextIdx[pid] = idx + 1
        if op.Type == "write" {
            if seqSearch(procOps, nextIdx, op.Value, placed+1, total) {
                return true
            }
        } else if op.Value == regVal {
            if seqSearch(procOps, nextIdx, regVal, placed+1, total) {
                return true
            }
        }
        nextIdx[pid] = idx
    }
    return false
}

// 会话保证检查——逐进程扫描,O(n) 复杂度
func checkReadYourWrites(h History) bool {
    for _, ops := range groupByProcess(h) {
        lastWrite := -1
        for _, op := range ops {
            if op.Type == "write" {
                lastWrite = op.Value
            } else if lastWrite != -1 && op.Value < lastWrite {
                return false
            }
        }
    }
    return true
}

func checkMonotonicReads(h History) bool {
    for _, ops := range groupByProcess(h) {
        lastRead := -1
        for _, op := range ops {
            if op.Type == "read" {
                if lastRead != -1 && op.Value < lastRead {
                    return false
                }
                lastRead = op.Value
            }
        }
    }
    return true
}

func checkMonotonicWrites(h History) bool {
    for _, ops := range groupByProcess(h) {
        lastT := int64(-1)
        for _, op := range ops {
            if op.Type == "write" {
                if op.CallTime <= lastT {
                    return false
                }
                lastT = op.CallTime
            }
        }
    }
    return true
}

func groupByProcess(h History) map[int]History {
    m := make(map[int]History)
    for _, op := range h {
        m[op.Process] = append(m[op.Process], op)
    }
    for pid := range m {
        sort.Slice(m[pid], func(i, j int) bool { return m[pid][i].CallTime < m[pid][j].CallTime })
    }
    return m
}

func PrintResult(r CheckResult) {
    fmt.Println("一致性检查结果:")
    p := func(name string, ok bool) {
        s := "PASS"
        if !ok { s = "FAIL" }
        fmt.Printf("  %-36s %s\n", name, s)
    }
    p("线性一致性(Linearizability)", r.Linearizable)
    p("顺序一致性(Sequential)", r.SeqConsistent)
    p("读己之写(Read Your Writes)", r.ReadYourWrites)
    p("单调读(Monotonic Reads)", r.MonotonicReads)
    p("单调写(Monotonic Writes)", r.MonotonicWrites)
    p("最终一致性(Eventual)", r.EventualConsistent)
}

使用示例:构造一组操作历史并检测。

package main

import (
    "consistency"
    "fmt"
)

func main() {
    // 场景 1:合法的线性一致历史
    h1 := consistency.History{
        {Process: 1, Type: "write", Key: "x", Value: 1, CallTime: 0, RetTime: 10},
        {Process: 2, Type: "read", Key: "x", Value: 1, CallTime: 12, RetTime: 20},
    }
    fmt.Println("=== 场景 1:简单的读写 ===")
    consistency.PrintResult(consistency.Check(h1))

    // 场景 2:违反线性一致性但满足顺序一致性
    h2 := consistency.History{
        {Process: 1, Type: "write", Key: "x", Value: 1, CallTime: 0, RetTime: 10},
        {Process: 2, Type: "write", Key: "x", Value: 2, CallTime: 12, RetTime: 20},
        {Process: 3, Type: "read", Key: "x", Value: 1, CallTime: 22, RetTime: 30},
    }
    fmt.Println("\n=== 场景 2:stale read ===")
    consistency.PrintResult(consistency.Check(h2))

    // 场景 3:违反单调读
    h3 := consistency.History{
        {Process: 1, Type: "write", Key: "x", Value: 5, CallTime: 0, RetTime: 5},
        {Process: 2, Type: "read", Key: "x", Value: 5, CallTime: 6, RetTime: 10},
        {Process: 2, Type: "read", Key: "x", Value: 3, CallTime: 11, RetTime: 15},
    }
    fmt.Println("\n=== 场景 3:单调读违反 ===")
    consistency.PrintResult(consistency.Check(h3))

    // 场景 4:违反读己之写
    h4 := consistency.History{
        {Process: 1, Type: "write", Key: "x", Value: 10, CallTime: 0, RetTime: 5},
        {Process: 1, Type: "read", Key: "x", Value: 3, CallTime: 6, RetTime: 10},
    }
    fmt.Println("\n=== 场景 4:读己之写违反 ===")
    consistency.PrintResult(consistency.Check(h4))
}
=== 场景 1:简单的读写 ===
一致性检查结果:
  线性一致性(Linearizability):    PASS
  顺序一致性(Sequential):         PASS
  读己之写(Read Your Writes):     PASS
  单调读(Monotonic Reads):        PASS
  单调写(Monotonic Writes):       PASS
  最终一致性(Eventual):           PASS

=== 场景 2:stale read ===
一致性检查结果:
  线性一致性(Linearizability):    FAIL
  顺序一致性(Sequential):         PASS
  读己之写(Read Your Writes):     PASS
  单调读(Monotonic Reads):        PASS
  单调写(Monotonic Writes):       PASS
  最终一致性(Eventual):           PASS

=== 场景 3:单调读违反 ===
一致性检查结果:
  线性一致性(Linearizability):    FAIL
  顺序一致性(Sequential):         FAIL
  读己之写(Read Your Writes):     PASS
  单调读(Monotonic Reads):        FAIL
  单调写(Monotonic Writes):       PASS
  最终一致性(Eventual):           PASS

=== 场景 4:读己之写违反 ===
一致性检查结果:
  线性一致性(Linearizability):    FAIL
  顺序一致性(Sequential):         FAIL
  读己之写(Read Your Writes):     FAIL
  单调读(Monotonic Reads):        PASS
  单调写(Monotonic Writes):       PASS
  最终一致性(Eventual):           PASS

几个要点说明:

  1. 线性一致性检查是 NP 完全的(Gibbons & Korach, 1997)。上面的实现使用回溯搜索加简单剪枝,适用于小规模历史(几十个操作)。工业级工具(Porcupine、Knossos)使用更高效的算法和剪枝策略。

  2. 场景 2 的解读write(x,1) 在 t=0-10 完成,write(x,2) 在 t=12-20 完成,read(x)->1 在 t=22-30。线性一致性要求 write(x,2) 排在 read(x) 之前(因为实时序如此),但 read 读到 1 意味着 write(x,1) 是最后一个写——这和 write(x,2) 的实时序矛盾。顺序一致性不要求实时序,所以可以构造全序 write(x,2)write(x,1)read(x)->1,合法。

  3. 简化假设:上面的代码假设单寄存器、整数值、值的大小关系等价于版本的新旧关系。真实系统中需要版本号或向量时钟来判断版本的新旧。

七、工程取舍与常见误区

误区一:“强一致性 = 慢”

线性一致性确实需要多数派通信,但”慢”是相对的。在同一个数据中心内,多数派通信的额外延迟通常在毫秒级甚至亚毫秒级。etcd 提供线性一致读,P99 延迟在几毫秒。

真正的性能瓶颈是跨数据中心场景。在北京到上海的延迟(约 10ms)下,每次线性一致读都需要 Leader 通信,P50 延迟就到了 10ms 以上。这时候如果业务能接受顺序一致性或因果一致性,性能提升是数量级的。

误区二:“最终一致性 = 不需要操心”

最终一致性只保证”最终收敛”,对收敛之前的行为没有约束。真正的问题是:

使用最终一致性的系统,通常需要在应用层补偿:幂等操作、版本冲突检测、补偿事务。这些补偿逻辑的复杂度不比实现强一致性低。

误区三:混淆一致性模型和隔离级别

一致性模型(Linearizability、Causal Consistency 等)和数据库隔离级别(Read Committed、Repeatable Read、Serializable 等)是不同的概念体系:

但它们有交叉。严格可串行化就是线性一致性和可串行化的结合。在分布式数据库中,两套概念经常同时出现,必须分清楚你在谈哪个。

怎么选

没有通用答案,但有一个决策框架:

  1. 先确定业务对”过期读”的容忍度。 如果任何过期读都不可接受(金融交易、库存扣减),选线性一致性或严格可串行化。
  2. 再确定跨数据中心的需求。 如果需要跨数据中心低延迟写入,因果一致性或最终一致性可能是唯一选项。线性一致性在跨数据中心场景下延迟很高。
  3. 最后确定你能承受的应用层复杂度。 一致性越弱,应用层需要处理的边界情况越多。如果团队对分布式系统的经验不足,选一个稍强的一致性模型,把复杂度下沉到存储层,比在应用层手动补偿要可靠得多。

八、小结

一致性模型不是”强”和”弱”两个端点,而是一条光谱。这条光谱上每个位置都有明确的形式化定义、明确的保证和代价。

选择哪种模型不是技术偏好问题,而是业务需求、基础设施条件和工程团队能力的权衡。搞清楚每级模型到底保证了什么、不保证什么,是做出正确选择的前提。

参考资料

论文

工具

系统文档


上一篇:混合逻辑时钟 | 下一篇:会话保证

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · distributed

【分布式系统百科】会话保证与因果一致性:用户视角的一致性

最终一致性承诺'最终'收敛,但没说收敛之前用户会看到什么。你改了头像刷新后消失、余额先涨后跌、回复比原帖先出现——这些都是缺少会话保证的症状。Terry 等人在 1994 年定义了四种会话保证,COPS 和 Eiger 把因果一致性做到了跨数据中心,Bailis 的 Bolt-on 方案让老系统也能补上因果语义。

2026-04-13 · distributed

【分布式系统百科】成员协议:SWIM 与 Gossip 的工程实现

从 Gossip 协议的 SI 传播模型出发,深入拆解 SWIM 故障检测协议的直接探测、间接探测和怀疑机制,分析 HashiCorp Memberlist 的源码实现,对比 Serf 与 Consul 的成员管理策略,并提供基于 Memberlist 构建集群成员管理的完整 Go 代码示例。


By .