Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4 一致性模型:线性/顺序/最终

本章回答

一次读到底允许返回哪个版本?「强一致」能不能写成一个可判定的条件?线性一致、顺序一致、因果一致、最终一致分别在实时序、全序、收敛速度上做了什么让步?分区发生时 CAP 到底限制了哪一件事,为什么说「三选二」是误读?

读完你会

能用 invocation/response 记录一段 history,并手工判断它是否线性一致;能写出一个可运行的小型历史检查器;能区分副本一致性、事务隔离和 CAP 的三个维度;能用 etcdctl get --consistency=l/s 看到陈旧读;能解释 Jepsen 为什么在 majority 写之下仍然让 MongoDB 丢数据;能为一组业务选出可辩护的一致性方案。

第 3 章把同一份数据放到了多个副本上。副本解决了耐久性和读扩展,却留下一个新问题:当一次读到达某个副本时,它允许看到哪个版本? 第 3 章只回答了「写去哪里、怎么复制」,没有回答「读返回什么才算对」。这一章补上这个判定标准。

先看一个真实形状的事故。账户 A 的余额是 100,用户发起一笔 100 元的转账。主库在 09:41:02.140 提交,A 变成 0,响应在回程丢失。客户端在 800 ms 后超时,自动重试。重试请求落到一个尚未应用这笔写的只读副本,读到 A 还是 100,余额检查通过,于是又扣了一次。09:41:05,A 的余额是 -100,收款方收到 200。这里没有哪台机器宕机,也没有哪段代码「写错」:主库提交了,副本只是还没追上,重试只是「再试一次」。事故的根源是系统对「读到的 100 意味着什么」没有定义。

「强一致」「最终一致」这些词在日常讨论里几乎无法证伪:每个人都能找到自己满意的解释。本章换一种写法:先记录一段 history,再给出一个判定条件,最后用检查器跑一遍。一个模型只有能说清「哪段 history 违反它」,才值得写进设计文档。

路线是这样:4.1 建立 history、实时序和三种操作的词汇;4.2–4.5 依次给出线性一致、顺序一致、因果/会话保证、最终一致的定义、反例和代价;4.6 把 CAP 还原成 Gilbert–Lynch 的精确版本,并补上 PACELC;4.7 把副本一致性和事务隔离级别拆成两个维度;4.8–4.11 用分区陈旧读、etcd 观测、Jepsen 案例和二次转账把理论落到故障上;4.12 给出业务选型表,4.13–4.14 收束成陷阱清单与出口检查。读完这一章,你应该能指着一张时序图说:「这里违反的是线性一致,不是隔离级别。」

4.1 历史、实时序与三种操作

一致性模型不是对「数据库性格」的描述,而是对一段可观测历史的约束。先把历史写成表。下面是一段单寄存器 x 上的六个操作,时间单位是毫秒,inv 是客户端发出调用的时刻,resp 是客户端收到响应的时刻:

#进程操作invresp返回
1P1write(x, 1)010ok
2P2read(x)481
3P1write(x, 2)1216ok
4P2read(x)18222
5P3cas(x, 2, 3)2025true
6P3cas(x, 2, 4)2630false

这张表里已经藏着几个容易读错的点:

  • 操作 1 和操作 2 的时间区间重叠(4 < 10),它们是并发的。并发不等于「同时执行」,只表示一个操作返回之前另一个已经发起。
  • 操作 3 在操作 2 返回之后才发起(8 < 12),所以操作 2 在操作 3 之前。这是实时序,不是「谁的时间戳小」。
  • 操作 5 和操作 6 都来自 P3,且不重叠;操作 6 的 CAS 期望值是 2,但操作 5 已把 x 改成 3,所以返回 false 是合法的。
  • 操作 4 读到了 2,发生在操作 3 返回之后,符合直觉;如果它读到 1,这段历史就有问题了。

把同样的信息写成 JSON Lines,方便程序读取:

{"proc":"P1","op":"write","key":"x","value":1,"inv":0,"resp":10,"ret":"ok"}
{"proc":"P2","op":"read","key":"x","inv":4,"resp":8,"ret":1}
{"proc":"P1","op":"write","key":"x","value":2,"inv":12,"resp":16,"ret":"ok"}
{"proc":"P2","op":"read","key":"x","inv":18,"resp":22,"ret":2}
{"proc":"P3","op":"cas","key":"x","expected":2,"value":3,"inv":20,"resp":25,"ret":true}
{"proc":"P3","op":"cas","key":"x","expected":2,"value":4,"inv":26,"resp":30,"ret":false}

这六条记录对应三种最小操作。很多教材只讲读和写,但检查真实系统时 CAS 不可少:只用读/写建模,「读-改-写」会被拆成两个独立操作,丢失更新就看不见了;CAS 把「比较并替换」建模成一个原子操作,才能表达「只有版本仍是 7 才扣款」这类语义。

操作调用返回单寄存器顺序规范
read(k)键 k值 v 或 not_found返回此前最后一次写(或 CAS 成功)写入的值
write(k, v)键 k、新值 vok把 k 设为 v,总是成功
cas(k, e, n)键 k、期望值 e、新值 ntrue / false当前值等于 e 时设为 n 并返回 true;否则不改并返回 false
定义 4.1.1 · 历史与实时序

操作(operation)是一个五元组 (proc, kind, key, arg, ret),外加两个时刻:inv(invocation,客户端发起调用的时间)与 resp(response,客户端收到响应的时间),且 inv < resp。历史(history)是一组已完成操作及其 inv/resp 的集合。

实时序(real-time order)<rt 定义为:a <rt b 当且仅当 a.resp < b.inv。若两个操作的时间区间重叠(a.inv < b.resp 且 b.inv < a.resp),称它们并发(concurrent),实时序不规定二者的先后。

没有 resp 的操作称为待定操作(pending)。线性一致性是非阻塞的:待定操作可以被丢弃,也可以被补全为任意结果;它不能反过来「卡住」其他操作。

为什么一定要用客户端观测到的 inv/resp,而不是服务端日志里的提交时间?因为一致性模型约束的是客户端能观察到什么。服务端在 t=5 提交,客户端在 t=9 才收到响应;另一个客户端的读在 t=7 发起。若用服务端提交时间排序,会错误地认为写已经在读之前完成;实际上读的调用早于写的响应,二者并发,读返回旧值完全合法。反过来,如果服务端在 t=9 提交,客户端 t=10 收到响应,另一个读在 t=11 发起却返回旧值,这才是真的违反线性一致。提交时间在操作区间内部,响应时间才是区间右端。

记录历史时有四个坑:

  1. 不要用墙上时钟跨机器比较。time.time() 会被 NTP 调整,两台机器的偏差可能是毫秒,也可能是秒。用 time.monotonic_ns() 在单机记录;跨客户端时先测偏差,只有偏差远小于操作间隔才敢比较。
  2. 不要只记录服务端的开始/结束。服务端处理时间只是区间的一部分,无法替代客户端的 resp。
  3. 不要丢掉失败的 CAS。false 也是一次合法的返回;把它当「没发生」会掩盖顺序错误。
  4. 不要把 pending 操作当成功。超时后服务端可能已提交;检查器应允许把它补全或删除,而不是强行赋一个结果。

真实系统通常还会在响应里带一个版本号(revision、applied_index、LSN)。记录历史时一并保存,后面判断「读到的是不是旧版本」会容易得多:

09:41:02.140  write A=80   rev=58  resp=ok
09:41:02.221  read  A=100  rev=57  applied=57   <- 比写返回的 rev 旧

这两行不需要复杂的理论,已经能说明一次陈旧读。4.2 要做的,是把「rev 旧」升级成「整段历史是否可能存在一个合法全序」。

4.1.1 从单对象到多对象:先确定「被检查的对象」

4.2 节的检查器只处理一个 key。真实系统里还有多 key 事务、跨行约束和「先读 A 再读 B」的业务逻辑。把它们塞进单对象模型,会得到两种相反的错误。

第一种错误是把一个事务拆成若干个单 key 操作,分别检查线性一致,然后宣布「每个 key 都线性一致,所以系统一致」。4.7 节会给出反例:每个 key 的读都合法,拼起来却不是任何时刻的快照。事务的原子性不是「每个 key 各自原子」,而是「整组操作在同一时刻可见」。

第二种错误是把事务内部读到的中间值当成客户端可见的操作。事务在提交前读到的值,外部客户端看不到;把它放进 history 会增加不必要的实时序约束,甚至把正确实现误判为错误。

层次被检查的对象合适的模型例子
单 key一个寄存器线性一致/顺序一致/最终一致余额、配置项
多 key 原子操作一个事务/批量 CAS串行化、严格串行化转账、库存扣减
会话一个客户端的多次操作读己之写、单调读个人资料、购物车

所以动手判定前先问:这段 history 的操作粒度是什么? 如果业务不变量跨多个 key,单 key 检查器只能证明「每个 key 没退步」,不能证明「业务不变量没被破坏」。本章后面的检查器明确只做单 key;跨 key 的检查需要把事务当成一个复合操作,或者使用快照/串行化检查器(第 6 章)。

4.2 线性一致性:定义与判定

线性一致(linearizability,也叫原子一致、atomic consistency)的直觉是:每个操作看起来都在它的调用与响应之间某个瞬间原子地生效,整个系统像只有一个副本。 直觉很好用,但判断事故时不够精确。精确版本需要存在一个全序。

定义 4.2.1 · 线性一致

历史 H 是线性一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:

  1. 扩展实时序:若 a <rt b,则 a 在 S 中排在 b 之前;
  2. 合法:从初始状态出发,按 S 依次执行每个操作,每个操作的返回值都符合该对象的顺序规范(对寄存器而言:读返回此前最后一次写的值;CAS 的返回值与实际状态一致)。

等价说法:每个操作都存在一个线性化点(linearization point),落在它的 [inv, resp] 区间内;所有操作按线性化点排序后,结果与顺序规范一致。

线性一致:读必须看到已返回的写 写 x=1 在 t1 返回;t2 之后开始的读若返回 0,则违反线性一致。 线性一致:读必须看到已返回的写 客户端 A 客户端 B write x=1 t1 写返回 read x ? t2 读开始 t2 > t1 ⇒ 读必须返回 1(或更新的值) 返回 0 = stale read = 违反线性一致 返回 timeout 只是 unknown,不构成反例
读图:线性一致的判定不是看「有没有复制」,而是看能否找到一个全序,既尊重每个操作的实时区间,又让读返回该全序中最近一次写的值。图中下半部分给出反例。

图里只画了一个写和一个读,但已经包含判定所需的全部信息:写 x=1 在 t1 返回,读在 t2 > t1 才开始,读必须返回 1(或更新的值)。读返回 0 就是陈旧读,违反线性一致;读超时只是 unknown,不构成反例,因为待定操作可以被丢弃。

