16.4 因果一致性
一句话
因果一致只约束有因果关系的操作:如果 A 在因果上先于 B,任何副本都不能先看到 B 再看到 A;并发操作则允许各副本按不同顺序看到——它比顺序一致弱,却是分区期间仍能保持可用的最强模型。
因果序:happens-before 的三个来源
因果序(happens-before,记作 →)由三部分构成:
| 来源 | 规则 | 例子 |
|---|---|---|
| 程序顺序 | 同一进程内,先发起的操作 → 后发起的 | 先发帖、后编辑 |
| 消息传递 | 发送 → 对应的接收 | 服务器收到请求后才回响应 |
| 传递性 | A → B 且 B → C,则 A → C | 回复的回复 |
两个操作如果没有 → 关系,就是并发的:谁先谁后没有因果依据。因果一致只对「有 → 关系」的操作提出要求,并发操作随便排。
因果一致要求(所有副本都必须满足):
a1 → b1 ⇒ 任何副本都不能先 b1 后 a1
因果一致不管(各副本可以不同):
c1 与 a1 并发 ⇒ R2 可以先 c1 后 a1,R0 可以先 a1 后 c1
因果气泡:论坛里「评论先于帖子」
一个典型场景:
- 用户发帖 P;
- 另一个用户看到 P,评论 C(C 因果依赖 P);
- 第三个用户刷新,看到了 C,却看不到 P。
这就是因果气泡(causal bubble):C 出现在 P 之前,违反了 P → C。用户看到的是「回复了一条不存在的帖子」。
| 模型 | 会不会出现「评论先于帖子」 | 原因 |
|---|---|---|
| 线性一致 | 不会 | 所有操作有实时全序 |
| 顺序一致 | 不会 | 所有进程共享一个全序,P 在 C 前 |
| 因果一致 | 不会 | 明确要求 P → C 的顺序 |
| 最终一致(无因果) | 会 | 只承诺最终收敛,中间顺序不限 |
🔑 因果一致解决的是「反直觉的顺序」,不是「新旧」。它允许你看到旧数据,但不允许你看到「结果先于原因」。
实现:向量时钟 + 依赖投递
因果一致的实现核心是两件事:
- 给每个操作带上因果元数据:向量时钟(version vector),
vc[i]表示「我已知副本 i 的第几个事件」; - 投递前检查依赖:消息只有在「它依赖的所有事件都已投递」后才能交给应用,否则进缓冲区等待。
投递条件(消息来自副本 s,时钟为 m.vc,本地时钟为 local):
对所有 k ≠ s: m.vc[k] ≤ local[k] // 其他副本的依赖都已满足
对 s 自己: local[s] == m.vc[s] - 1 // 这是 s 的下一个事件
满足则投递,并把本地时钟按分量取 max;不满足则挂起,等缺失的事件到达。
一个可运行的模拟:3 个副本,a1 是 R0 的写,b1 是 R1 在收到 a1 之后写的(因果依赖),c1 是 R2 并发写的。
package main
import "fmt"
type msg struct {
id string
from int
vc [3]int
}
func deliverable(m msg, local [3]int) bool {
for k := 0; k < 3; k++ {
if k == m.from {
if local[k] != m.vc[k]-1 {
return false
}
} else if m.vc[k] > local[k] {
return false
}
}
return true
}
func run(name string, local [3]int, localLog []string, pending []msg) {
log := append([]string{}, localLog...)
for len(pending) > 0 {
progress := false
var rest []msg
for _, m := range pending {
if deliverable(m, local) {
log = append(log, m.id)
for k := 0; k < 3; k++ {
if m.vc[k] > local[k] {
local[k] = m.vc[k]
}
}
progress = true
} else {
rest = append(rest, m)
}
}
pending = rest
if !progress {
break // 依赖永远缺失:真实系统要告警/修复,而不是静默丢弃
}
}
fmt.Printf("%-8s 投递顺序 %v\n", name, log)
}
func main() {
// a1: R0 的写,时钟 [1,0,0]
// b1: R1 收到 a1 之后写的,时钟 [1,1,0] —— 因果依赖 a1
// c1: R2 并发写的,时钟 [0,0,1]
a1 := msg{"a1", 0, [3]int{1, 0, 0}}
b1 := msg{"b1", 1, [3]int{1, 1, 0}}
c1 := msg{"c1", 2, [3]int{0, 0, 1}}
run("R0", [3]int{1, 0, 0}, []string{"a1"}, []msg{b1, c1})
run("R1", [3]int{1, 1, 0}, []string{"a1", "b1"}, []msg{c1})
run("R2", [3]int{0, 0, 1}, []string{"c1"}, []msg{b1, a1})
}
输出:
R0 投递顺序 [a1 b1 c1]
R1 投递顺序 [a1 b1 c1]
R2 投递顺序 [c1 a1 b1]
读法:
b1依赖a1,在三个副本上都排在其后——因果顺序被保住;c1与a1并发,在 R2 上排到了a1之前——允许,但注意这意味着三个副本的全序不同;- 因此这段历史满足因果一致,但不满足顺序一致(没有统一全序),更不满足线性一致。
⚠️ 缓冲不是「等一会儿再投递」这种超时策略。若依赖长期缺失(副本永久故障),系统要能从别处补齐依赖,或显式暴露「数据不完整」,而不是让消息永远卡在缓冲区。
因果一致 ≠ 因果收敛
因果一致只规定「有因果关系的操作按序可见」,没规定并发操作最后怎么合并。于是有两种强度:
| 模型 | 因果顺序 | 并发写的最终状态 | 保证 |
|---|---|---|---|
| 因果一致 | 保证 | 各副本可以不同 | 不要求收敛 |
| 因果收敛(causal convergence / strong causal) | 保证 | 看到相同写集合的副本状态相同 | 因果一致 + 确定性冲突解决 |
要实现因果收敛,必须在因果一致之上再加确定性合并:LWW(按某种全序)、多值(保留冲突让应用解决)、CRDT(可交换/结合/幂等的合并)。否则两个副本对同一组并发写可以得出不同结果,永远不收敛。
工程上说的「因果一致」通常默认包含收敛,因为不收敛的因果一致很难用。选型时要问清楚:只保因果序,还是也保最终一致?
代价与可用性
可用性:分区期间仍可写
因果一致不要求所有副本对「并发写」达成一致,所以每个副本都可以先接受本地写、带上因果元数据,等网络恢复后再交换:
- 分区期间:两侧都能继续读写(各自推进因果历史),不会像线性一致那样少数派直接不可用;
- 恢复后:按依赖补投递、做反熵,最终收敛(若加了确定性合并)。
有理论结果(Mahajan、Alvisi、Dahlin,2011)指出:在始终可用(分区期间也接受读写)的系统里,因果一致是可实现的最强一致性模型;再强(顺序一致/线性一致)就必然要在分区时牺牲可用性或等待。这条结论解释了为什么很多全球部署的系统把目标定在「因果 + 收敛」。
元数据成本
| 方案 | 元数据规模 | 适用 |
|---|---|---|
| 向量时钟(每副本一个分量) | O(副本数) | 副本数少、变动少 |
| 依赖矩阵(每对副本) | O(副本数²) | 小规模、强因果广播 |
| 按键依赖(per-key dependency) | 与该 key 的写历史相关 | 大规模、key 空间大(如 COPS) |
| 混合逻辑时钟(HLC) | O(1) | 近似因果,需配合其他机制(23.5 混合逻辑时钟 (HLC)) |
副本数上百时,O(N) 的向量时钟会撑爆消息头;实践里要裁剪(只保留活跃副本、按 key 粒度记录依赖、定期压缩)。裁剪的边界是:不能裁掉尚未被所有副本确认的依赖,否则会丢失因果信息。
读写路径的依赖跟踪
只给写带因果元数据还不够,读也要带:如果客户端读了 A,再基于 A 写 B,B 必须依赖 A(这叫做「写跟随读」,见 16.6 会话保证)。很多因果一致的 bug 就出在这里——写了 B 却没把 A 的依赖带上,别的副本就可能先看到 B 再看到 A,形成因果气泡。
工程实现对照
| 系统/机制 | 做法 | 说明 |
|---|---|---|
| Dynamo 风格 | 向量时钟检测冲突,保留多版本 | 它解决的是冲突检测,不保证因果投递;别混为一谈 |
| COPS | 显式跟踪 key 之间的因果依赖 | 跨 key 因果,元数据按需记录 |
| MongoDB 因果会话 | 服务端维护 cluster time,会话携带 | 会话内因果,跨会话不保证 |
| Antidote / 因果存储 | 向量时钟 + 依赖投递 + CRDT | 因果收敛的典型组合 |
| 会话保证 | 把因果范围缩到单个客户端 | 成本最低,但只保一个会话(16.6) |
反模式
| 反模式 | 后果 |
|---|---|
| 用物理时间戳当因果序 | 时钟偏差导致「结果先于原因」的因果倒置 |
| 只给写带依赖,读不带 | 写跟随读的链条断裂,出现因果气泡 |
| 因果元数据无限增长 | 消息头膨胀、比较开销上升,最终压垮吞吐 |
| 认为因果一致自动收敛 | 并发写各说各话,副本永久分歧 |
| 把因果一致当线性一致 | 并发写的顺序在不同副本上不同,跨副本读到「不同版本」 |
| 用超时替代依赖补齐 | 依赖缺失时静默丢消息或提前投递,因果被破坏 |
| 对账/资金类业务用因果一致 | 需要的是全局唯一顺序,因果不够 |
自检
Q1 「评论先于帖子」违反的是哪一种一致性?为什么最终一致会允许它?
▸ 答案:违反因果一致(发帖 → 评论,评论不能先于帖子出现)。最终一致只承诺「停止写入后最终收敛」,不约束中间过程的顺序;如果评论先同步到某个副本、帖子后到,用户就会先看到评论,直到帖子补到才恢复。因果一致通过携带依赖并要求「依赖未满足不投递」来消除这种气泡。
Q2 为什么说因果一致「比顺序一致弱」,却又「比最终一致强」?
▸ 答案:比顺序一致弱,是因为它不要求所有副本共享同一个全序——并发的 c1 与 a1 在不同副本上顺序可以不同(模拟输出里 R2 就是 c1 在前),而顺序一致要求唯一全序。比最终一致强,是因为它对有因果关系的操作有硬性顺序要求(a1 永远在 b1 前),最终一致连这个都不保证。
Q3 向量时钟在因果一致里到底解决什么问题?没有它会怎样?
▸ 答案:它解决「判断消息的依赖是否已满足」。接收方比较消息时钟与本地时钟,就能知道「这个写依赖的哪些事件我还没见过」,从而决定投递还是缓冲。没有它,接收方无法区分「这是新消息」和「这是一个依赖缺失的乱序消息」,只能靠超时猜测——要么提前投递破坏因果,要么无限等待。
Q4 一个跨三地的社交应用,团队想用因果一致。你会提醒他们哪三件事?
▸ 答案:① 元数据成本:副本/分片多时向量时钟会膨胀,要设计按 key 的依赖或裁剪策略;② 必须同时做确定性合并:因果一致本身不收敛,并发点赞/编辑要 CRDT 或明确的多值语义;③ 读也要带依赖:写跟随读的链条不能断,否则仍会出现因果气泡。补充:跨地域延迟决定反熵周期,监控「因果依赖等待时长」比看平均延迟更能发现异常。
Q5 「因果一致是分区期间可实现的最强模型」——这句话对工程选型意味着什么?
▸ 答案:意味着如果业务要求分区期间两侧都能读写(高可用),那么能拿到的最强保证就是因果一致(加收敛);想要线性一致/顺序一致,就必须在分区时让少数派不可用或等待。选型时不要幻想「既全可用又强一致」:要么接受因果 + 收敛,要么接受分区时的可用性损失(CAP 的取舍,见 17.3 分区期间的取舍)。
跨篇相关:因果序的严格定义与逻辑时钟见 23.6 happened-before 关系、23.3 Lamport 逻辑时钟、23.4 向量时钟;比它强的顺序一致见 16.3 顺序一致性;比它弱的最终一致见 16.5 最终一致性;单客户端的因果保证见 16.6 会话保证;并发写的冲突合并见 25.3 冲突检测与解决策略、25.6 CRDT (计数 / 集合 / 寄存器)(待补);分区期间可用性的取舍见 17.3 分区期间的取舍;反熵与复制延迟见 18.4 复制延迟与读一致性;物理时钟为什么不能当因果见 23.1 物理时钟与漂移;谱系定位见 16.7 一致性模型谱系图。