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 是客户端收到响应的时刻:
| # | 进程 | 操作 | inv | resp | 返回 |
|---|---|---|---|---|---|
| 1 | P1 | write(x, 1) | 0 | 10 | ok |
| 2 | P2 | read(x) | 4 | 8 | 1 |
| 3 | P1 | write(x, 2) | 12 | 16 | ok |
| 4 | P2 | read(x) | 18 | 22 | 2 |
| 5 | P3 | cas(x, 2, 3) | 20 | 25 | true |
| 6 | P3 | cas(x, 2, 4) | 26 | 30 | false |
这张表里已经藏着几个容易读错的点:
- 操作 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、新值 v | ok | 把 k 设为 v,总是成功 |
cas(k, e, n) | 键 k、期望值 e、新值 n | true / false | 当前值等于 e 时设为 n 并返回 true;否则不改并返回 false |
操作(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 发起却返回旧值,这才是真的违反线性一致。提交时间在操作区间内部,响应时间才是区间右端。
记录历史时有四个坑:
- 不要用墙上时钟跨机器比较。
time.time()会被 NTP 调整,两台机器的偏差可能是毫秒,也可能是秒。用time.monotonic_ns()在单机记录;跨客户端时先测偏差,只有偏差远小于操作间隔才敢比较。 - 不要只记录服务端的开始/结束。服务端处理时间只是区间的一部分,无法替代客户端的
resp。 - 不要丢掉失败的 CAS。
false也是一次合法的返回;把它当「没发生」会掩盖顺序错误。 - 不要把 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)的直觉是:每个操作看起来都在它的调用与响应之间某个瞬间原子地生效,整个系统像只有一个副本。 直觉很好用,但判断事故时不够精确。精确版本需要存在一个全序。
历史 H 是线性一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:
- 扩展实时序:若
a <rt b,则a在S中排在b之前; - 合法:从初始状态出发,按
S依次执行每个操作,每个操作的返回值都符合该对象的顺序规范(对寄存器而言:读返回此前最后一次写的值;CAS 的返回值与实际状态一致)。
等价说法:每个操作都存在一个线性化点(linearization point),落在它的 [inv, resp] 区间内;所有操作按线性化点排序后,结果与顺序规范一致。
图里只画了一个写和一个读,但已经包含判定所需的全部信息:写 x=1 在 t1 返回,读在 t2 > t1 才开始,读必须返回 1(或更新的值)。读返回 0 就是陈旧读,违反线性一致;读超时只是 unknown,不构成反例,因为待定操作可以被丢弃。
并发允许两种结果。 如果读在写返回之前就发起了(区间重叠),读返回 0 或 1 都可能是线性一致的:把读的线性化点放在写之前,它看到 0;放在写之后,它看到 1。很多人把「线性一致」误读成「读必须立刻看到正在进行的写」,这是错的。线性一致只要求存在某一个合法的全序,不要求读偏向新值。
反例的最小结构是「写已返回,读才开始,却读到旧值」:
| 历史 | 写区间 | 读区间 | 读返回 | 判定 |
|---|---|---|---|---|
| A | write(x,1) [0,10] | read(x) [12,20] | 1 | 线性一致 |
| B | write(x,1) [0,10] | read(x) [11,20] | 0 | 违反:读开始时写已返回 |
| C | write(x,1) [0,10] | read(x) [5,15] | 0 | 线性一致:可把读排在写前 |
| D | read(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)
四点读法:
H_OK的读在写返回之后开始,见证全序只有一种合法排列。H_STALE的读返回 0,但写已完成;任何全序都必须把写放在读前,读应返回 1,所以搜索失败。H_SC在 4.3 节会看到它顺序一致;检查器返回False只说明「不线性一致」,不说明「不一致」。判定器回答的是某个模型,不是笼统的『一致/不一致』。H_CAS说明 CAS 成功会改变状态,后续读必须看到 2;CAS 不是「读一下再写」,而是一个原子操作。
4.2.2 复杂度与工程判定
暴力搜索最坏是 n!,而判定线性一致性在一般情况下是 NP 完全的(Wing 与 Gong, 1993)。真实历史动辄百万操作,不能直接套上面的代码。工程上有四类优化:
| 优化 | 做法 | 代价 |
|---|---|---|
| 按对象分片 | 每个 key 独立检查;线性一致是局部性质 | 跨对象事务另算 |
| 约束传播 | 读的值只能来自某个写;先固定候选写,再查实时序 | 实现复杂 |
| 记忆化 | 对 (已放置集合, 状态) 去重;同一状态不重复搜索 | 状态爆炸时仍慢 |
| 分窗 | 把长历史切成小窗口,分别在每个窗口内检查 | 可能漏掉跨窗反例 |
Jepsen 的 Knossos 用的是这类搜索加剪枝;它报告的是一个具体反例(哪几个操作无法排列),而不是一个概率。工程上更实用的做法是:先用 session 保证、版本号和业务不变量做粗筛,再把可疑窗口喂给检查器。检查器不是证明系统正确,它只能证明「这段有限历史存在/不存在合法全序」。没有反例不等于正确,只等于这段历史没暴露问题。
若系统中每个对象的历史都线性一致,且对象之间没有跨对象的原子约束,则整个系统的历史线性一致。证明思路:把各对象的全序与实时序取并集,做拓扑排序;不同对象的操作可交换,冲突只可能在同一对象内,而同一对象已有合法全序(完整证明见 Herlihy 与 Wing, 1990)。
顺序一致没有这个性质:两个各自顺序一致的对象,合起来可能不满足顺序一致。这也是分片系统敢说「每分片线性一致」的理论依据,但它不保证跨分片快照一致(4.7 节)。
4.2.3 边界:线性一致不保证什么
线性一致只约束单对象的读/写/CAS 历史,不自动提供下面这些东西:
- 跨对象的原子读。分别线性一致地读 A 和 B,合起来可能不是某个时刻的快照。
- 事务的隔离。两个线性一致的写仍可能丢失更新,如果它们是「读-改-写」而不是 CAS。
- 低延迟。线性一致读通常要一次 leader/quorum 往返;跨地域时这往返就是几十毫秒。
- 分区期间的可用性。4.6 节会证明,分区时线性一致和可用性不能同时成立。
- 会话保证的跨副本版本。线性一致对单对象已经蕴含读己之写和单调读(同一客户端的操作在实时序上不重叠);但如果你用的是「每个副本各自线性一致」,把多个副本的读拼起来,会话仍可能回退(4.8 节)。
4.2.4 一次搜索的回溯记录
把 H_STALE 的搜索过程写出来,比看代码更容易理解「为什么不存在全序」:
| 步骤 | 可选操作 | 当前状态 | 说明 |
|---|---|---|---|
| 1 | write(x,1) | x=1 | 读的实时序前驱只有写,必须先放写 |
| 2 | read(x)=0 | x=1 | 读要求当前值为 0,失败;无其他候选 |
| — | — | — | 回溯到步骤 1,仍无其他候选,返回 False |
H_SC 的回溯更能说明「实时序」和「程序序」的差别:
| 步骤 | 可选操作 | 当前状态 | 说明 |
|---|---|---|---|
| 1 | write(x,1) | x=1 | B 的写有实时序前驱 A 的写 |
| 2 | write(x,2) | x=2 | C 的第一次读在 B 写返回之后,B 必须先放 |
| 3 | read(x)=1 | x=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 年提出的模型。它保留了「所有操作存在一个全序」,但删掉了「全序必须尊重实时序」这一条。
历史 H 是顺序一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:
- 尊重程序序:若在同一进程中
a先于b被调用,则a在S中排在b之前; - 合法:按
S执行时,每个操作的返回值符合顺序规范。
注意:S 不需要尊重跨进程的实时序。一个在实时上早已返回的写,可以被排在一个后来才发起的读之后。
最小的「顺序一致但不线性一致」历史就是上面的 H_SC:
| 进程 | 操作 | 区间 | 返回 |
|---|---|---|---|
| A | write(x,1) | [0,5] | ok |
| B | write(x,2) | [6,10] | ok |
| C | read(x) | [11,15] | 1 |
| C | read(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,不一定是线性一致。为了让同一客户端的读不退步,读还要固定副本或带上「已见前缀」令牌;否则第二个读打到更落后的副本,程序序就被破坏了。
| 实现 | 全序来源 | 读路径 | 得到什么 | 代价 |
|---|---|---|---|---|
| 单点序列器 + 全序广播 | 序列器分配序号 | 本地前缀 | 顺序一致 | 序列器是瓶颈/单点 |
| 主库串行日志 | 主库日志顺序 | 本地已应用 | 顺序一致(若读不确认) | 主库故障切换复杂 |
| 每写都等确认 | 主库/quorum | leader 确认 | 线性一致 | 每次读一次往返 |
| 无全序,仅反熵 | 无 | 任意副本 | 最终一致 | 冲突合并 |
注意第三行:「顺序一致」和「线性一致」的差别往往就是读要不要等确认。 等确认就把读放进了实时序,不等就只保证「存在某个全序」。这也解释了为什么很多系统把「强读」做成一个显式选项:默认本地读是 SC/陈旧读,关键读才付一次往返。
顺序一致的另一个代价是全序本身。没有全局日志、没有序列器、没有共识,就没有 SC;多个副本各自按本地顺序应用,得到的是因果或最终一致。SC 不是「不协调的强一致」,它只是「不要求实时确认的强顺序」。
4.4 因果一致性与会话保证
线性一致和顺序一致都要求全序,代价是协调。很多系统退一步:只要求有因果关系的操作被所有副本以相同顺序看到,并发操作可以各排各的。 这就是因果一致(causal consistency)。
先看它要防的那个经典反例。论坛里,Alice 发帖 P,Bob 读到 P 后回复 R,Carol 读到了 R 却读不到 P:
| 步骤 | 进程 | 操作 | 观察到 |
|---|---|---|---|
| 1 | Alice | write(post, P) | ok |
| 2 | Bob | read(post) | P |
| 3 | Bob | write(reply, R) | ok |
| 4 | Carol | read(reply) | R |
| 5 | Carol | read(post) | not_found |
Carol 看到了回复却看不到被回复的帖子,这是「因果倒置」。因果一致禁止的正是这种形状;它不禁止 Carol 在 Bob 回复之前看不到 P(那时 P 和 R 并发),也不要求 Carol 看到 P 的那一刻 Bob 的回复已经存在。
happens-before(→)是满足以下规则的最小关系:
- 程序序:同一进程中先发起的操作
→后发起的操作; - 读到即因果:若读
r读到了写w的值,则w → r; - 传递:若
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 < V2 | V1 <= 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)是最容易被口号化的模型。它的正式承诺其实很短:如果不再有新的写,所有副本最终会返回同一个值。 注意三个词:最终(没有时间上界)、所有(每个副本)、同一个(收敛,不是「都最新」)。
对每个对象 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 s | 10:00:00 | 10:00:05 | W1 |
W2 x=2 | 0 | 10:00:02 | 10: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:
| 时间 | 事件 | A | B | 读到的值 |
|---|---|---|---|---|
| t0 | 客户端写 A,A 返回成功 | 1 | 0 | — |
| t1 | A 在复制给 B 之前宕机 | 1 | 0 | 从 B 读 → 0(陈旧) |
| t2 | 客户端读 B,触发读修复 | 1 | 0 | 读修复无法从 B 修 A(B 没有新值) |
| t3 | A 恢复,反熵交换 Merkle 树 | 1 | 1 | 从 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」不是「所有副本一样」,而是线性一致(原子对象)。
- 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(外部一致性) | C | C(付 TrueTime 等待) | 跨地域提交要等不确定性窗口 |
| etcd(默认线性一致读/写) | C | C(一次 ReadIndex/leader 往返) | 少数派拒绝服务 |
etcd(--consistency=s) | A(可能陈旧) | L(本地读,不协调) | 同一集群,按请求切换 |
| DynamoDB(最终一致读) | A | L | 强一致读可另行指定,按请求付延迟 |
Cassandra(QUORUM 读/写) | A/C 可调(多数派才可用) | L | quorum 提高交集,不自动等于线性一致 |
| MongoDB(v1 + majority + linearizable) | C | C(主库读) | 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:
| 步骤 | 操作 | 副本一致性 | 事务隔离 | 结果 |
|---|---|---|---|---|
| 1 | T1 在 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 L | t-1 已提交,rev=58 | 响应没有到达客户端 | 继续接受新请求 |
| follower F1 | 本地状态停在 rev=57 | rev=58 已经存在 | 用旧值回答读请求 |
这就是第 1 章说的「部分失败」在数据面的版本:每个节点都只掌握了事实的一部分,但每个节点都在基于自己那部分事实做决定。 分区不会「制造旧数据」,它只是让旧数据的存在时间从毫秒级变成无界;真正造成事故的是客户端把「读到的值」当成了「当前的值」。
陈旧读(stale read)是一次读返回了某个已经提交、但已经被更新的写覆盖的值。它与脏读不同:脏读读到的是未提交的数据;陈旧读读到的数据在某个时刻是合法的,只是不是最新版本。
形式化地说,若写 w 在 t_w 返回,读 r 在 t_r > t_w 发起(t_r 晚于 w 的响应),而 r 返回的版本早于 w 写入的版本,则 r 是陈旧读。这个定义默认 w 与 r 访问同一对象;跨对象的「不一致快照」是 4.7 节的问题,不是单对象陈旧读。
分区期间陈旧读的放大路径有五条,按出现频率排序:
| # | 路径 | 机制 | 为什么分区时更严重 |
|---|---|---|---|
| 1 | 异步复制 lag | follower 的 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_revision | key 最后一次修改的 revision | 应等于最近一次写回执的 revision | 小于写回执,说明读到了旧版本 |
raftIndex / raftAppliedIndex | 已提交日志索引 / 已应用索引 | 两者接近;follower 落后 leader 有界 | applied 长期落后,或与 leader 差距持续扩大 |
Status.errors | 成员报告的异常 | 空 | 非空,优先排查成员健康 |
| 延迟 | 本地读 vs leader 确认 | s 通常 0.1–1 ms;l 多一次往返 | l 在无 quorum 时超时,是可用性代价 |
三条读法:
- 线性一致读成功时,结果不能比已经返回的写旧。 如果写回执是 rev=52,读却返回 rev=51,这不是「最终一致」,而是线性一致读的实现 bug(或者你以为发的是
l,实际发的是s)。 - serializable 读落后多少,要有一个上界。 例如「读流量只允许打到 applied lag ≤ 5 s 的副本」;超过就摘除或降级。没有上界的
s读,在分区时可能落后几分钟。 - 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}
服务端根据模式选择读路径,并在响应里带回证据:
| 模式 | 服务端行为 | 响应证据 | 失败时客户端 |
|---|---|---|---|
linearizable | leader/ReadIndex 确认 | revision、served_by | 返回错误,不降级 |
bounded_staleness | 本地读,检查 lag ≤ 上界 | revision、applied_index、lag_ms | 超界则换副本或升级为线性读 |
session | 追到会话 token | applied_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 是单调递增的,但它被锚定在节点的本地墙上时钟上。于是出现了分析里的经典场景:
- 节点 A 是 primary,与集群隔离 10 秒,期间接受
w1、w2,optime 记为 10、11,但无法复制给多数派。 - 另一侧 B 当选 primary,接受
w3,optime 记为 10,并以多数派写确认;w3复制到了 C。 - 隔离解除,A 与 C 选举。A 的 optime 11 大于 C 的 10,A 当选。
- 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 线性一致 |
这份分析给出的教训可以直接迁移到任何系统:
- 「多数派确认」是一个条件承诺,不是无条件事实。 它依赖选举协议不会让一个缺少该写的节点当选;如果选举依赖墙上时钟,时钟偏斜就是数据丢失的故障注入项。
- 读一致性不能修复写丢失。 如果已确认的写被回滚,
linearizable读再强也只能返回「当前存在的值」;它无法让不存在的写复活。 - 协议版本是配置的一部分。 同一个 MongoDB 版本,v0 与 v1 的一致性行为不同;升级/切换协议要和升级代码一样做验证。分析特别指出 v1 不是 Raft,保留了 rollback 等额外复杂度。
- 测试要注入时钟偏斜和分区,而不只是杀进程。 只测「节点崩溃后重启」会漏掉「隔离主库 + 快时钟赢得选举」这条路径。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 章的项目二提供了分区注入接口,按下面的表设计用例:
| 故障 | 注入方式 | 观察 | 期望 |
|---|---|---|---|
| 隔离 follower | fault_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)。于是:
| 时间 | 事件 | A | B | 读/写版本 |
|---|---|---|---|---|
| t0 | 初始 | 100 | 0 | rev=57 |
| t1 | t-1 提交,响应丢失 | 0 | 100 | rev=58 |
| t2 | 客户端超时,生成 t-2 重试 | 0 | 100 | — |
| t3 | t-2 从副本读 A | 0 | 100 | 读到 100,rev=57 |
| t4 | guard 通过,t-2 扣款 | -100 | 200 | rev=59 |
| t5 | 对账发现流水有两笔 100 出账,但用户只授权了一笔 | -100 | 200 | 客户 A 多损失 100 |
注意两个错误是独立的,任何一个被修复都能阻止事故:
- 如果 t-2 复用 t-1 的幂等键,服务端的唯一约束会发现「这个意图已经执行」,直接返回原结果;
- 如果 t-2 的读是线性一致的(或读己之写),它会看到 A=0,guard 拒绝;
- 如果扣款是原子条件写(
balance >= 100),即使 guard 通过,第二次扣款也会失败; - 如果对账把流水与用户授权关联起来,能在 t4 之后立即发现并冻结,或者扣款有「单日单账户最多转 100」的业务约束,也能兜住。
四个「如果」对应四层防御,不能只做一层。幂等键解决「同一个意图被执行两次」,条件写解决「基于旧值的写覆盖新值」,线性一致读解决「看到旧值」,对账解决「前三个都漏了」。 它们防的不是同一件事。
客户端发起 transfer(t-1, A, B, 100)。服务端提交成功,响应丢失;客户端超时后用新键 t-2 重试。重试的读落到 lag 副本,看到 A=100,guard 通过;服务端没有把流水关联到用户授权(或业务去重键),也没有条件扣款,于是再次扣款。结果 A=-100、B=200。
修复分四层:
- 身份:客户端为每个逻辑转账生成
transfer_id,所有自动/手动重试复用它;服务端transfers.id主键唯一。 - 原子写:扣款用
UPDATE ... WHERE balance >= amount或 CAS(版本号),不要先读后写。 - 读:guard 读走 leader/线性一致读,或至少带
applied_index >= 写回执的会话 token。 - 对账:把流水与用户授权/外部账单定期核对;发现同一授权对应多笔出账或账实不符时告警并冻结,而不是等用户投诉。
只做第 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 | 高 | 中 | 键设计/唯一约束 |
一个可执行的五问决策树:
- 读错会不会直接造成资损、超卖、越权或不可逆的外部副作用? 会 → 决策读走线性一致/leader,写走条件更新或事务。
- 能不能用幂等 + 对账把错误变成可修复的? 能 → 可以降一档,但必须真的实现对账和补偿,而不是写在文档里。
- 能接受多旧? 给出毫秒/版本上界;不能给出上界 → 不要选「最终一致」作为决策读。
- 并发写是否可交换/可合并? 可合并 → CRDT/版本向量;不可合并 → 单写者、CAS 或锁(第 9 章)。
- 分区时业务要可用还是要正确? 两者都要 → 把操作拆开:展示读降级、决策写失败关闭;不要指望一个开关同时满足。
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 本章陷阱清单
- 把 CAP 当「三选二」:P 是网络属性,不是产品开关;分区时是在 C 和 A 之间选,无分区时三者可以同时成立,但强一致仍要付延迟(PACELC)。
- 把「最终一致」当成「很快一致」:定义没有时间上界;没有反熵、读修复、tombstone 保留和对账,收敛可能永远不到来。
- 用只读副本读余额再做扣款判断:读是陈旧的,guard 通过不代表余额够;这是 4.11 节二次转账的直接原因。
- 以为开了 majority 写就等于线性一致:MongoDB v0 的案例证明,选举协议依赖墙上时钟时,多数派确认的写仍可能被回滚。
- 把陈旧读当成脏读:陈旧读读到的是已提交的旧值,调高事务隔离级别不会修复;要改读路径(leader/ReadIndex/会话 token)。
- 把线性一致当成串行化:单键线性一致不提供跨键快照,也不阻止丢失更新;多对象原子性要靠事务/条件写。
- 用墙上时钟给跨机器操作排序:时钟偏斜会让「后发生的写」输给「时间戳更大的写」;用逻辑时钟、版本号或单观察者记录。
- 「先读后写」不加 CAS/条件更新:读和写之间的时间窗足够两个请求都通过检查;原子性要落在写路径上。
- 重试时换新的幂等键:服务端无法把第二次请求归并到第一次;键必须由客户端为逻辑请求生成一次,并在所有重试中复用。
- 把会话保证当成全局保证:读己之写/单调读只保护单个客户端;其他用户仍可能看到旧值,跨设备也不自动生效。
- 只在无分区、时钟同步的环境测一致性:Jepsen 的故障注入专门制造隔离主库和时钟偏斜;不注入这两个,测不到选举/回滚类问题。
- 检查器把 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 |
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.6 | 4.9 | 交叉引用 | 明确不重复 TCP/IP、epoll、HTTP/DNS/TLS、Kubernetes |
| 本书自编 + Gilbert & Lynch、Abadi、Jepsen | CAP 证明、PACELC、MongoDB 3.4 分析 | 4.6、4.10 | 完整 | 只引用定义、定理与公开测试数据,不复制论文/分析原文 |
下一站:知道了「读允许返回什么」,下一步要问「多个副本如何就同一个顺序达成一致」。进入 5 共识:Paxos 与 Raft,看 quorum 如何相交、term 如何阻止旧主、ReadIndex 如何把线性一致读落到协议上。