并发允许两种结果。 如果读在写返回之前就发起了(区间重叠),读返回 0 或 1 都可能是线性一致的:把读的线性化点放在写之前,它看到 0;放在写之后,它看到 1。很多人把「线性一致」误读成「读必须立刻看到正在进行的写」,这是错的。线性一致只要求存在某一个合法的全序,不要求读偏向新值。

反例的最小结构是「写已返回,读才开始,却读到旧值」:

历史写区间读区间读返回判定
Awrite(x,1) [0,10]read(x) [12,20]1线性一致
Bwrite(x,1) [0,10]read(x) [11,20]0违反:读开始时写已返回
Cwrite(x,1) [0,10]read(x) [5,15]0线性一致:可把读排在写前
Dread(x) [0,5] 返回 2;read(x) [6,10] 返回 1,期间无写——违反:读出现回退

历史 D 是另一个常见形状:没有写,读却从 2 退到 1。线性一致要求所有操作共享一个全序,读只能沿着这个全序前进,不可能倒退。「单次读都合法」不等于「整段历史线性一致」,这是 4.8 节陈旧读的核心。

4.2.1 判定:枚举所有尊重实时序的全序

有限历史可以暴力判定。思路是深度优先搜索所有「线性扩展」:每一步从尚未放置、且其所有实时序前驱都已放置的操作中选一个,应用到一个寄存器状态上;如果操作是读,读到的值必须等于当前状态;如果是 CAS,返回值必须与当前状态一致。只要找到一条走完全部操作的路径,历史就是线性一致的。

下面是一个可运行的单寄存器检查器。它接受 Op 列表,返回 (是否线性一致, 见证全序或 None):

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Op:
    proc: str
    kind: str          # "write" | "read" | "cas"
    key: str
    arg: object        # write: 新值;read: None;cas: (期望值, 新值)
    ret: object        # write: "ok";read: 读到的值;cas: True/False
    inv: int           # invocation 时间
    resp: int          # response 时间


def is_linearizable(history, initial=None):
    """单寄存器检查器:返回 (是否线性一致, 见证全序或 None)。"""
    ops = list(history)
    n = len(ops)
    # preds[j] 中的操作必须在 j 之前:a.resp < b.inv 即 a <rt b
    preds = [
        {i for i, a in enumerate(ops) if i != j and a.resp < b.inv}
        for j, b in enumerate(ops)
    ]
    placed = [False] * n
    order = []

    def apply(state, op):
        s = dict(state)
        if op.kind == "write":
            s[op.key] = op.arg
            return s, True
        if op.kind == "read":
            return s, s.get(op.key) == op.ret
        if op.kind == "cas":
            expected, new = op.arg
            if s.get(op.key) == expected:
                s[op.key] = new
                return s, op.ret is True
            return s, op.ret is False
        raise ValueError(op.kind)

    def dfs(state, done):
        if done == n:
            return True
        for i, op in enumerate(ops):
            if placed[i] or any(not placed[p] for p in preds[i]):
                continue
            nxt, ok = apply(state, op)
            if not ok:
                continue
            placed[i] = True
            order.append(i)
            if dfs(nxt, done + 1):
                return True
            order.pop()
            placed[i] = False
        return False

    ok = dfs(dict(initial or {}), 0)
    return ok, ([ops[i] for i in order] if ok else None)


def show(name, history):
    ok, order = is_linearizable(history, initial={"x": 0})
    print(f"{name:8s} linearizable={ok}")
    if ok:
        print("         witness: " + " -> ".join(
            f"{o.proc}.{o.kind}({o.ret})" for o in order))


H_OK = [
    Op("A", "write", "x", 1, "ok", 0, 10),
    Op("B", "read", "x", None, 1, 12, 20),
]
H_STALE = [
    Op("A", "write", "x", 1, "ok", 0, 10),
    Op("B", "read", "x", None, 0, 11, 20),
]
H_SC = [  # 顺序一致,但不线性一致;4.3 节解释
    Op("A", "write", "x", 1, "ok", 0, 5),
    Op("B", "write", "x", 2, "ok", 6, 10),
    Op("C", "read", "x", None, 1, 11, 15),
    Op("C", "read", "x", None, 2, 16, 20),
]
H_CAS = [
    Op("A", "write", "x", 1, "ok", 0, 5),
    Op("A", "cas", "x", (1, 2), True, 6, 10),
    Op("B", "read", "x", None, 2, 11, 15),
]

for name, h in [("H_OK", H_OK), ("H_STALE", H_STALE), ("H_SC", H_SC), ("H_CAS", H_CAS)]:
    show(name, h)

把它保存为 linearizability.py 并运行,输出是:

H_OK     linearizable=True
         witness: A.write(ok) -> B.read(1)
H_STALE  linearizable=False
H_SC     linearizable=False
H_CAS    linearizable=True
         witness: A.write(ok) -> A.cas(True) -> B.read(2)

四点读法:

  1. H_OK 的读在写返回之后开始,见证全序只有一种合法排列。
  2. H_STALE 的读返回 0,但写已完成;任何全序都必须把写放在读前,读应返回 1,所以搜索失败。
  3. H_SC 在 4.3 节会看到它顺序一致;检查器返回 False 只说明「不线性一致」,不说明「不一致」。判定器回答的是某个模型,不是笼统的『一致/不一致』。
  4. H_CAS 说明 CAS 成功会改变状态,后续读必须看到 2;CAS 不是「读一下再写」,而是一个原子操作。

4.2.2 复杂度与工程判定

暴力搜索最坏是 n!,而判定线性一致性在一般情况下是 NP 完全的(Wing 与 Gong, 1993)。真实历史动辄百万操作,不能直接套上面的代码。工程上有四类优化:

优化做法代价
按对象分片每个 key 独立检查;线性一致是局部性质跨对象事务另算
约束传播读的值只能来自某个写;先固定候选写,再查实时序实现复杂
记忆化对 (已放置集合, 状态) 去重;同一状态不重复搜索状态爆炸时仍慢
分窗把长历史切成小窗口,分别在每个窗口内检查可能漏掉跨窗反例

Jepsen 的 Knossos 用的是这类搜索加剪枝;它报告的是一个具体反例(哪几个操作无法排列),而不是一个概率。工程上更实用的做法是:先用 session 保证、版本号和业务不变量做粗筛,再把可疑窗口喂给检查器。检查器不是证明系统正确,它只能证明「这段有限历史存在/不存在合法全序」。没有反例不等于正确,只等于这段历史没暴露问题。

定理 4.2.1 · 线性一致的可组合性

若系统中每个对象的历史都线性一致,且对象之间没有跨对象的原子约束,则整个系统的历史线性一致。证明思路:把各对象的全序与实时序取并集,做拓扑排序;不同对象的操作可交换,冲突只可能在同一对象内,而同一对象已有合法全序(完整证明见 Herlihy 与 Wing, 1990)。

顺序一致没有这个性质:两个各自顺序一致的对象,合起来可能不满足顺序一致。这也是分片系统敢说「每分片线性一致」的理论依据,但它不保证跨分片快照一致(4.7 节)。

4.2.3 边界:线性一致不保证什么

线性一致只约束单对象的读/写/CAS 历史,不自动提供下面这些东西:

  • 跨对象的原子读。分别线性一致地读 A 和 B,合起来可能不是某个时刻的快照。
  • 事务的隔离。两个线性一致的写仍可能丢失更新,如果它们是「读-改-写」而不是 CAS。
  • 低延迟。线性一致读通常要一次 leader/quorum 往返;跨地域时这往返就是几十毫秒。
  • 分区期间的可用性。4.6 节会证明,分区时线性一致和可用性不能同时成立。
  • 会话保证的跨副本版本。线性一致对单对象已经蕴含读己之写和单调读(同一客户端的操作在实时序上不重叠);但如果你用的是「每个副本各自线性一致」,把多个副本的读拼起来,会话仍可能回退(4.8 节)。

4.2.4 一次搜索的回溯记录

把 H_STALE 的搜索过程写出来,比看代码更容易理解「为什么不存在全序」:

步骤可选操作当前状态说明
1write(x,1)x=1读的实时序前驱只有写,必须先放写
2read(x)=0x=1读要求当前值为 0,失败;无其他候选
———回溯到步骤 1,仍无其他候选,返回 False

H_SC 的回溯更能说明「实时序」和「程序序」的差别:

步骤可选操作当前状态说明
1write(x,1)x=1B 的写有实时序前驱 A 的写
2write(x,2)x=2C 的第一次读在 B 写返回之后,B 必须先放
3read(x)=1x=2读要求 1,失败;没有别的候选
———回溯,穷尽后返回 False

如果只保留程序序、去掉实时序,H_SC 就能找到 4.3 节给出的见证全序。检查器的差别不在搜索算法,而在 preds 里放了哪些边。 把 a.resp < b.inv 换成「同一进程的前一个操作」,得到的就是顺序一致检查器;这正是 4.3 节的形式差别。

成功时返回的 witness 是一个证书:它给出每个操作在线性化顺序中的位置。失败时返回 False 只对这段有限历史成立;换一段更长的历史、加上没记录到的 pending 操作,结论可能改变。判定器的输出要连同 history 一起存档,否则无法复核。

4.3 顺序一致性:差在哪

顺序一致(sequential consistency,SC)是 Lamport 1979 年提出的模型。它保留了「所有操作存在一个全序」,但删掉了「全序必须尊重实时序」这一条。

定义 4.3.1 · 顺序一致

历史 H 是顺序一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:

  1. 尊重程序序:若在同一进程中 a 先于 b 被调用,则 a 在 S 中排在 b 之前;
  2. 合法:按 S 执行时,每个操作的返回值符合顺序规范。

注意:S 不需要尊重跨进程的实时序。一个在实时上早已返回的写,可以被排在一个后来才发起的读之后。

最小的「顺序一致但不线性一致」历史就是上面的 H_SC:

进程操作区间返回
Awrite(x,1)[0,5]ok
Bwrite(x,2)[6,10]ok
Cread(x)[11,15]1
Cread(x)[16,20]2

C 的两次读有明确的程序序:先读到 1,再读到 2。顺序一致的见证全序是:

write(x,1) -> read(x)=1 -> write(x,2) -> read(x)=2

这个全序尊重每个进程的程序序(A 只有写 1,B 只有写 2,C 的两次读顺序不变),也满足寄存器语义:第一次读之前最后写的是 1,第二次读之前最后写的是 2。它唯一的问题是:B 的写 write(x,2) 在 [6,10] 已经返回,而 C 的第一次读在 11 才开始,实时序要求写 2 排在读 1 之前;一旦如此,读 1 就不可能返回 1。 所以这段历史顺序一致,但不线性一致。

实时轴 0     5  6    10 11   15 16   20
A      [W=1]
B              [W=2]
C                        [R=1]  [R=2]
                          ↑
                  W=2 已返回,R=1 才开始;
                  SC 可以把 W=2 排到 R=1 之后,
                  线性一致不允许。

