16.2 线性一致性

一句话

线性一致要求每个操作在自己的调用与返回之间原子地生效,且不重叠的操作按实时先后生效;它把「物理时间」换成了「逻辑全序」,所以可实现,代价是每次需要新鲜度的读写都要付一轮协调(RTT + 共识)。

定义:一段历史、一个全序、一条实时约束

把系统行为记成历史 H:每个操作有 invocation(调用)与 response(返回)两个事件,读还带返回值。定义两个偏序:

  • 程序顺序:同一进程内,先发起的操作在前;
  • 实时顺序 <_H:操作 A 的 response 早于操作 B 的 invocation,则 A 在 B 前。

H 线性一致,当且仅当存在一个顺序历史 S,满足:

  1. S 是 H 中所有操作的一个全序,且尊重 <_H;
  2. 按 S 依次执行操作,每个读的返回值都与单副本语义一致。

换一种更好用的说法:每个操作都有一个线性化点(linearization point),落在它的调用与返回之间;所有操作按线性化点排成一个全序,系统的行为等价于「所有操作按这个全序依次执行」。读到的值,就是它之前最近一次写。

进程 P1:  ├── W(x=1) ──┤
                        ▲ 线性化点可以在这里
进程 P2:            ├── R(x) ──┤
                    ▲ 也可以在这里,但不能在 R 返回之后

🔑 线性一致 = 单副本幻觉 + 实时约束。用户在任何一个节点、任何时刻读,都像是读同一份数据的当前值。

一个例子看懂判定

H2:
P1:  ├─ W(x=1) ─┤
P2:                  ├─ R(x) → 0 ─┤
     t=0        t=1  t=2         t=3

W 在 t=1 返回,R 在 t=2 才发起。实时顺序要求 W < R,所以 R 必须读到 1(或更晚的写)。读到 0 说明它读的是「W 生效前」的状态——线性化点无论怎么放,都不可能在 R 发起之前还把 W 排在 R 后面。这不是线性一致。

对比同一段历史在顺序一致下的判定:顺序一致不要求尊重实时顺序,只要存在一个全序让所有进程的顺序一致即可,于是「R 先、W 后」是一个合法全序——H2 顺序一致成立,线性一致不成立。两者的分界线就在「实时顺序」这一条上(16.3 会展开)。

判定器:把定义写成代码

历史上所有操作数量不多时,可以暴力枚举全序来判定。下面这个程序对 5 段历史同时判定「线性一致」与「顺序一致」:

package main

import "fmt"

type op struct {
	proc       int
	kind       byte // 'W' 或 'R'
	val        int
	start, end int
}

var histories = []struct {
	name string
	ops  []op
}{
	{"H1 写完成后再读,读到新值", []op{
		{0, 'W', 1, 0, 1},
		{1, 'R', 1, 2, 3},
	}},
	{"H2 写完成后仍读到旧值", []op{
		{0, 'W', 1, 0, 1},
		{1, 'R', 0, 2, 3},
	}},
	{"H3 同一进程读到 1 再读到 0", []op{
		{0, 'W', 1, 0, 1},
		{1, 'R', 1, 2, 3},
		{1, 'R', 0, 4, 5},
	}},
	{"H4 两个进程看到的顺序相反", []op{
		{0, 'W', 1, 0, 1},
		{0, 'W', 2, 2, 3},
		{1, 'R', 1, 4, 5},
		{1, 'R', 2, 6, 7},
		{2, 'R', 2, 4, 5},
		{2, 'R', 1, 6, 7},
	}},
	{"H5 并发写,两个读各看到一个", []op{
		{0, 'W', 1, 0, 5},
		{1, 'W', 2, 1, 6},
		{2, 'R', 1, 7, 8},
		{3, 'R', 2, 7, 8},
	}},
}

func validOrder(p []int, ops []op) bool {
	reg := 0
	for _, idx := range p {
		o := ops[idx]
		if o.kind == 'W' {
			reg = o.val
		} else if reg != o.val {
			return false
		}
	}
	return true
}

// realTime=true 检查线性一致,false 检查顺序一致
func exists(ops []op, realTime bool) bool {
	n := len(ops)
	p := make([]int, n)
	used := make([]bool, n)
	var dfs func(int) bool
	dfs = func(k int) bool {
		if k == n {
			return validOrder(p, ops)
		}
		for i := 0; i < n; i++ {
			if used[i] {
				continue
			}
			ok := true
			// 已排在前面的操作里,不能有「比 i 更晚完成」的
			if realTime {
				for j := 0; j < k; j++ {
					if ops[i].end <= ops[p[j]].start {
						ok = false
						break
					}
				}
			}
			// 同一进程里更早发起的操作必须已排
			for j := 0; j < n && ok; j++ {
				if !used[j] && ops[j].proc == ops[i].proc && ops[j].start < ops[i].start {
					ok = false
				}
			}
			if ok {
				p[k] = i
				used[i] = true
				if dfs(k + 1) {
					return true
				}
				used[i] = false
			}
		}
		return false
	}
	return dfs(0)
}

