目录
一、一个分区现场:两边都觉得自己是对的
凌晨两点,两个机房之间的光纤被施工挖断。监控上看到的不是「机房 B 挂了」,而是两个机房都活着,都还在接受写入。
- 机房 A 的用户把订单改成「已支付」;
- 机房 B 的用户把同一笔订单改成「已取消」;
- 网络恢复后,两条更新开始互相覆盖,最终留下哪一条,取决于谁的时钟更准、谁的写入更晚到达。
事后复盘时,有人说:「我们是 AP 系统,选择了可用性,这是 CAP 的代价。」这句话听起来很专业,但它其实什么都没解释——AP 不是一个可以挂在嘴边的标签,而是一组非常具体的承诺:分区期间哪些请求还能成功、失败时返回什么、恢复后如何收敛、以及旧数据会不会覆盖新数据。
这篇文章只回答三个问题:
- 分区发生时,系统还能不能继续服务?如果能,它承诺读到什么?
- 分区恢复后,旧副本会不会把新数据覆盖掉?
- 为什么 Raft 的「多数派」能同时防住这两件事?
为了不停留在概念层面,文末会跑一个 5 节点的 Raft 模拟:人为制造 {A,B,C} | {D,E} 的 3/2 分区,观察多数派继续提交、少数派把任期涨到 5 仍然选不出 leader、以及恢复后日志如何收敛。代码不长,输出是真实跑出来的。
二、CAP 的准确版本:P 不是可选项
先看最常见的三个误读。
| 常见说法 | 问题在哪 | 更准确的说法 |
|---|---|---|
| CAP 是「三选二」,可以选 CA | P(分区)不是你能选掉的;网络分区是客观事实 | 没有分区时可以同时满足 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 个响应里取版本最高的那个,那么一次已经完成的写,后续读至少能在一个副本上看到它。
但这不等于线性一致性,原因至少有四个:
- 并发写没有顺序。 两个客户端同时写同一个 key,quorum 只能保证读到其中一个版本,不能替你决定谁应该赢。需要版本向量检测并发,再由业务规则合并。
- sloppy quorum 会破坏交集。 Dynamo 风格的 hinted handoff 允许写落到「临时替身」节点上,
R + W > N的集合关系就不再成立。 - 多键操作不在保证范围内。 两个 key 分别满足 quorum,不代表它们的更新对其他读者同时可见;跨 key 的原子性仍然需要事务或共识。
- 成员变更期间没有保证。 配置从 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 把问题拆成三块:
- 领导者选举:同一任期最多一个 leader;
- 日志复制:leader 把命令追加到本地日志,复制到多数派后提交;
- 安全性:新 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=1tick 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=2tick 50 | 客户端向 B 写入 "z=3(分区期间)",等待多数派确认tick 52 | B 提交到 index=3(3/5 已复制,term=1)tick 56 | E 发起选举 term=3tick 66 | E 发起选举 term=4tick 77 | E 发起选举 term=5tick 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=8tick 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」的结论。先算一笔账。
一次共识写入至少需要:
- leader 把日志写到本地磁盘(
fsync); - 把日志发给多数派;
- 多数派各自
fsync并返回; - 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 和幂等来兜底。
参考来源
- Seth Gilbert, Nancy Lynch: Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services(CAP 的形式化证明,明确 C 为线性一致性、A 为每个非故障节点都返回响应)
- Martin Kleppmann: Please Stop Calling Databases CP or AP(为什么把数据库简单标成 CP/AP 会掩盖真正的取舍)
- Raft 官方论文:In Search of an Understandable Consensus Algorithm(领导者选举、日志复制、选举限制与提交规则)
- Raft 官方网站与可视化(选举与日志复制的交互式演示)
- Jepsen: Consistency Models(线性一致性、因果一致性、可串行化等模型的形式化定义与层级关系)
- etcd 官方文档:API Guarantees(线性一致读、serializable 读与 revision 语义)
- Amazon Dynamo 论文(无主复制、quorum、向量时钟、hinted handoff 与反熵)
- CRDT 技术主页(可交换、可结合、幂等的复制数据类型与合并规则)
- Martin Kleppmann: How to do distributed locking(锁过期、延迟写与 fencing token)