两者的差别可以压成一张表:

性质线性一致顺序一致
全序需要需要
尊重实时序需要不需要
尊重进程程序序单线程客户端下由实时序蕴含明确要求
可组合性是(按对象)否
直观承诺读能看到「已返回」的写所有客户端同意的写顺序,但可能滞后于现实
典型实现leader/quorum 读、ReadIndex、共识单点序列化、全序广播、部分主从读
分区时少数派不能提供线性一致读/写可能返回陈旧读;单个副本的局部历史可以是 SC,但全局 SC 仍需额外协调

两个常见误解需要当场拆掉。

误解一:顺序一致比线性一致「便宜很多」。 顺序一致仍然要求一个全局全序,通常仍要单点序列化或共识;它省掉的是「等待实时确认」,不是「协调所有操作」。一个系统如果连全局日志都没有,往往连顺序一致也给不了,只能给因果或最终一致。

误解二:顺序一致就是「串行化」。 串行化(serializability)是事务层面的性质:并发事务的结果等价于某个串行执行顺序;顺序一致是单对象读写的可见性顺序。两者正交,4.7 节会展开。把「sequential consistency」翻成「顺序一致性」时,尤其容易和数据库的「串行隔离」混在一起。

4.3.1 顺序一致也不是免费的

一个常见的实现是「单点序列器 + 全序广播」:所有写提交给序列器,序列器分配一个递增序号并广播;每个副本按序号应用。读可以在本地按已应用的前缀回答,不必等序列器确认。这样得到的全局顺序尊重每个客户端的程序序(客户端按序号提交),但读可能被排在一个已经返回的写之前,所以是 SC,不一定是线性一致。为了让同一客户端的读不退步,读还要固定副本或带上「已见前缀」令牌;否则第二个读打到更落后的副本,程序序就被破坏了。

实现全序来源读路径得到什么代价
单点序列器 + 全序广播序列器分配序号本地前缀顺序一致序列器是瓶颈/单点
主库串行日志主库日志顺序本地已应用顺序一致(若读不确认)主库故障切换复杂
每写都等确认主库/quorumleader 确认线性一致每次读一次往返
无全序,仅反熵无任意副本最终一致冲突合并

注意第三行:「顺序一致」和「线性一致」的差别往往就是读要不要等确认。 等确认就把读放进了实时序,不等就只保证「存在某个全序」。这也解释了为什么很多系统把「强读」做成一个显式选项:默认本地读是 SC/陈旧读,关键读才付一次往返。

顺序一致的另一个代价是全序本身。没有全局日志、没有序列器、没有共识,就没有 SC;多个副本各自按本地顺序应用,得到的是因果或最终一致。SC 不是「不协调的强一致」,它只是「不要求实时确认的强顺序」。

4.4 因果一致性与会话保证

线性一致和顺序一致都要求全序,代价是协调。很多系统退一步:只要求有因果关系的操作被所有副本以相同顺序看到,并发操作可以各排各的。 这就是因果一致(causal consistency)。

先看它要防的那个经典反例。论坛里,Alice 发帖 P,Bob 读到 P 后回复 R,Carol 读到了 R 却读不到 P:

步骤进程操作观察到
1Alicewrite(post, P)ok
2Bobread(post)P
3Bobwrite(reply, R)ok
4Carolread(reply)R
5Carolread(post)not_found

Carol 看到了回复却看不到被回复的帖子,这是「因果倒置」。因果一致禁止的正是这种形状;它不禁止 Carol 在 Bob 回复之前看不到 P(那时 P 和 R 并发),也不要求 Carol 看到 P 的那一刻 Bob 的回复已经存在。

定义 4.4.1 · happens-before 与因果一致

happens-before(→)是满足以下规则的最小关系:

  1. 程序序:同一进程中先发起的操作 → 后发起的操作;
  2. 读到即因果:若读 r 读到了写 w 的值,则 w → r;
  3. 传递:若 a → b 且 b → c,则 a → c。

两个操作若既不 a → b 也不 b → a,称并发。

历史是因果一致的,当且仅当每个进程观察到的写集合是因果闭的(看到 w2 就包含所有 w1 → w2 的 w1),且其观察顺序是因果序(→ 的传递闭包)的一个线性扩展;不同进程可以对并发写选择不同的扩展。等价地:若 w1 → w2,任何进程看到 w2 时,w1 已经在它的视图中排在 w2 之前。

因果一致比线性一致弱在哪?它不要求实时序,也不要求全局唯一顺序。Alice 发帖后,Carol 可以过一会儿才看到;Bob 和 Dave 对两条并发评论的先后可以不同。它比最终一致强在哪?它禁止「回复先于原帖」这种不可解释的顺序,而最终一致允许副本在收敛前任意乱序。

因果一致在工程上常和「会话保证」一起出现。因果一致是跨客户端的性质,实现成本不低(要传播依赖关系,例如向量时钟);会话保证是单客户端的性质,实现便宜得多,但只保护这个客户端自己。复制 lag 的成因和读己之写的工程做法在 第 3 章 已经出现过;本节换一个角度,把会话保证当成一组可判定的承诺来检查。

会话保证承诺不满足时的典型事故常见实现
读己之写(read-your-writes)本客户端写返回后,后续读能看到该写或更新值用户改完资料,刷新还是旧头像写后读主库;会话 token 带版本;粘性会话
单调读(monotonic reads)同一客户端连续读不会回退到更旧版本余额先显示 100,再显示 80,再显示 100会话固定副本;记录最大 revision,只接受 ≥ 它的副本
单调写(monotonic writes)同一客户端的写按程序序生效先改名后改头像,副本先应用头像再应用改名单写者队列;按客户端序号去重排序
写后读(writes-follow-reads)读到 v 之后的写,排在生产 v 的写之后回复排到了原帖之前读时带因果依赖;写时携带依赖版本

一个最小实现是给会话发一个 token,里面放「本会话最后一次写产生的 applied index」;读副本只有追到这个 index 才允许返回:

import time


def read_your_writes(replicas, key, token, timeout_ms=200):
    """token 是写返回时拿到的 applied_index。"""
    deadline = time.monotonic() + timeout_ms / 1000
    while time.monotonic() < deadline:
        for replica in replicas:
            value, applied = replica.get_with_version(key)
            if applied >= token:          # 该副本已经包含我的写
                return value
        time.sleep(0.005)
    raise TimeoutError("no replica caught up; fall back to leader")

这段代码的关键不是循环,而是 applied >= token 这个条件。没有它,replicas 列表里的任何一个副本都可能返回旧值;有了它,最坏情况是等待或回退到主库,而不是悄悄破坏读己之写。get_with_version 需要副本暴露「已应用的日志位置」,这也是为什么第 3 章强调复制日志索引:没有版本,就没有会话保证的锚点。

4.4.1 向量时钟:把因果依赖带在请求上

会话 token 是一个标量,只能表达「我这个客户端见过哪些写」。跨客户端的因果需要更完整的依赖:Bob 的回复依赖 Alice 的帖子,Carol 的副本必须先应用 Alice 的写,才能应用 Bob 的回复。向量时钟(vector clock)就是做这件事的。

每个副本维护一个 {node: counter};本地事件递增自己的计数器;发送消息时带上整个向量;接收方逐项取最大值。比较规则:

关系条件含义
V1 <= V2每个分量都 ≤V1 不晚于 V2;相等表示同一因果状态
V1 < V2V1 <= V2 且至少一个分量更小V1 严格早于 V2
并发两个方向都不 ≤需要冲突解决

一个最小判断函数:

def happens_before(a, b):
    keys = set(a) | set(b)
    return all(a.get(k, 0) <= b.get(k, 0) for k in keys) and a != b

Bob 读完 Alice 的帖子后,写回复时携带 {A:1, B:1};Carol 的副本如果只有 {B:1},就不能应用这条回复,要先等 A:1 到达。这就是「因果交付」(causal delivery)。代价是向量随写入者数量增长;工程上用依赖集合、版本向量压缩或混合逻辑时钟来控制大小。

向量时钟只解决因果顺序,不解决实时序:Alice 的帖子可能很久以后才到 Carol,只要顺序对,因果一致就满足。要实时,仍然需要线性一致读/写。

因果一致和会话保证都不能免费升级成线性一致:

  • 读己之写只保护「自己写的」,别人写的最新值仍可能看不到;
  • 单调读只保证不回退,不保证看到最新;
  • 因果一致不保证实时,Alice 的帖子可能几秒后才对 Carol 可见;
  • 所有这些都不提供跨对象的原子快照。

它们适合什么?社交动态、评论、购物车、用户资料这类「顺序错了很怪,但晚一点没关系」的场景。余额、库存、配置开关这类「错了要赔钱」的场景,仍然要更强的模型或原子写。

4.5 最终一致性:收敛的条件

最终一致(eventual consistency)是最容易被口号化的模型。它的正式承诺其实很短:如果不再有新的写,所有副本最终会返回同一个值。 注意三个词:最终(没有时间上界)、所有(每个副本)、同一个(收敛,不是「都最新」)。

定义 4.5.1 · 最终一致

对每个对象 k,若在某个时刻之后再没有对 k 的写,则存在一个时刻 T,使得对所有副本 r 和所有 t > T,r 上对 k 的读都返回同一个值。这个定义只要求活性(最终发生),不要求顺序、实时性,也不给出 T 的上界。

这个定义单独看几乎不约束实现:一个永远不复制、但也不接受读的系统也「最终一致」。工程上说的最终一致,通常隐含了下面六个条件;缺一个,收敛就可能不成立,或者只在 PPT 上成立。冲突解决的具体机制(LWW、版本向量、CRDT)在 第 3 章 已经展开;这里把它们当成「收敛条件」逐条检查。

#收敛条件不满足时会怎样工程手段
1写会停止(quiescence)持续写入时没有「最终」可言;每个对象只能按最后一次写算按对象界定收敛窗;不要用全局「系统已收敛」
2每个更新最终到达每个副本副本永久落后;节点下线后数据没有重新复制反熵(Merkle 树)、hinted handoff、读修复、全量同步
3冲突解决是确定性的,且不依赖到达顺序两个副本对同一组写得出不同结果,永久分叉LWW + 可信时钟;版本向量 + 应用合并;CRDT
4删除不会被「复活」tombstone 先被 GC,分区恢复后旧值被读修复带回来tombstone 保留时间 > 最大分区/修复时间
5读路径不会永久指向陈旧副本副本已收敛,但缓存/连接池/死副本仍在返回旧值TTL 上界;健康检查;版本校验
6更新不会因保留期/磁盘故障被静默丢弃hinted handoff 过期、队列截断、副本重建丢数据保留期 > 最大故障窗口;校验和;定期修复

把这六条压成一句话:最终一致 = 停止写 + 送到 + 确定合并 + 不复活删除 + 读路径可达 + 不丢更新。 其中任何一条都需要工程实现,不是选一个数据库就自动获得的。