func main() {
	fmt.Printf("%-34s %-14s %-14s\n", "历史", "线性一致", "顺序一致")
	for _, h := range histories {
		fmt.Printf("%-34s %-14v %-14v\n", h.name, exists(h.ops, true), exists(h.ops, false))
	}
}

输出:

历史                                 线性一致           顺序一致
H1 写完成后再读,读到新值                     true           true
H2 写完成后仍读到旧值                       false          true
H3 同一进程读到 1 再读到 0                  false          false
H4 两个进程看到的顺序相反                     false          false
H5 并发写,两个读各看到一个                    false          true

逐条读:

历史 违反了什么 结论
H1 无 两者都成立
H2 实时顺序(写完成后读仍见旧值) 顺序一致成立,线性一致不成立
H3 程序顺序(同一进程先读 1 后读 0,中间没有写 0) 都不成立
H4 全序(两个进程看到的写顺序相反,无法共存于一个全序) 都不成立
H5 实时顺序 + 读结果(两个写在读之前都完成,读却分别看到不同写) 顺序一致成立,线性一致不成立

⚠️ 暴力枚举只适合教学与小规模验证。生产级检查器(Jepsen 的 Knossos/Elle)用分治、按进程/对象切分与剪枝,把复杂度从 n! 压到可处理范围;原理仍是「找一个尊重实时顺序的全序」。

它是「单对象」的,不是「事务」

这是最常被混淆的一点:

模型 约束粒度 实时顺序 解决什么
线性一致 单个对象/单条记录 是 单个 key 看起来像单副本
可串行化 一组事务 否 事务之间等价于某个串行执行
严格可串行化 一组事务 是 串行化 + 事务间实时顺序

线性一致不提供跨对象原子性:你对 x 的读是线性一致的,对 y 的读也是线性一致的,但「同时读 x 和 y」不保证看到同一个时刻的快照。那需要事务(可串行化/快照隔离),见 44.10 事务与隔离级别的实现(待补)。

反过来,可串行化也不蕴含线性一致:一个事务系统可以把「先提交的事务」排到「后发起的事务」后面,只要不违反事务语义——它不尊重实时。Spanner 的严格可串行化把两者相加,代价是 commit-wait(16.1 已对比过三者)。

代价:协调的延迟下界

线性一致的新鲜读必须确认自己看到的是最新值,这就要和「最新值的持有者」通信:

读法 需要几次跨节点往返 是否线性一致 备注
Leader 读(写也走 leader) 1 RTT(读) 是 leader 可能是瓶颈
租约读(leader 持有租约) 0 RTT(本地) 是(租约有效期内) 依赖时钟;租约到期有窗口
Read-index + quorum 确认 1 RTT 是 常用;不依赖时钟
Follower 本地读 0 RTT 否 读到的是「已应用前缀」,可能陈旧
Quorum 读(R + W > N) 1 RTT 不一定 见下面的坑

用物理数字看下界:

链路 RTT 串行线性一致操作上限 1000 路流水线理论上限
0.5 ms(同城) 2,000 op/s 2,000,000 op/s
2 ms(同城跨区) 500 op/s 500,000 op/s
30 ms(北京–上海) 33 op/s 33,000 op/s
180 ms(中美) 6 op/s 5,556 op/s

(上限按「每个操作 1 个 RTT」估算,实际还要加排队、落盘与批处理收益。)这张表说明两件事:延迟下界由 RTT 决定,吞吐可以靠流水线与批处理补。所以跨地域线性一致的代价主要不是「每秒能做的事少」,而是「每次都要等一个 RTT」;对延迟敏感的业务会被这一条卡死。

实现:写靠共识,读靠「怎么确认自己够新」

组件 做法 关键点
写 单 leader + 共识日志(Raft/Paxos) 提交必须等多数派持久化(21.6 Raft:日志复制)
读 A 读也进日志 简单、强,但读也付一轮共识
读 B Read-index leader 确认自己仍是 leader 后,等到 commitIndex ≥ readIndex 再读;1 RTT
读 C 租约(lease) leader 在租约期内直接本地读;依赖时钟上界(21.11 领导者租约)
读 D Follower 读 + 有界陈旧 不是线性一致,只能承诺「落后不超过 X」
跨地域 共识组放在多数派区域 写延迟 = 到多数派的 RTT;读可走 read-index

一个必须点名的坑:W + R > N 不等于线性一致

Quorum 只能保证「读集合与写集合有交集」,但交集里的那个副本可能:

  • 还没应用最新的写(异步复制);
  • 用本地时钟做 LWW,时钟偏差让旧写获胜;
  • 并发写的定序规则不唯一(两个副本各认一个赢家)。

要让 quorum 读线性一致,还需要:写按全序提交、读返回确定的最高版本、必要时 read-repair/read-index。证明与边界见 21.2 Quorum 与 W + R > N 的证明。这也是「Cassandra 配了 QUORUM 就强一致」这句话不成立的原因。

测试:怎么证明它不线性一致

