目录
5798 字
29 分钟
CAP 不是三选二:分区发生时,多副本系统到底在承诺什么

一、一个分区现场:两边都觉得自己是对的#

凌晨两点,两个机房之间的光纤被施工挖断。监控上看到的不是「机房 B 挂了」,而是两个机房都活着,都还在接受写入。

  • 机房 A 的用户把订单改成「已支付」;
  • 机房 B 的用户把同一笔订单改成「已取消」;
  • 网络恢复后,两条更新开始互相覆盖,最终留下哪一条,取决于谁的时钟更准、谁的写入更晚到达。

事后复盘时,有人说:「我们是 AP 系统,选择了可用性,这是 CAP 的代价。」这句话听起来很专业,但它其实什么都没解释——AP 不是一个可以挂在嘴边的标签,而是一组非常具体的承诺:分区期间哪些请求还能成功、失败时返回什么、恢复后如何收敛、以及旧数据会不会覆盖新数据。

这篇文章只回答三个问题:

  1. 分区发生时,系统还能不能继续服务?如果能,它承诺读到什么?
  2. 分区恢复后,旧副本会不会把新数据覆盖掉?
  3. 为什么 Raft 的「多数派」能同时防住这两件事?

为了不停留在概念层面,文末会跑一个 5 节点的 Raft 模拟:人为制造 {A,B,C} | {D,E} 的 3/2 分区,观察多数派继续提交、少数派把任期涨到 5 仍然选不出 leader、以及恢复后日志如何收敛。代码不长,输出是真实跑出来的。

二、CAP 的准确版本:P 不是可选项#

先看最常见的三个误读。

常见说法问题在哪更准确的说法
CAP 是「三选二」,可以选 CAP(分区)不是你能选掉的;网络分区是客观事实没有分区时可以同时满足 C 和 A;分区发生时,只能在 C 和 A 之间取舍
C 就是「数据一致」「一致」在不同语境下指完全不同的东西CAP 里的 C 特指线性一致性(linearizability)
最终一致就是「最终会正确」没有冲突解决策略,就只会收敛到某个值,不一定是正确的值最终一致 = 不再有新写入后副本收敛;正确性取决于冲突怎么解

Brewer 在 2000 年的 PODC 主题演讲里提出这个猜想,Gilbert 和 Lynch 在 2002 年给出了形式化证明。证明的结构其实很朴素:把节点分成两半,客户端在其中一半写入,在另一半读取。如果两边都必须返回非错误响应(A),那么读操作无法知道另一半是否刚写过(C 不成立);如果要保证读到最新值(C),那么至少有一边必须拒绝服务或等待(A 不成立)。

这里有两个经常被忽略的限定:

第一,C 是线性一致性,不是「事务一致」。 线性一致性描述的是单个对象上的读写:每个操作看起来都在调用和返回之间的某个瞬间原子生效,并且遵守真实时间顺序。它不保证跨多个对象的原子性——那是事务和可串行化(serializability)要解决的问题。严格可串行化(strict serializability)才是「可串行化 + 真实时间顺序」。

第二,CAP 只在分区期间说话。 没有分区时,系统完全可以既线性一致又可用;这时真正的取舍在延迟和一致性之间。Abadi 后来把这个补充总结成 PACELC:Partition 时在 A 和 C 之间选,Else(无分区)时在 Latency 和 C 之间选。

所以「我们是 AP 系统」这句话的完整版应该是:

分区期间,我们选择让每个非故障节点继续返回成功;代价是可能返回旧值。分区恢复后,我们用版本向量和业务合并把副本收敛。无分区时,我们仍然要面对「同步复制更一致但更慢」的延迟成本。

还有一个更根本的问题:分区检测本身是猜测。 在异步网络里,你无法区分「节点死了」和「节点只是很慢」。超时是一种本地判断,不是全局事实。FLP 不可能性定理早就说过:在完全异步、允许一个进程崩溃的模型里,不存在既保证安全性又保证活性的确定性共识算法。Raft 的做法不是推翻 FLP,而是承认它——安全性不依赖时钟,活性依赖超时。

三、一致性不是开关,是一条光谱#

工程上很少遇到「要么强一致、要么最终一致」的二选一。真实系统提供的是一组可以逐项勾选的保证:

模型承诺典型实现
线性一致性所有读写像在单点上按真实时间原子执行etcd 默认读、单主同步复制
顺序一致性所有节点看到相同的操作顺序,但不要求与真实时间一致理论模型(Lamport);部分共享内存一致性协议
因果一致性有因果关系的操作保持顺序,并发操作可以乱序带向量时钟的会话系统
读己之写同一客户端写完后的读能看到自己的写粘性会话、版本令牌、读主库
单调读同一客户端不会先看到新值、再看到旧值粘性副本、按版本号拒绝回退
最终一致停止写入后,副本最终收敛Dynamo 风格、CRDT、异步复制

注意最后三行:它们不是「弱化版的 CAP 选择」,而是会话保证(session guarantees)。很多业务真正需要的不是全局线性一致,而是「这个用户不会看到自己刚改的数据又变回去」。把需求从「全局强一致」降到「单用户读己之写」,往往能换来一个数量级的延迟和可用性收益。

quorum:R + W > N 保证了什么,又没保证什么#

无主复制(leaderless)系统常用 quorum 读写。设副本数 N,写成功需要 W 个副本确认,读需要询问 R 个副本:

R + W > N => 任意读集合与任意写集合至少相交于一个副本

例如 N=3, W=2, R=2,写集合 {A,B} 和读集合 {B,C} 必然共享 B。如果每个值都带版本号,读的时候从 R 个响应里取版本最高的那个,那么一次已经完成的写,后续读至少能在一个副本上看到它。

但这不等于线性一致性,原因至少有四个:

  1. 并发写没有顺序。 两个客户端同时写同一个 key,quorum 只能保证读到其中一个版本,不能替你决定谁应该赢。需要版本向量检测并发,再由业务规则合并。
  2. sloppy quorum 会破坏交集。 Dynamo 风格的 hinted handoff 允许写落到「临时替身」节点上,R + W > N 的集合关系就不再成立。
  3. 多键操作不在保证范围内。 两个 key 分别满足 quorum,不代表它们的更新对其他读者同时可见;跨 key 的原子性仍然需要事务或共识。
  4. 成员变更期间没有保证。 配置从 3 副本变成 5 副本时,新旧 quorum 可能不相交。

所以 R + W > N 是一个有用的必要设计条件,不是「强一致」的充分条件。把它当成后者,是很多「我们配了 quorum,怎么还会读到旧数据」事故的来源。

四、BASE:最终一致不是「最终自动正确」#

BASE 是 Basically Available、Soft state、Eventually consistent 的缩写。它经常被说成 CAP 里 AP 的「替代方案」,但更准确的理解是:它描述的是 AP 系统在工程上如何兑现自己的承诺。

  • Basically Available:系统尽量响应,但可能返回旧值或部分结果;
  • Soft state:状态可能在没有新写入的情况下,因后台反熵(anti-entropy)而变化;
  • Eventually consistent:停止写入后,副本最终收敛到同一个值。

关键词是「最终」——它没有说多久,也没有说收敛到哪个值。要让「最终一致」变成可用的工程保证,至少需要三样东西:

1. 一个冲突解决策略。 最常见的是 last-write-wins(LWW):给每个写打时间戳,大的赢。它实现简单,但会静默丢更新——两台机器的时钟差 50ms,先发生的写就可能因为时间戳更晚而覆盖后发生的写。LWW 适合「丢失一次更新可以接受」的场景(比如用户头像),不适合库存、余额、状态机。

2. 能检测并发的元数据。 版本向量(version vector)可以判断两个版本是「有先后」还是「并发」。注意它只能检测并发,不能解决并发:检测到之后,仍然需要业务规则——购物车用并集合并,库存用「不允许超卖」的约束拒绝,文本用 OT/CRDT 合并。

3. 真的在跑的反熵。 副本不会因为我们希望它一致就自动一致。需要 read repair(读的时候发现旧副本就修)、Merkle tree 比较(后台找出差异范围)、hinted handoff(临时节点恢复后把写补回去)。这些机制停了,最终一致就只剩「最终」两个字。