「最终」到底有多最终?没有上界。一个每小时跑一次反熵的系统,收敛时间可能接近一小时;hinted handoff 保留三天,节点宕机三天内回来还能补,超过三天就可能丢。收敛时间不是 SLA,除非你用机制给它加上界,例如「读修复在 200 ms 内完成」「反熵每 30 秒一轮」「副本 lag 超过 5 秒就摘除读流量」。把这些机制写进 SLO,才谈得上「最终一致的延迟」。

一个具体的时钟陷阱:LWW(last-write-wins)用时间戳决定胜者,但时间戳来自各节点的墙上时钟。

写节点时钟偏差真实发生时间记录的时间戳LWW 胜者
W1 x=1+5 s10:00:0010:00:05W1
W2 x=2010:00:0210:00:02—

W2 在真实时间上更晚,却因为 W1 的节点时钟快而输掉。最终两个副本都收敛到 1,收敛了,但收敛到了错误的值。这就是为什么条件 3 要求「确定性」还不够,还要说明时间戳的语义:LWW 的「最后」是「时间戳最大」,不是「真实最后」。如果业务不能接受这种错序,就需要版本向量/因果序,或者把冲突交给应用合并。

CRDT 走的是另一条路:把状态设计成可交换、可结合、可幂等的合并结构,这样到达顺序不影响结果。一个 Grow-only Counter 的最小实现:

class GCounter:
    def __init__(self, node):
        self.node = node
        self.counts = {}

    def inc(self, n=1):
        self.counts[self.node] = self.counts.get(self.node, 0) + n

    def merge(self, other):
        for node, value in other.counts.items():
            self.counts[node] = max(self.counts.get(node, 0), value)

    def value(self):
        return sum(self.counts.values())


a, b = GCounter("a"), GCounter("b")
a.inc(2)
b.inc(3)
a.merge(b)
b.merge(a)
print(a.value(), b.value())   # 5 5

merge 对每个节点取最大值,满足交换律、结合律、幂等律;无论 a.merge(b) 和 b.merge(a) 谁先执行,结果都是 5。这就是「强最终一致」(strong eventual consistency):所有看到同一组更新的副本,不需要协调就处于相同状态。代价是数据模型受限:计数器可以这样合并,但「把余额设为 100」这种覆盖写不能;它需要 LWW 或应用层冲突解决,也就重新引入了时钟/顺序问题。

4.5.1 反熵、读修复与删除:收敛的实际时间线

「最终」不是一个瞬间,而是一条由多种机制拼起来的时间线。假设 key x 在副本 A 上从 0 改为 1:

时间事件AB读到的值
t0客户端写 A,A 返回成功10—
t1A 在复制给 B 之前宕机10从 B 读 → 0(陈旧)
t2客户端读 B,触发读修复10读修复无法从 B 修 A(B 没有新值)
t3A 恢复,反熵交换 Merkle 树11从 B 读 → 1(收敛)

读修复只在「读到的副本比另一个副本新」时有用;如果读打到落后副本,它没有新值可修。真正兜底的是反熵或 hinted handoff:

机制触发典型延迟失败模式
读修复读时发现版本差异一次读的延迟读不到就不修
hinted handoff写时目标副本不可达目标恢复即投递TTL 过期后丢
反熵(Merkle)周期任务分钟–小时带宽/周期决定上界
全量同步节点重建/落后太多小时–天期间读仍陈旧

删除比写入更危险。删除通常写一个 tombstone;如果 tombstone 在分区恢复前被 GC,落后副本会把旧值当成「缺失的新值」读修复回来,出现「删除复活」。所以 tombstone 的保留时间必须大于最大可能分区时长 + 反熵周期,而不是「几天应该够了」。

收敛时间的粗糙上界可以写成:

T_converge <= max(反熵周期, hinted handoff 投递时间, 读修复触发间隔) + 传播/应用时间

这个式子里的每一项都必须有监控;没有这些项,「最终一致」只是一个没有上界的承诺。4.12 节会把「有界陈旧」作为选型的一档,而不是把「最终」当成一个可承诺的延迟。

4.6 CAP 的精确版本与 PACELC

CAP 被引用得最多,也被误读得最多。先把三个字母还原成定义。2000 年 Brewer 提出猜想,2002 年 Gilbert 与 Lynch 给出证明;他们证明的「C」不是「所有副本一样」,而是线性一致(原子对象)。

定义 4.6.1 · CAP 的三个字母
  • C(Consistency):对象满足线性一致,即 4.2 节的定义。
  • A(Availability):每个到达未故障节点的请求,都必须在有限时间内返回一个非错误响应。
  • P(Partition tolerance):网络允许在节点之间丢弃任意多的消息,把它们分成互相不可达的组。

CAP 定理:一个分布式数据对象不可能同时满足 C、A、P。

形式证明里的 A 只要求请求终止(返回某种响应);工程上通常收紧为「返回非错误响应」,因为 503 等于拒绝服务。无论采用哪个版本,返回 503 都不是「可用」。

证明只需要一个两节点反例。节点 N1、N2 组成系统,初始 x=0,网络把它们分区。客户端向 N1 写 x=1,N1 返回成功(否则不满足 A)。接着客户端向 N2 读 x:

  • 若 N2 返回 1,它必须在分区期间知道 N1 的写,但消息被丢弃,矛盾;
  • 若 N2 返回 0,读不是线性一致的(写在读之前已返回),违反 C;
  • 若 N2 等待到超时,违反 A;若 N2 返回错误,工程语义下也算不可用。

三者不可能同时成立。注意这里没有假设「N2 一定知道 N1 写过」;恰恰因为分区,它不可能知道。

这个证明经常被转述成「CAP 三选二」,但精确的含义要窄得多:

常见说法精确说法
「CAP 三选二,我们选 CA」P 是网络属性,不是产品开关。所谓 CA 只是在「假设永不分区的部署」里成立;一旦分区,仍要选 C 或 A。
「AP 系统不保证一致性」AP 只表示分区时优先可用;无分区时仍可提供线性一致,或至少提供读己之写、单调读。
「CP 系统不可用」CP 只在少数派/失去 quorum 时拒绝写或线性一致读;多数派一侧仍可服务。
「A 就是 200 OK」A 要求非错误响应;返回 503 不是 available。等待到超时也不是。
「CAP 说的是延迟」CAP 只谈分区时的 C/A;延迟是 PACELC 的「Else」分支。
「这个数据库是 CP/AP」一致性是按操作选的:同一系统可以写用 quorum、读走本地副本、关键读走 leader。

所以更有用的问法不是「这个数据库是 CP 还是 AP」,而是:在这次请求、这个对象、这个故障场景下,我放弃的是线性一致还是可用性?放弃的边界在哪里?

PACELC 补上了 CAP 没说的另一半。Abadi 在 2012 年把它写成:If Partition, choose A or C; Else, choose Latency or Consistency. 没有分区时,强一致仍然要付协调/等待的延迟;这个代价天天都在,不是故障时才出现。

系统/配置分区时(P)无分区时(E)说明
Spanner(外部一致性)CC(付 TrueTime 等待)跨地域提交要等不确定性窗口
etcd(默认线性一致读/写)CC(一次 ReadIndex/leader 往返)少数派拒绝服务
etcd(--consistency=s)A(可能陈旧)L(本地读,不协调)同一集群,按请求切换
DynamoDB(最终一致读)AL强一致读可另行指定,按请求付延迟
Cassandra(QUORUM 读/写)A/C 可调(多数派才可用)Lquorum 提高交集,不自动等于线性一致
MongoDB(v1 + majority + linearizable)CC(主库读)3.4.0 之前 v1 有 bug,见 4.10

「Else, Latency or Consistency」不是空话,可以用数字感受:同可用区一次 RTT 约 0.3–1 ms,跨地域 30–150 ms;线性一致读通常要多一次 leader/quorum 确认,quorum 写要多一次多数派落盘。这些延迟在系统完全健康时也会发生,而 CAP 只在分区时说话。选型时如果只问 CAP,就会漏掉 99.9% 时间里的性能代价。

4.6.1 分区不是一个二值事件

CAP 证明里的 P 是「任意消息丢失」,现实中的分区更复杂:可能单向、可能只影响部分链路、可能持续几百毫秒,也可能时断时续。系统通常用超时/心跳/quorum 来猜测分区,猜测本身会出错:

现象系统以为实际后果
心跳超时但网络正常分区/节点故障对端 GC 停顿/过载误切主,增加抖动
单向丢包对端还活着我发得出去,收不到回复半双工状态,选举/复制异常
短时抖动分区网络瞬断频繁重配置,放大故障
分区但客户端仍能访问两侧只有一个 leader两个 leader 各自服务双写/数据分叉(第 5 章)

所以「分区时选 C 还是 A」在工程上还要加一问:我能不能及时、准确地检测到分区? 检测太敏感会把正常抖动当分区,检测太迟钝会让旧 leader 继续服务。lease(租约)用时间换检测速度,fencing(第 9 章)用单调 token 把旧持有者挡在资源外;两者都不是 CAP 定理本身,而是把 CAP 的选择落到实现上。

最后一个常见错误:把 CAP 的 C 当成「所有副本数据相同」。副本相同是收敛(4.5 节),线性一致是读的可见性顺序。一个系统可以所有副本最终完全相同,却在收敛前返回陈旧读;也可以每次读都线性一致,却在分区时拒绝服务。两者不是同一个坐标轴。

4.7 一致性不是隔离级别

「一致性」这个词在分布式系统和数据库里指两件不同的事,混用会导致非常具体的错误。本节把两个维度拆开:

  • 副本一致性(replica consistency):同一个对象有多个副本时,一次读允许看到哪个版本?关键词是线性一致、顺序一致、因果、最终。
  • 事务隔离(transaction isolation):多个事务并发访问多个对象时,允许出现哪些交错?关键词是读未提交、读已提交、可重复读、快照隔离、串行化。

它们回答的问题不同:

维度副本一致性事务隔离
对象单个对象的多个副本多个对象、多个事务
核心问题读能否看到已返回的写并发事务能否看到彼此的中间状态
典型保证线性一致、顺序一致、因果、最终RU、RC、RR、SI、Serializable
典型反例读到旧余额脏读、丢失更新、写偏斜
实现手段quorum、ReadIndex、leader 读、会话 token锁、MVCC、SSI、串行化
跨地域代价每次强读一次往返冲突检测/锁等待/回滚

线性一致不等于串行化。 一个每键都线性一致的 KV 存储,不提供跨键事务:转账时先读 A、再读 B,两个读各自线性一致,合起来仍可能不是同一时刻的快照。反过来,一个串行化的数据库如果允许从只读副本读,返回的仍可能是旧版本;串行化约束的是事务之间的等价性,不是副本的实时可见性。