手段 作用
历史记录 + 检查器 采集每个操作的调用/返回/返回值,离线判定
Jepsen 式故障注入 分区、时钟偏移、进程暂停、leader 切换
针对性场景 写后立刻从 follower 读;leader 切换后读;网络分区两侧读
监控指标 复制延迟、read-index 等待时间、租约剩余时间

常见 bug 都出在「读路径」而不是「写路径」:写入走共识没问题,但读为了性能走了本地副本、缓存或过期租约,线性一致就被破坏了。缓存层尤其危险:即使存储是线性一致的,前面加一层本地缓存,读就不再线性一致(41.7 多级缓存 讲的陈旧上界就是这个问题的量化)。

反模式

反模式 后果
「用了 Raft 就是线性一致」 follower 本地读、缓存读都会破坏;读路径必须显式确认
「W + R > N 就是线性一致」 异步应用、LWW 时钟、并发写定序都会破坏
在存储前加本地缓存还宣称线性一致 缓存命中直接返回旧值,绕过协调
把线性一致当多对象事务 跨 key 读到不同时刻的快照,出现「转账看见一半」
跨地域强一致却不接受 RTT 架构目标与物理下界冲突,最终靠「偷偷本地读」妥协
leader 切换后立刻读旧 leader 旧 leader 可能还没意识到自己已不是 leader(脑裂,18.6 脑裂与防护
只压测吞吐,不测故障下的读 正常路径都通过,切主/分区时读出旧值

自检

Q1 线性一致和顺序一致,能不能用一段历史同时区分?举一个具体例子。

▸ 答案:能。P1: W(x=1) 在 t=0–1 完成;P2: R(x)→0 在 t=2–3。线性一致要求尊重实时顺序,W 必须在 R 之前生效,所以 R 必须读到 1,读到 0 违反线性一致。顺序一致只需要存在一个全序,把 R 排在 W 之前即可(P1、P2 各自的程序顺序都没被违反),所以这段历史顺序一致成立。上文的判定器输出里 H2 就是这一行。

Q2 为什么「follower 本地读」不是线性一致?如果复制延迟只有 1ms,能不能算?

▸ 答案:不能。follower 读到的是「已经应用到本地的前缀」,它可能落后于 leader 已提交的最新写;1ms 的延迟只意味着「大多数时候落后很小」,不构成保证——一次 GC 停顿、网络抖动或切主就能让它读到旧值。线性一致要求「读之前确认自己看到了最新已提交值」,这需要 read-index/租约/走 leader,而不是「通常很快」。

Q3 一个系统写入走 Raft,读走 follower 本地读。它提供了什么一致性?怎么改成线性一致读?

▸ 答案:写是线性一致的(按日志全序提交),读不是:follower 可能落后,读到已应用前缀。整体不满足线性一致,但满足「顺序一致」的近似(所有副本按同一日志顺序应用,读是某个前缀)——详见 16.3 顺序一致性。改成线性一致读:读请求走 leader + read-index,或 leader 租约内本地读,或读也进日志;代价是每次读多 1 RTT 或依赖时钟租约。

Q4 为什么说「线性一致是可组合的」,而顺序一致不是?这和工程选型有什么关系?

▸ 答案:线性一致是局部性质:如果每个对象各自线性一致,那么把它们组合起来,整段历史仍然线性一致(每个操作在各自对象的线性化点上原子生效,可以合并成一个全序)。顺序一致不满足这一点:两个对象各自顺序一致,组合后的历史可能找不到一个统一全序(反例见 16.3)。工程含义:如果你要把多个对象拼成一个更大的一致性单元(比如用多个寄存器实现一个事务),选线性一致的对象;顺序一致的对象组合后不保证整体顺序一致。

Q5 跨地域部署,业务要求「读到自己刚写的」,团队想用线性一致读。你会怎么评估?

▸ 答案:先算物理账:线性一致读至少 1 RTT(read-index)或依赖租约;北京–上海 30ms、中美 180ms,意味着每次读都要等这么久,QPS 高时还要乘上排队。然后问业务:需要的是全局线性一致,还是会话级读己之写?后者可以用会话令牌/粘性路由在最终一致存储上实现(16.6 会话保证),成本低一个数量级。只有当业务真的要求「任何客户端、任何时刻读到的都是最新已提交值」(如余额、库存、锁)时,才为线性一致付跨地域 RTT。

跨篇相关:严格一致的不可实现性与三者对比见 16.1 严格一致性;去掉实时约束后的弱化版见 16.3 顺序一致性;分区期间在 C 与 A 之间的取舍见 17.3 分区期间的取舍;无分区时的延迟与一致性权衡见 17.4 PACELC:无分区时的延迟-一致性权衡;复制延迟如何破坏读一致性见 18.4 复制延迟与读一致性;脑裂与旧 leader 见 18.6 脑裂与防护;quorum 的证明见 21.2 Quorum 与 W + R > N 的证明;租约读见 21.11 领导者租约;RTT 的物理下界见 8.1 TCP/IP 与三次握手;会话级保证见 16.6 会话保证;完整谱系见 16.7 一致性模型谱系图。