16.2 线性一致性
一句话
线性一致要求每个操作在自己的调用与返回之间原子地生效,且不重叠的操作按实时先后生效;它把「物理时间」换成了「逻辑全序」,所以可实现,代价是每次需要新鲜度的读写都要付一轮协调(RTT + 共识)。
定义:一段历史、一个全序、一条实时约束
把系统行为记成历史 H:每个操作有 invocation(调用)与 response(返回)两个事件,读还带返回值。定义两个偏序:
- 程序顺序:同一进程内,先发起的操作在前;
- 实时顺序
<_H:操作 A 的 response 早于操作 B 的 invocation,则 A 在 B 前。
H 线性一致,当且仅当存在一个顺序历史 S,满足:
S是H中所有操作的一个全序,且尊重<_H;- 按
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 一致性模型谱系图。