一个具体的「不一致快照」:初始 A=100,B=100。事务 T1 把 50 从 A 转到 B,提交后 A=50、B=150。客户端在 T1 提交前线性一致地读 A 得到 100,在 T1 提交后线性一致地读 B 得到 150。两次读都合法,但把它们拼起来得到 A+B=250,而系统在任何时刻的总和都是 200。每一把钥匙都线性一致,不等于跨钥匙的读是一个快照。 要解决它,需要快照读/多对象事务(第 6 章),而不是把单键一致性调得更强。

事务隔离级别的常见异常可以用一张表对照(细节在第 6 章):

隔离级别脏读不可重复读幻读丢失更新写偏斜
读未提交(RU)可能可能可能可能可能
读已提交(RC)阻止可能可能可能可能
可重复读(RR)阻止阻止视实现视实现可能
快照隔离(SI)阻止阻止阻止阻止可能
串行化(Serializable)阻止阻止阻止阻止阻止

这张表里最容易被忽略的是快照隔离下的写偏斜:两个事务各读一份「对方没动」的快照,各自写不同的行,合起来破坏不变量。它是隔离级别问题,不是副本问题;把读切到主库、把一致性调成线性,都不会修复它。反过来,4.8 节的陈旧读是副本问题,把隔离级别从 RC 调到 Serializable 也不会修复它,因为旧值本身是已提交的合法数据。

4.7.1 两个维度同时出错:一个组合反例

把两个维度放在同一个事故里,能看清它们各自负责什么。初始 A=100、B=100:

步骤操作副本一致性事务隔离结果
1T1 在 primary 上把 50 从 A 转到 B(Serializable)强强提交后 A=50、B=150
2客户端从 lag 副本读 A弱(陈旧)不涉及读到 100
3客户端从 primary 读 B强不涉及读到 150
4客户端用 A+B=250 做预算弱不涉及基于不存在过的快照决策

第 1 步的事务是串行化的,第 3 步读 B 也可能是线性一致的;但第 2 步读 A 已经是陈旧读,组合起来更不是任何时刻的快照。要修第 4 步,需要一致的快照读(同一 revision 读多个 key),而不是把事务隔离级别再调高。

反过来也成立:一个系统每 key 都线性一致,但两个并发事务各自读 A、读 B,然后写对方读过的行,仍可能产生写偏斜。副本一致性管「读到的版本」,事务隔离管「并发事务的交错」;一个维度的加强不能替代另一个。

两个维度可以组合,现实系统通常只各取一档:

系统/用法副本一致性事务隔离说明
etcd 单次 txn线性一致单次原子事务(非交互)多键比较/写入是一个日志条目,但不是交互式事务
PostgreSQL 主库读主库单副本RC/RR/Serializable 可选强隔离不等于跨副本强一致
PostgreSQL 异步只读副本最终一致(异步复制)只读查询可能读到旧事务的结果
Spanner线性一致(外部一致)严格串行化TrueTime + 2PC;代价是提交等待
DynamoDB 最终一致读最终一致单条条件写条件写提供单条原子性;跨项事务要显式使用事务 API

最后一个术语:严格串行化(strict serializability)= 串行化 + 实时序。它要求事务的串行顺序尊重实时序,Spanner 的「外部一致性」就是它。线性一致是单对象的严格串行化;串行化是多对象的等价性。把这三个词排成一条线:

单对象:最终一致 < 因果一致 < 顺序一致 < 线性一致
多对象:串行化 < 严格串行化
严格串行化 = 线性一致(实时序)+ 串行化(多对象原子性)

这两条线不是同一个维度(因果和顺序的关系、会话保证的位置都有细节),但足以防止最常见的偷换概念:「我们用了 Serializable,所以读一定是最新的」是错的;「我们用了线性一致,所以转账不会丢更新」也是错的。

4.8 失败模式:分区期间的陈旧读

4.7 节把副本一致性和隔离级别拆开了。现在把镜头对准最典型的副本一致性故障:网络分区期间,客户端从落后副本读到了已提交但过期的值,并把这个值当成决策依据。 它和超时、重试、重复副作用是同一条链上的环节,只是这次「错误的数据」而不是「重复的动作」先出场。

把开头那个账户事故展开成时间线。集群三副本:leader L、follower F1、follower F2。F2 仍与 L 连通,F1 与 L 之间的复制链路在 09:41:02.130 被切断:

t(ms)  客户端                 leader L                 follower F1
  0    TX t-1  ----------->  A=100 B=0    rev=57      A=100  rev=57
 10                          ── F1 与 L 的复制链路断开(分区) ──
 20                          A=0   B=100  rev=58      A=100  rev=57
 21                          ack 在回程丢失            (收不到 rev=58)
800    timeout(800ms 预算)
804    重试 t-2 ----------->  (读请求被负载均衡到 F1)
805                          A=100  rev=57  <-------- 陈旧读:guard 通过
820    TX t-2  ----------->  A=-100 B=200 rev=59

这里有三份「真相」,每一份在自己的视角里都合理:

角色它知道的它不知道的它做了什么
客户端t-1 在 800 ms 超时t-1 已在 20 ms 提交重试,并重新执行余额检查
leader Lt-1 已提交,rev=58响应没有到达客户端继续接受新请求
follower F1本地状态停在 rev=57rev=58 已经存在用旧值回答读请求

这就是第 1 章说的「部分失败」在数据面的版本:每个节点都只掌握了事实的一部分,但每个节点都在基于自己那部分事实做决定。 分区不会「制造旧数据」,它只是让旧数据的存在时间从毫秒级变成无界;真正造成事故的是客户端把「读到的值」当成了「当前的值」。

定义 4.8.1 · 陈旧读

陈旧读(stale read)是一次读返回了某个已经提交、但已经被更新的写覆盖的值。它与脏读不同:脏读读到的是未提交的数据;陈旧读读到的数据在某个时刻是合法的,只是不是最新版本。

形式化地说,若写 w 在 t_w 返回,读 r 在 t_r > t_w 发起(t_r 晚于 w 的响应),而 r 返回的版本早于 w 写入的版本,则 r 是陈旧读。这个定义默认 w 与 r 访问同一对象;跨对象的「不一致快照」是 4.7 节的问题,不是单对象陈旧读。

分区期间陈旧读的放大路径有五条,按出现频率排序:

#路径机制为什么分区时更严重
1异步复制 lagfollower 的 apply 落后于 leader 的 commit分区让 lag 从毫秒变成秒/分钟,且没有上界
2读流量打到 follower负载均衡/只读副本/就近接入客户端不知道哪个副本落后
3读重试换副本第一次读超时,重试落到另一个 lag 更大的副本连续读可能回退,破坏单调读
4缓存/连接固定缓存 TTL 未到、连接池粘在旧副本即使副本已追上,读路径仍返回旧值
5旧 leader 仍服务分区后旧 leader 未及时自停,继续接受读需要 lease/fencing,第 5、9 章展开

第 3 条值得单独说,因为它经常被当成「读重试无害」。假设客户端连续两次读同一个 key:

read #1 -> F1:A=100  rev=57
read #2 -> F2:A=80   rev=55   (F2 比 F1 更落后)
read #3 -> F1:A=100  rev=57