CRDT(Conflict-free Replicated Data Type)是把「合并规则」下沉到数据结构的一类方案:G-Counter、PN-Counter、OR-Set、LWW-Register、MV-Register 等。它的价值在于合并操作可交换、可结合、幂等,所以副本以任意顺序收到更新都能收敛。代价是数据结构受限制,且元数据会随副本数和删除操作增长。

五、共识算法到底解决什么#

前面讲的都是「如何接受不一致」。共识算法走的是另一条路:让一组节点对同一串命令的顺序达成一致,然后用确定性状态机按这个顺序执行,得到相同的状态。这就是复制状态机(replicated state machine)。

Raft 把问题拆成三块:

  1. 领导者选举:同一任期最多一个 leader;
  2. 日志复制:leader 把命令追加到本地日志,复制到多数派后提交;
  3. 安全性:新 leader 必须包含所有已提交的日志。

其中最关键的两条规则,恰好对应本文开头那两个问题。

规则一:多数派才能提交#

5 个节点里,任意两个多数派(3 个节点)必然相交。因此:

  • 一个写要在 3 个节点上落盘才算提交;
  • 一个新 leader 要拿到 3 张票才能当选;
  • 同一任期最多一个 leader;而任意两个多数派必然相交,相交节点在同一任期只会投一票,所以不会出现两个 leader 同时提交。

3/2 分区时,3 个节点那一侧可以继续选 leader、继续提交;2 个节点那一侧无论怎么选举都凑不齐 3 票,只能不断涨任期。这不是 bug,是多数派定义直接推出来的行为。

规则二:候选人的日志必须至少和投票者一样新#

只靠多数派还不够。假设少数派那边有个节点任期涨得很高,网络恢复后它来竞选,如果大家只看任期投票,它就会赢——但它可能缺少多数派已经提交的日志,一上台就会把已提交的数据覆盖掉。

Raft 的选举限制要求:投票者比较候选人的最后一条日志,任期更大者更新;任期相同则索引更大者更新。候选人日志不够新,就一票都拿不到。这条规则保证了「已提交的日志一定出现在未来所有 leader 的日志里」。

还有一条容易写错的提交规则:leader 只能通过「当前任期的日志条目」来推进 commit index。旧任期的条目即使已经在多数派上,也不能直接数票提交——它们只能随着当前任期的条目被间接提交。这是 Raft 论文里专门强调的安全细节,很多简化实现会在这里出错。

六、跑一遍:5 节点、3/2 分区、恢复#

下面这个模拟用一个进程模拟 5 个节点,消息延迟 1 个 tick,选举超时在 8–16 tick 之间随机,心跳每 2 tick 一次。它不是生产实现(省略了快照、成员变更、预投票),但选举、日志复制、多数派提交和分区行为都是真的。

核心只有三段。第一段是投票时的日志新旧判断:

function onRequestVote(node, msg) {
// 任期落后直接拒绝;任期更高则先退位
if (msg.term < node.term) return reply(node, msg, false);
if (msg.term > node.term) stepDown(node, msg.term);
const upToDate =
msg.lastLogTerm > lastLogTerm(node) ||
(msg.lastLogTerm === lastLogTerm(node) && msg.lastLogIndex >= lastLogIndex(node));
const canVote = node.votedFor === null || node.votedFor === msg.candidateId;
const granted = canVote && upToDate;
if (granted) {
node.votedFor = msg.candidateId;
resetElectionTimer(node);
}
reply(node, msg, granted);
}

第二段是 leader 推进提交:

function advanceCommit(leader) {
for (let idx = leader.log.length - 1; idx > leader.commitIndex; idx--) {
// 只数当前任期的条目;旧任期条目靠新条目间接提交
if (leader.log[idx].term !== leader.term) continue;
const replicated = nodes.filter(
(n) => n.id === leader.id || leader.matchIndex[n.id] >= idx
).length;
if (replicated >= MAJORITY) {
leader.commitIndex = idx;
break;
}
}
}

第三段是分区本身——消息能否送达,只由两个节点是否在同一侧决定:

function connected(from, to) {
if (!partition) return true; // 全连通
return partition.has(from) === partition.has(to);
}
// 分区期间的消息直接丢弃,模拟网络被切断
function send(from, to, msg) {
if (!connected(from, to)) return;
inflight.push({ at: tick + 1, from, to, msg });
}