每一次读都可能是「该副本自己的日志上线性一致」的读,但三次读合起来不是任何一致模型:三次读之间没有新的写,值却从 100 退到 80 再回到 100。「每个副本各自线性一致」不等于「整个系统线性一致」。 线性一致性是对整段 history 的判定;把多个独立副本的局部 history 拼起来,实时序可能根本无法满足(4.2 节的定义里,读 #2 在 read #1 返回之后发起,必须看到 100 或更新值)。

业务后果不是抽象的「不一致」,而是可以计价的:

  • 余额检查通过,实际余额不足,账户透支 100 元;
  • 库存显示 1 件,三个分区各自卖出 1 件,超卖 2 件;
  • 优惠券在旧副本上显示「未使用」,同一张券被核销两次;
  • 权限撤销写到 leader,读副本仍返回旧 ACL,被撤销的用户多访问了几分钟;
  • 调度器基于旧状态做决策,把同一个任务派给两个 worker。

这些场景的共同点是:读不是终点,而是下一个写/副作用的输入。 如果读只用于展示,陈旧最多让人困惑;如果读用于 guard(余额够不够、库存有没有、锁是否空闲),陈旧读就会变成重复副作用。4.11 节会把这条链接到二次转账。

修复要分层,不能只喊「读主库」:

手段做法代价适用
线性一致读leader 读、ReadIndex、quorum 确认多一次往返;分区少数派不可用余额、库存、权限等决策读
有界陈旧读副本必须满足 applied_index >= 写回执的 index可能等待;需要暴露 index能接受几十毫秒延迟的读
会话 token写返回 token,读副本追到 token 才返回每会话状态;跨设备要同步读己之写、个人资料
单调读记录本会话见过的最大 revision,只接受 ≥ 它的副本固定副本;故障切换要挑副本连续刷新的页面
quorum 读R + W > N,读多个副本取最新读放大;仍不自动等于线性一致无主复制、Dynamo 风格
失败关闭无法确认新鲜度时返回错误,而不是旧值可用性下降支付、风控、权限
原子条件写不依赖读做 guard,把条件放进写需要 CAS/事务扣款、扣库存

4.8.1 读路由策略与代价

同一个「读余额」请求,路由策略不同,得到的新鲜度和可用性完全不同:

策略新鲜度延迟分区时实现复杂度
只读 leader线性一致(正确实现的 leader 读)一次到 leader 的 RTT少数派不可用低
follower + lag 上界有界陈旧(如 ≤ 50 ms)本地读落后超界则摘除中
quorum 读(R+W>N)可能读到最新,但不保证线性读多个副本需多数派中
会话固定副本单调读本地读副本故障要迁移中
本地优先 + 失败回退 leader取决于回退通常本地,偶尔 RTT回退可能超时中
任意副本最终一致最低高可用低

选择不是「哪个最强」,而是这个读能不能接受旧值、能接受多旧、分区时能不能失败。余额 guard 选第一行;商品详情选第二/第六行;购物车选第四行;配置读选第一行 + watch。把策略写进接口(例如 read(key, mode=...)),而不是藏在负载均衡器里,否则调用方无法判断自己拿到的是什么。

前面那张修复手段表的最后一行——原子条件写——是陈旧读的兜底,不是替代品。即使读到了旧值,只要写是「余额 ≥ 100 才扣」的原子操作,第二次扣款会被拒绝;但用户仍会看到错误的余额,体验和客服成本还在。读的一致性和写的原子性要一起设计。

观测上,至少要能回答「这次读来自哪个副本、它的 applied index 是多少、和写回执差多少」。如果响应里只有值,没有版本,事故复盘时只能猜。4.9 节用 etcd 演示如何把这两个数字读出来。

4.9 观测:etcd 的 linearizable vs serializable read

etcd 把「读的一致性」做成了每个请求的显式选项,非常适合观察。下面以 etcd 3.5 的 etcdctl 为例;etcdctl get 的 --consistency 有两个取值:

  • l(linearizable,默认):读必须经过 leader 确认(ReadIndex/心跳),保证返回的是集群已提交的最新值;
  • s(serializable):读由本地成员直接服务,可能落后于 leader,但延迟低、分区少数派也能读。

先准备一个三节点集群(本机示例端口 2379/22379/32379),写入一个余额,然后看三个成员的状态:

etcdctl --endpoints=http://127.0.0.1:2379 put /ledger/alice 100

etcdctl --endpoints=http://127.0.0.1:2379,http://127.0.0.1:22379,http://127.0.0.1:32379 \
  endpoint status -w json \
  | jq -r '.[] | [.Endpoint, .Status.header.revision, .Status.raftIndex, .Status.raftAppliedIndex, .Status.leader] | @tsv'

输出示例(字段顺序:endpoint、当前 revision、raftIndex、raftAppliedIndex、leader id):

http://127.0.0.1:2379    51    51    51    10276657743932975437
http://127.0.0.1:22379   51    51    51    10276657743932975437
http://127.0.0.1:32379   51    51    51    10276657743932975437

现在向 leader 写 80,制造一次版本推进:

etcdctl --endpoints=http://127.0.0.1:2379 put /ledger/alice 80
OK

从 leader 做一次线性一致读,确认写已经可见:

etcdctl --endpoints=http://127.0.0.1:2379 get /ledger/alice --consistency=l -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":52,"raft_term":4},"kvs":[{"key":"L2xlZGdlci9hbGljZQ==","create_revision":51,"mod_revision":52,"version":2,"value":"ODA="}],"count":1}

value 是 base64 的 80,mod_revision=52 是这次写的版本。接下来把 follower 22379 与集群的 peer 流量切断。可以用防火墙/网络策略,或直接用第 13 章项目的 /admin/partition:

# 隔离 22379 的 peer 流量(任选其一):
#   1) 在 22379 所在节点用防火墙/网络策略切断它与 2379、32379 的 peer 连接;
#   2) 用第 13 章项目的 /admin/partition 接口(macOS/CI 可复现)。
# 本机三进程部署时,peer 端口通常是 2380/22380/32380。

再查一次状态,会看到 22379 停在旧 revision:

http://127.0.0.1:2379    52    52    52    10276657743932975437
http://127.0.0.1:22379   51    51    51    10276657743932975437
http://127.0.0.1:32379   52    52    52    10276657743932975437

此时从 22379 做 serializable 读,它会用本地状态回答:

etcdctl --endpoints=http://127.0.0.1:22379 get /ledger/alice --consistency=s -w json
{"header":{"cluster_id":14841639068965178418,"member_id":11276657743932975437,"revision":51,"raft_term":4},"kvs":[{"key":"L2xlZGdlci9hbGljZQ==","create_revision":51,"mod_revision":51,"version":1,"value":"MTAw"}],"count":1}

revision=51、mod_revision=51、value=100:这是 4.8 节说的陈旧读。再从同一个 22379 做 linearizable 读:

etcdctl --endpoints=http://127.0.0.1:22379 --command-timeout=2s \
  get /ledger/alice --consistency=l -w json
{"level":"warn","ts":"2026-10-19T09:41:07.812Z","logger":"etcd-client","caller":"v3/retry_interceptor.go:63","msg":"retrying of unary invoker failed","target":"etcd-endpoints://0xc0000e8000/127.0.0.1:22379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = context deadline exceeded"}
Error: context deadline exceeded

具体错误文本随 etcdctl 版本和超时设置略有差异;关键是它没有返回旧值,而是失败。这正是 CAP 的现场演示:serializable 读在分区少数派上仍然可用,但返回旧值;linearizable 读拒绝返回旧值,代价是不可用。没有哪个选项「更好」,只有这次读能不能接受旧值。

批量对比三个成员的读,可以一眼看出谁落后:

for ep in 2379 22379 32379; do
  etcdctl --endpoints=http://127.0.0.1:$ep get /ledger/alice --consistency=s -w json \
    | jq -r --arg ep "$ep" '[$ep, .header.revision, (.kvs[0].mod_revision // -1), (.kvs[0].value // "")] | @tsv'
done
2379   52  52  ODA=
22379  51  51  MTAw
32379  52  52  ODA=

怎么读这些数字:

字段含义正常异常
header.revision这次读执行的 MVCC revision同一时刻不同副本可能不同;单副本单调不减同一客户端连续读出现回退
mod_revisionkey 最后一次修改的 revision应等于最近一次写回执的 revision小于写回执,说明读到了旧版本
raftIndex / raftAppliedIndex已提交日志索引 / 已应用索引两者接近;follower 落后 leader 有界applied 长期落后,或与 leader 差距持续扩大
Status.errors成员报告的异常空非空,优先排查成员健康
延迟本地读 vs leader 确认s 通常 0.1–1 ms;l 多一次往返l 在无 quorum 时超时,是可用性代价

三条读法:

  1. 线性一致读成功时,结果不能比已经返回的写旧。 如果写回执是 rev=52,读却返回 rev=51,这不是「最终一致」,而是线性一致读的实现 bug(或者你以为发的是 l,实际发的是 s)。
  2. serializable 读落后多少,要有一个上界。 例如「读流量只允许打到 applied lag ≤ 5 s 的副本」;超过就摘除或降级。没有上界的 s 读,在分区时可能落后几分钟。
  3. linearizable 读超时不是数据丢失。 它说明当前成员无法确认最新值,因此拒绝回答;这比返回旧值安全。复盘时要把它计入可用性,而不是一致性错误。

两个容易忽略的边界:

  • --consistency 只作用于 get(以及基于 get 的命令),不作用于 watch。watch 从指定 revision 之后按序推送已提交事件,但一个从旧 revision 开始的 watch 会先收到旧事件;它不是线性一致读。
  • endpoint status 的 revision 是该成员本地看到的 revision,不是全局最新值;比较时要以 leader 的 header.revision 或写回执为准,不能拿两个 follower 互相比较。

4.9.1 把读模式写进客户端契约

etcd 的 --consistency 是一个好示范:一致性是请求的属性,不是集群的属性。客户端应该能显式表达:

{"key":"/ledger/alice","consistency":"bounded_staleness","max_lag_ms":50}

服务端根据模式选择读路径,并在响应里带回证据:

模式服务端行为响应证据失败时客户端
linearizableleader/ReadIndex 确认revision、served_by返回错误,不降级
bounded_staleness本地读,检查 lag ≤ 上界revision、applied_index、lag_ms超界则换副本或升级为线性读
session追到会话 tokenapplied_index等待/回退 leader
eventual任意副本revision(可能旧)接受旧值

关键在最后一列:降级必须是显式的。如果客户端请求 linearizable,服务端因为分区偷偷返回本地旧值,调用方会把陈旧读当成最新值,4.11 节的事故就会重演。反过来,bounded_staleness 超界时返回错误或升级,也比静默返回旧值好。

如果手边没有 etcd,第 13 章的项目二提供了同构接口:kv_client.py get k --consistency linearizable 与 --consistency serializable,底层分别是 ReadIndex 读和本地读。命令不同,读法相同:看返回的 revision/applied index 与写回执的差。

4.10 真实案例:Jepsen 对 MongoDB 3.4 的分析

2017 年 2 月 7 日,Jepsen 发布了针对 MongoDB 3.4.0-rc3 的分析。这份分析的重要性不在于「MongoDB 有 bug」,而在于它用一个可复现的 history 证明了:在 majority write concern 和 linearizable read concern 都打开的情况下,系统仍然可能丢失已经被确认的写。 这两个选项的名字听起来已经是最强保证,但一致性是端到端的,选举协议的一个假设就能把它击穿。

分析的测试环境是五节点副本集,工作负载包含读、写和 CAS;故障注入不是简单的「杀节点」,而是专门制造有时钟偏斜的隔离主库:

阶段动作目的
1找出所有自认为是 primary 的节点,切断它们与集群其余部分的连接制造少数派主库
2把这些节点的时钟向前拨 2 分钟让它们的 optime 看起来更新
3让隔离主库继续接受写(多数派写关注)产生「无法复制但已确认」的写
4杀死所有 primary,恢复网络、重置时钟强制重新选举
5让隔离主库重新加入并参与选举观察谁赢、哪些写被回滚
6静默后做最终读,检查哪些已确认的插入不见了统计丢失

MongoDB v0 复制协议的选举用 optime 比较「谁更新」。optime 是单调递增的,但它被锚定在节点的本地墙上时钟上。于是出现了分析里的经典场景:

  1. 节点 A 是 primary,与集群隔离 10 秒,期间接受 w1、w2,optime 记为 10、11,但无法复制给多数派。
  2. 另一侧 B 当选 primary,接受 w3,optime 记为 10,并以多数派写确认;w3 复制到了 C。
  3. 隔离解除,A 与 C 选举。A 的 optime 11 大于 C 的 10,A 当选。
  4. A 的日志里没有 w3;为了让日志一致,系统回滚 w3。w3 已经被多数派确认,却从系统中消失了。

时钟偏斜不是「小概率的脏数据」,它直接扩大了「隔离主库的 optime 超过多数派」的窗口。分析指出,v0 的这个缺陷是设计性的,不是某个版本的实现 bug;包括 3.4.1 在内的所有 v0 部署都存在,修复方式是切换到 v1 协议。

Jepsen 的第一组数字:v0 协议下,4525 次插入尝试中,322 条最终保留,93 条被确认后丢失。这不是「写失败了」,而是「客户端收到了成功,数据后来没了」。

v1 协议借鉴了 Raft 的 term 概念,把 optime 换成 (term, timestamp),理论上应该修复 v0 的选举缺陷。但 v1 在 3.4.0-rc3 与 rc4 上仍然暴露了多个 bug:

  • primary 在确认写时只检查「我是否还是 primary」,没有检查 term 是否已经变化,于是可能确认上一个 term 的写(SERVER-27053);
  • 心跳消息可能让一个过期 primary 推进 commit point,确认尚未真正持久的写;
  • 3.4.0-rc4 中,secondary 复制时没有检查对方是否是更新的节点,仍然用时间戳而不是 term 比较(SERVER-27149)。

结果:v1 下 6078 次插入尝试,932 条被确认,417 条最终丢失——接近已确认写的一半。这两个 bug 的根因都可以追溯到 v0 时代的假设:代码里仍然相信「时间戳更大 = 日志更新」,而 v1 的 term 才是逻辑时钟。

修复与验证:

版本/协议结果
v0,所有版本(含 3.4.1)设计性不安全;隔离主库 + 时钟偏斜下可丢 majority 确认写
v1,≤ 3.2.11 / 3.4.0-rc4实现 bug;丢已确认写
v1,3.2.12 / 3.4.0 / 3.5.1修复 term/commit 检查;通过扩展后的 Jepsen 测试
v1 + majority 写 + linearizable 读,3.4.0 及以后测试中保持已确认写,单文档读/写/CAS 线性一致

这份分析给出的教训可以直接迁移到任何系统:

  1. 「多数派确认」是一个条件承诺,不是无条件事实。 它依赖选举协议不会让一个缺少该写的节点当选;如果选举依赖墙上时钟,时钟偏斜就是数据丢失的故障注入项。
  2. 读一致性不能修复写丢失。 如果已确认的写被回滚,linearizable 读再强也只能返回「当前存在的值」;它无法让不存在的写复活。
  3. 协议版本是配置的一部分。 同一个 MongoDB 版本,v0 与 v1 的一致性行为不同;升级/切换协议要和升级代码一样做验证。分析特别指出 v1 不是 Raft,保留了 rollback 等额外复杂度。
  4. 测试要注入时钟偏斜和分区,而不只是杀进程。 只测「节点崩溃后重启」会漏掉「隔离主库 + 快时钟赢得选举」这条路径。Jepsen 的 nemesis 之所以有效,是因为它针对协议的假设设计故障,而不是随机搞破坏。
事实边界

本节只引用 Jepsen 公开分析中的时间、测试方法、数字和结论:2017-02-07 发布,五节点副本集,v0 的 4525/322/93,v1 的 6078/932/417,以及 3.2.12/3.4.0 的修复。具体实现细节以原文和 MongoDB 官方文档为准,本节不复述源码。链接:Jepsen: MongoDB 3.4.0-rc3。

4.10.1 把案例变成自己的测试

不必复现 Jepsen 的五节点集群,也可以在自己的项目里验证同一类假设。第 13 章的项目二提供了分区注入接口,按下面的表设计用例:

故障注入方式观察期望
隔离 followerfault_inject.sh --partition 2从该 follower 读serializable 读返回旧值;linearizable 读报错/等待
隔离 leader--partition 1新 leader 选举、旧 leader 读旧 leader 不再服务线性读;新 leader 可写
时钟偏斜用逻辑时间/延迟模拟,不改系统时钟选举/租约旧主不会凭更大的时间戳获胜
响应丢失代理丢弃响应(或用项目一 --drop-response)客户端超时、重试同键重试不产生第二条副作用
恢复--heal副本追平、读修复revision 收敛,读不再回退

每条用例都要记录 history:操作的 inv/resp、返回值、revision。测试的断言不是「最终一致了」,而是「在注入期间,线性一致读要么返回最新值,要么失败;serializable 读允许旧值,但不能在同一会话中回退」。这比「跑一遍没报错」强得多。

对本书的读者来说,这个案例还有一个方法论意义:第 4.2 节的检查器不是玩具。 把上面的插入/读/CAS 操作记录成 history,检查器(或 Jepsen 的 Knossos)就能给出「不存在合法全序」的反例。没有 history 和判定器,你只能看到「监控显示写入成功」,看不到「成功之后又消失」。

4.11 重试导致重复副作用:陈旧读后的二次转账

现在把 4.8 节的陈旧读和重试接起来。这是全书最值得背下来的事故形状之一:读到的旧值让 guard 通过,重试让同一个意图执行两次。 它同时违反了两条设计原则:读没有版本约束,写也没有幂等身份。

场景:账户 A 初始 100 元,向 B 转账 100 元。服务端的处理函数是典型的「先查再写」:

1. balance = read(A)            # 读请求走只读副本
2. if balance < amount: 拒绝
3. debit(A, amount); credit(B, amount)
4. 返回 transfer_id

第一次请求 t-1 在 leader 上提交:A=0、B=100,rev=58。响应在回程丢失,客户端 800 ms 超时。客户端重试,但重试逻辑生成了新的 transfer_id=t-2;同时读请求被负载均衡到落后的副本,读到 A=100(rev=57)。于是:

时间事件AB读/写版本
t0初始1000rev=57
t1t-1 提交,响应丢失0100rev=58
t2客户端超时,生成 t-2 重试0100—
t3t-2 从副本读 A0100读到 100,rev=57
t4guard 通过,t-2 扣款-100200rev=59
t5对账发现流水有两笔 100 出账,但用户只授权了一笔-100200客户 A 多损失 100

注意两个错误是独立的,任何一个被修复都能阻止事故:

  • 如果 t-2 复用 t-1 的幂等键,服务端的唯一约束会发现「这个意图已经执行」,直接返回原结果;
  • 如果 t-2 的读是线性一致的(或读己之写),它会看到 A=0,guard 拒绝;
  • 如果扣款是原子条件写(balance >= 100),即使 guard 通过,第二次扣款也会失败;
  • 如果对账把流水与用户授权关联起来,能在 t4 之后立即发现并冻结,或者扣款有「单日单账户最多转 100」的业务约束,也能兜住。

四个「如果」对应四层防御,不能只做一层。幂等键解决「同一个意图被执行两次」,条件写解决「基于旧值的写覆盖新值」,线性一致读解决「看到旧值」,对账解决「前三个都漏了」。 它们防的不是同一件事。

例 4.11.1 · 重试导致重复副作用:陈旧读后的二次转账

客户端发起 transfer(t-1, A, B, 100)。服务端提交成功,响应丢失;客户端超时后用新键 t-2 重试。重试的读落到 lag 副本,看到 A=100,guard 通过;服务端没有把流水关联到用户授权(或业务去重键),也没有条件扣款,于是再次扣款。结果 A=-100、B=200。

修复分四层:

  1. 身份:客户端为每个逻辑转账生成 transfer_id,所有自动/手动重试复用它;服务端 transfers.id 主键唯一。
  2. 原子写:扣款用 UPDATE ... WHERE balance >= amount 或 CAS(版本号),不要先读后写。
  3. 读:guard 读走 leader/线性一致读,或至少带 applied_index >= 写回执 的会话 token。
  4. 对账:把流水与用户授权/外部账单定期核对;发现同一授权对应多笔出账或账实不符时告警并冻结,而不是等用户投诉。

只做第 1 层,能防「同一次重试」;只做第 2 层,能防「并发双花」;只做第 3 层,能防「读落后于已提交写」,但防不住两个并发请求都在写之前读到 100;只做第 4 层,只能事后发现。四层一起做,才是「有效一次」的语义。

一个可运行的 SQLite 版本(Python 标准库,无需外部依赖)把前两层放在同一个事务里:

import sqlite3


def transfer(conn, tid, src, dst, amount):
    with conn:  # 事务:异常自动回滚
        cur = conn.execute(
            "INSERT INTO transfers(id, src, dst, amount, status) "
            "VALUES (?, ?, ?, ?, 'pending') ON CONFLICT(id) DO NOTHING",
            (tid, src, dst, amount))
        if cur.rowcount == 0:                 # 幂等键已存在
            return "duplicate"
        cur = conn.execute(
            "UPDATE accounts SET balance = balance - ?, version = version + 1 "
            "WHERE id = ? AND balance >= ?",   # 原子 guard
            (amount, src, amount))
        if cur.rowcount == 0:
            raise ValueError("insufficient funds")
        conn.execute(
            "UPDATE accounts SET balance = balance + ? WHERE id = ?",
            (amount, dst))
        conn.execute("UPDATE transfers SET status = 'done' WHERE id = ?", (tid,))
    return "ok"

配套的建表语句:

CREATE TABLE accounts (
  id      TEXT PRIMARY KEY,
  balance INTEGER NOT NULL,
  version INTEGER NOT NULL
);
CREATE TABLE transfers (
  id     TEXT PRIMARY KEY,
  src    TEXT NOT NULL,
  dst    TEXT NOT NULL,
  amount INTEGER NOT NULL,
  status TEXT NOT NULL
);

两次调用 transfer(conn, "t-1", "A", "B", 100) 的结果:第一次返回 "ok",第二次返回 "duplicate",A 只扣一次。示例为了简短返回 duplicate;生产实现应在命中幂等键时读出原 transfers 行,返回第一次的结果。再调用 transfer(conn, "t-2", ...) 会因 balance >= 100 不成立抛 insufficient funds,事务回滚,不会留下半条记录。注意这是单机 SQLite 的串行写模型;换成 PostgreSQL/MySQL 时,唯一约束和行锁的语义类似,但隔离级别、锁等待和死锁重试要按第 6 章处理。

还有一个更隐蔽的变体:客户端没有超时,但用户看到「处理中」后手动点了第二次。 这次是新的用户意图,客户端会生成新键,幂等键救不了;只有业务层的去重规则(同一用户、同一收款方、同一金额、时间窗)或条件写能挡住。幂等键识别「同一个意图的重试」,业务规则识别「看起来像新意图的重复点击」。 两者也不能互相替代。

最后,不要用「读主库」替代条件写。线性一致读只能保证「读的那一刻是最新」,读和写之间仍有时间窗;两个并发请求可以都线性一致地读到 100,然后都扣款。读一致性解决可见性,条件写/事务解决原子性。 这就是 4.7 节两个维度的分界线。

4.12 如何为业务选一致性

一致性选型不是「选一个最强的数据库」,而是按操作回答五个问题:读错了会怎样?写冲突能否合并?能接受多旧?分区时要不要可用?这个错误能否被对账修复?把答案落到机制上,而不是口号上。

业务读错的后果写冲突推荐组合失败时兜底
账户余额/支付资损、透支同一账户并发扣款线性一致读 + 条件扣款 + 幂等键账本对账、冻结、人工复核
库存/秒杀超卖、履约失败同一 SKU 并发扣减quorum 写或原子 CAS + 预留/回补超卖告警、补偿、限购
优惠券/积分重复发放、薅羊毛同券并发核销幂等键 + 唯一约束 + 条件更新对账、回收、风控
社交 Feed看到旧内容,可接受并发发帖/删除最终一致 + 单调读下拉刷新、版本号
评论/回复回复先于原帖,体验怪并发写不同对象因果一致 + 读己之写客户端重排、延迟渲染
购物车丢商品、数量错多端并发修改读己之写 + 合并策略(CRDT/LWW)合并提示、版本冲突
配置/开关灰度错、功能异常覆盖写线性一致读 + CAS + watch版本回滚、双人审批
权限/ACL越权、撤权延迟并发授权/撤销线性一致读 + 版本号 + 短 TTL 缓存失败关闭、审计
日志/分析统计偏差,可接受重复/乱序最终一致 + 幂等消费重放、去重、离线校正
跨地域用户资料旧头像/旧昵称多地域双写最终一致 + LWW/版本向量冲突提示、手动合并

这张表不是标准答案,而是一个论证模板:每一行都要能回答「为什么这一档够用」。如果答不上来,就往上加一档;如果加一档的延迟/可用性代价无法接受,就把业务改成能接受旧值(例如展示层加「可能延迟」提示),或者把关键判断移到写路径上。

成本可以粗略量化:

机制额外延迟(同地域)分区时可用性实现复杂度主要风险
线性一致读(leader/ReadIndex)+1 次 RTT,约 0.5–2 ms少数派不可用中跨地域时延放大
quorum 写(R+W>N)+1 次多数派落盘需多数派中仍不自动线性一致
会话 token / 读己之写0–1 次 RTT,可能等待高低只保护本会话
有界陈旧读0–lag高中需要 lag 上界和摘除
最终一致 + 冲突合并0高高(冲突逻辑)收敛慢、语义受限
幂等键 + 条件写0–1 次 RTT高中键设计/唯一约束

一个可执行的五问决策树:

  1. 读错会不会直接造成资损、超卖、越权或不可逆的外部副作用? 会 → 决策读走线性一致/leader,写走条件更新或事务。
  2. 能不能用幂等 + 对账把错误变成可修复的? 能 → 可以降一档,但必须真的实现对账和补偿,而不是写在文档里。
  3. 能接受多旧? 给出毫秒/版本上界;不能给出上界 → 不要选「最终一致」作为决策读。
  4. 并发写是否可交换/可合并? 可合并 → CRDT/版本向量;不可合并 → 单写者、CAS 或锁(第 9 章)。
  5. 分区时业务要可用还是要正确? 两者都要 → 把操作拆开:展示读降级、决策写失败关闭;不要指望一个开关同时满足。

4.12.1 一个走完流程的例子:库存扣减

假设 SKU 只有 1 件库存,两个用户同时下单。业务要求:不能超卖;下单接口在分区时宁可失败也不能卖重;商品详情页可以显示旧库存。

操作一致性选择机制失败时
详情页读库存有界陈旧/最终一致缓存 + lag_ms 上界显示「库存紧张」,下单时再校验
下单扣库存线性一致 + 原子条件写UPDATE stock SET qty=qty-1 WHERE sku=? AND qty>=1,leader/quorum返回 503/409,不降级
订单创建幂等order_id 唯一约束重试返回原订单
超时补偿最终一致预留 + TTL + 回补定时对账,释放未支付库存

两个常见错误:

  • 先读库存再扣:两个请求都读到 1,都通过 guard;除非扣减是原子条件写,否则超卖。读的一致性再强,也挡不住读写之间的窗口。
  • 用 quorum 写就以为线性一致:W=2, R=2 只保证读写集合相交;如果读只是取第一个响应、没有版本比较/读修复,仍可能拿到旧值。即使读到了最新,也不自动等于线性一致(并发写、sloppy quorum、跨 key 都不在 R+W>N 的覆盖范围)。库存扣减要靠条件写或共识,不是靠「多数派」三个字。

这个例子也说明为什么本节开头的表要按操作拆:同一个 SKU,详情读和下单写的选择可以完全不同。

三个常见的过度设计也要避免:

  • 所有读都走线性一致:跨地域延迟翻倍,读扩展消失,leader 成为热点;展示型读完全没必要。
  • 所有写都用全局事务:跨地域 2PC 的延迟和阻塞代价很高;很多写可以用「单分区事务 + outbox + 幂等消费」解决(第 6、7 章)。
  • 把「最终一致」当成「不用设计」:冲突解决、tombstone、反熵、对账都是实打实的代码;不写这些,最终一致只是「最终不一致」。

4.13 本章陷阱清单

12 条高频错误
  1. 把 CAP 当「三选二」:P 是网络属性,不是产品开关;分区时是在 C 和 A 之间选,无分区时三者可以同时成立,但强一致仍要付延迟(PACELC)。
  2. 把「最终一致」当成「很快一致」:定义没有时间上界;没有反熵、读修复、tombstone 保留和对账,收敛可能永远不到来。
  3. 用只读副本读余额再做扣款判断:读是陈旧的,guard 通过不代表余额够;这是 4.11 节二次转账的直接原因。
  4. 以为开了 majority 写就等于线性一致:MongoDB v0 的案例证明,选举协议依赖墙上时钟时,多数派确认的写仍可能被回滚。
  5. 把陈旧读当成脏读:陈旧读读到的是已提交的旧值,调高事务隔离级别不会修复;要改读路径(leader/ReadIndex/会话 token)。
  6. 把线性一致当成串行化:单键线性一致不提供跨键快照,也不阻止丢失更新;多对象原子性要靠事务/条件写。
  7. 用墙上时钟给跨机器操作排序:时钟偏斜会让「后发生的写」输给「时间戳更大的写」;用逻辑时钟、版本号或单观察者记录。
  8. 「先读后写」不加 CAS/条件更新:读和写之间的时间窗足够两个请求都通过检查;原子性要落在写路径上。
  9. 重试时换新的幂等键:服务端无法把第二次请求归并到第一次;键必须由客户端为逻辑请求生成一次,并在所有重试中复用。
  10. 把会话保证当成全局保证:读己之写/单调读只保护单个客户端;其他用户仍可能看到旧值,跨设备也不自动生效。
  11. 只在无分区、时钟同步的环境测一致性:Jepsen 的故障注入专门制造隔离主库和时钟偏斜;不注入这两个,测不到选举/回滚类问题。
  12. 检查器把 pending 操作当成功:超时操作可能已提交也可能没执行;线性一致性允许丢弃或补全 pending,强行赋值会制造假反例/假通过。

4.14 本章出口检查

检查项达标线没达标去哪
记录一段 history每个操作有 proc/kind/key/inv/resp/ret,能区分并发与实时序4.1
判断并运行检查器能手工解释 H_OK、H_STALE,并运行 python3 linearizability.py 得到与 4.2 节一致的输出4.2
解释顺序一致能构造「SC 但不线性一致」的历史,并给出 SC 见证全序4.3
解释因果一致能画出 post/reply 的 happens-before,指出 Carol 的违反点4.4
说出会话保证能区分读己之写、单调读、单调写、写后读,并各举一个事故4.4
说出收敛条件至少列出停止写、送达、确定性合并、tombstone、读路径、保留期4.5
精确复述 CAP能写出 C/A/P 定义,指出「三选二」错在哪,并给出 PACELC 的例子4.6
区分一致性与隔离能解释「每键线性一致但不一致快照」和「Serializable 但读到旧副本」4.7
观察 etcd 两种读能跑 --consistency=l/s,用 revision 判断陈旧,解释超时是可用性代价4.9
复述 Jepsen 案例能说出 v0 的时钟/选举链、v1 的 term bug、93/417 两个数字4.10
修复二次转账能给出幂等键、条件写、线性一致读、对账四层,并说明各防什么4.11
为业务选型能按「读错后果/写冲突/陈旧上界/分区取舍」给出组合与兜底4.12
即时自测 · 4

Q1 历史:write(x,1) 区间 [0,10],read(x) 区间 [5,15] 返回 0。它线性一致吗?如果读返回 1 呢?如果读区间是 [11,20] 返回 0 呢?

Q2 历史:A write(x,1) [0,5],B write(x,2) [6,10],C read(x)=1 [11,15],C read(x)=2 [16,20]。它是顺序一致吗?是线性一致吗?给出见证或反例。

Q3 三节点集群发生分区,2 个节点在多数派一侧,1 个在少数派。多数派一侧继续接受写并返回成功,少数派一侧对线性一致读返回 503。这个系统是 CP 还是 AP?「少数派返回 503」是否说明它不满足 CAP 的 A?

Q4 etcd 写返回 revision=58,随后从 follower 用 --consistency=s 读到 revision=57,用 --consistency=l 超时。这两个结果分别说明什么?哪个是 bug?

Q5 转账服务读到 A=100(实际已是 0),guard 通过后扣款 100;客户端超时后用新键重试,再次扣款。请按「身份、原子写、读、对账」四层给出修复,并说明只做「读主库」为什么不够。

答案

A1 返回 0 时线性一致:读与写并发,可以把读的线性化点放在写之前,读到初始值 0。返回 1 也线性一致:把读放在写之后即可。区间 [11,20] 返回 0 则违反:写已在 10 返回,读在 11 才开始,任何合法全序都必须让读看到 1。

A2 顺序一致,见证全序 write(x,1) -> read=1 -> write(x,2) -> read=2,它尊重 C 的程序序。但不线性一致:B 的写在 10 已返回,C 的第一次读在 11 才开始,实时序要求写 2 排在读 1 之前;此时寄存器值是 2,读不可能返回 1。所以不存在合法全序。

A3 它是 CP(更准确地说,在这个配置下分区时选择 C):多数派继续提供线性一致的服务,少数派拒绝线性一致读。CAP 的 A 要求每个未故障节点都返回非错误响应;少数派节点没有故障,却返回 503,因此在这个请求上不满足 A。CAP 并不要求系统在所有分区模式下都可用;它说的是不能同时满足 C 和 A。

A4 s 读返回 rev=57 是合法的 serializable(本地)读:follower 还没应用 rev=58,返回旧值符合 serializable 的定义,但它是陈旧读。l 读超时说明该 follower 无法联系 leader/quorum,无法确认最新值,因此拒绝回答;这是分区少数派的可用性代价,不是数据损坏。若 l 读成功却返回 rev=57,那才是线性一致实现的 bug。两者都不是「脏读」,旧值本身是已提交的。

A5 身份:客户端为逻辑转账生成并复用 transfer_id,服务端主键/唯一约束去重,重复请求返回原结果。原子写:扣款用 UPDATE ... WHERE balance >= 100 或 CAS,避免读-改-写窗口。读:guard 读走 leader/线性一致读,或带 applied_index 的会话 token。对账:账本余额与流水定期核对,异常冻结。只做「读主库」不够,因为读和写之间仍有时间窗,两个并发请求都能读到 100 再各扣一次;线性一致只保证读的可见性,不保证写的原子性。反过来,只做条件写也不够:客户端换新键重试仍会产生第二条业务记录,只是余额不会被扣成负数;对账和幂等仍然必要。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层4.6、4.9背景只借用「请求跨网络、可能丢失/延迟」的视角;不重写 TCP/HTTP
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)4.8、4.9背景连接超时/重传只引用,不展开内核路径
《Python 网络编程》socket/HTTP 客户端、并发4.2、4.4、4.11精选借用工程风格;检查器、会话 token、转账事务均为本书重写
《Linux 系统与内核》第 4/5 篇4.1–4.11 / 5.1–5.64.9交叉引用明确不重复 TCP/IP、epoll、HTTP/DNS/TLS、Kubernetes
本书自编 + Gilbert & Lynch、Abadi、JepsenCAP 证明、PACELC、MongoDB 3.4 分析4.6、4.10完整只引用定义、定理与公开测试数据,不复制论文/分析原文

下一站:知道了「读允许返回什么」,下一步要问「多个副本如何就同一个顺序达成一致」。进入 5 共识:Paxos 与 Raft,看 quorum 如何相交、term 如何阻止旧主、ReadIndex 如何把线性一致读落到协议上。