场景是:先让系统正常选主并提交两条日志,然后在 tick 40 切断成 {A,B,C} | {D,E},tick 50 往多数派侧写一条新日志,tick 80 恢复网络。真实输出(节选):

tick 8 | B 发起选举 term=1
tick 10 | B 当选 leader term=1(得票 3/5)
tick 17 | B 提交到 index=1(3/5 已复制,term=1)
tick 22 | B 提交到 index=2(3/5 已复制,term=1)
tick 40 | ⚡ 网络分区:{A,B,C} | {D,E}
tick 48 | E 发起选举 term=2
tick 50 | 客户端向 B 写入 "z=3(分区期间)",等待多数派确认
tick 52 | B 提交到 index=3(3/5 已复制,term=1)
tick 56 | E 发起选举 term=3
tick 66 | E 发起选举 term=4
tick 77 | E 发起选举 term=5
tick 80 | 🔌 分区恢复
tick 82 | B 从 leader 退为 follower(follower 报告更高任期,term=5)
tick 88 | A 拒绝 E:候选者日志不够新
tick 88 | B 拒绝 E:候选者日志不够新
tick 88 | C 拒绝 E:候选者日志不够新
tick 96 | A 拒绝 E:候选者日志不够新
tick 104 | C 发起选举 term=8
tick 106 | C 当选 leader term=8(得票 3/5)

最终状态:

A: role=follower term=8 commit=3 log=[x=1, y=2, z=3(分区期间)]
B: role=follower term=8 commit=3 log=[x=1, y=2, z=3(分区期间)]
C: role=leader term=8 commit=3 log=[x=1, y=2, z=3(分区期间)]
D: role=follower term=8 commit=3 log=[x=1, y=2, z=3(分区期间)]
E: role=follower term=8 commit=3 log=[x=1, y=2, z=3(分区期间)]
全部日志收敛: true
选举次数: 8, 成功当选次数: 2, 分区丢弃消息: 54

这段输出里有三个值得盯住的细节。

第一,分区期间多数派照常提交。 tick 50 的写入落在 B 这一侧,tick 52 就拿到了 3/5 的确认。少数派 {D,E} 完全不知道这条写入,但它们也没有假装自己知道。

第二,少数派涨任期没有用。 E 在 t=48、56、66、77 连续发起四次选举,任期从 2 涨到 5,但每次只有 D 一票。任期是竞选资格,不是权力;没有多数派,任期再高也提交不了任何东西。

第三,恢复后不是「任期高的赢」,而是「日志新的赢」。 E 带着 term=5 回到网络,B 确实因为看到更高任期而退位;但 E 随后在 term=6、7 的竞选中被 A、B、C 连续拒绝,理由都是「候选者日志不够新」。最后是日志完整的 C 在 term=8 当选,并把缺失的 z=3 补给了 D 和 E。

如果选举只看任期、不看日志,E 就会在恢复后当选,然后用一份缺少 z=3 的日志覆盖多数派——那条已经向客户端确认过的写入就丢了。Raft 的选举限制就是用来堵这个洞的。

七、共识的代价:它不是一个免费的「强一致」按钮#

看到这里,很容易得出「那就所有东西都上 Raft」的结论。先算一笔账。

一次共识写入至少需要:

  1. leader 把日志写到本地磁盘(fsync);
  2. 把日志发给多数派;
  3. 多数派各自 fsync 并返回;
  4. leader 拿到多数派确认后提交,再回复客户端。

如果多数派跨地域,第 2、3 步的延迟下限由光速决定。光纤中的传播速度大约是 20 万公里/秒,往返 1000 公里至少 10ms;跨太平洋往返则是一百多毫秒的量级。这还没算磁盘 fsync、排队和重试。也就是说,跨地域强一致写入的 P99 不可能做到个位数毫秒——这不是实现问题,是物理问题。

更实际的问题是:大多数业务并不需要「全局线性一致」。

你真正需要的更合适的选择
单库事务能解决的别拆,直接用数据库事务
自动故障切换,且不能丢已确认的写Raft/Paxos 复制(etcd、Consul、TiKV 等)
跨地域低延迟写入本地 quorum / 异步复制 + 会话一致性
离线、多端编辑,冲突可合并CRDT / 版本向量 + 业务合并
读扩展,能容忍短暂旧读主从异步复制 + 读己之写 / 单调读
事件通知、异步解耦事务性发件箱 + 幂等消费(这不是共识问题)

还有几个常见的「不该上共识」的场景:

  • 把共识当分布式锁用。 锁服务本身可以基于共识实现,但「拿到锁之后的操作」不会自动获得保护。一个延迟的写请求可能在锁过期、新 leader 上任之后才到达存储层。解决办法不是更强的共识,而是 fencing token:锁服务每次发放单调递增的 token,存储层拒绝比已见过的更小的 token。Kleppmann 在那篇著名的分布式锁文章里把这个问题讲得很清楚。
  • 用共识做跨地域 active-active。 如果每次写都要跨洋确认,用户体验会被延迟吃掉。更常见的是每个地域本地提交,再用异步复制和冲突解决收敛——这又回到了 BASE 的领域。
  • 自己实现 Raft。 Raft 论文可读,但正确实现要处理日志截断、快照、成员变更、网络重排、磁盘故障、时钟假设等一堆边界。除非目标是学习或研究,否则优先用 etcd/Consul 这类久经考验的实现。

八、上了共识之后,工程上还要补的四件事#

1. 线性一致读不是默认的。 Raft 复制的是日志,不是读请求。follower 默认可能读到旧数据。要线性一致读,通常有两种做法:ReadIndex(leader 先向多数派确认自己仍是 leader,再在 commit index 上读)或 lease read(依赖时钟假设,在租约内直接读)。etcd 默认提供线性一致读,也允许显式选择 serializable 读来换取更低延迟。

2. 会话保证要单独设计。 即使用了共识,客户端也可能在重试时连到不同节点。读己之写、单调读、一致前缀读这些保证,需要在客户端或代理层用版本令牌、粘性路由或 read_index 来实现。

3. 监控 leader 的稳定性,而不只是「有没有 leader」。 重点看:leader 变更次数、term 增长速度、选举耗时、复制延迟、提案失败率、fsync 延迟。频繁的 term 增长往往意味着网络抖动、磁盘变慢或选举超时配置不合理,而不是「集群很健康」。

4. 想清楚分区期间的行为预案。 多数派侧继续服务、少数派侧拒绝写,这是 CP 系统的正常表现。但业务要能接受这种「部分可用」:少数派侧的用户看到的是错误、超时,还是降级为只读?恢复后要不要补偿?这些问题在事故当晚想,通常就来不及了。

九、小结#

  • CAP 不是三选二。 P 不是可选项;没有分区时可以同时满足 C 和 A,分区时只能在 C 和 A 之间取舍。CAP 里的 C 是线性一致性,不是笼统的「数据一致」。
  • PACELC 补上了没有分区的那一半:无分区时,取舍在延迟和一致性之间。
  • 一致性是一条光谱。 线性一致、因果一致、读己之写、单调读、最终一致,对应的是不同的成本与承诺。先把业务真正需要的保证说清楚,再选实现。
  • R + W > N 只保证读写集合相交。 并发写、sloppy quorum、多键操作、成员变更都在这条公式的保证之外。
  • 最终一致需要冲突解决、版本元数据和真正在跑的反熵。 没有这些,「最终」只是一个愿望。
  • 共识算法让多数派对同一串命令的顺序达成一致。 多数派保证同时只有一个 leader;选举限制保证新 leader 一定包含已提交的日志;只提交当前任期的条目是那条容易被忽略的安全规则。
  • 多数派之外涨任期没有用,日志不够新也赢不了。 这两条合起来,才是「已确认的写不会丢」的来源。
  • 共识有物理成本。 跨地域写入的延迟下限由光速和 fsync 决定;大多数场景需要的是会话保证、CRDT 或异步复制,而不是全局线性一致。
  • 共识不保护副作用。 锁过期、旧 leader 的延迟写、重复消费,仍然要靠 fencing token 和幂等来兜底。

参考来源#

CAP 不是三选二:分区发生时,多副本系统到底在承诺什么
https://www.hehonglei.cn/posts/cap-consensus-under-partition/
作者
Honglei He
发布于
2026-10-21
许可协议
CC BY-NC-SA 4.0