专业书籍精读 · Database Internals · 第 14 章
Database Internals · Ch 14 · Alex Petrov · 2019
你点下「转账成功」,背后不是一台机器在记账,而是三台、五台机器各记一本。这一章回答的问题很直白:这几本账,怎么保证永远是同一本?——不是「大致同步」,而是「谁也别想说出两个互相矛盾的版本」。这件事在计算机里有个专门的名字,叫共识。
想象五个人分散在不同城市,只能靠时好时坏的对讲机商量「周六几点聚餐」。信号会断、人会临时离席,更要命的是:你永远分不清对方是走了,还是只是信号卡住、三秒后又活过来。你们要定下一个时间,而且一旦定下,谁后来问都是同一个答案——哪怕当时有两个人根本没听见。
最自然的办法是「选个组长,他说了算」。问题是组长失联的时候,剩下的人会同时自封组长——于是出现两个组长、两份互相矛盾的账(行话叫「脑裂」)。更尴尬的是,老组长的信号可能只是卡了一下,他自己还以为自己在位,继续往账本上写。这不是假想:真实系统里,一次垃圾回收停顿或一次网线抽风,就足够制造出两个自信满满的组长。
第一个点子是「过半数」。任何决定,必须有超过一半的人点头才算数。妙处在于:任何两个「过半数的小团体」,一定至少有一个共同的人——五个人里,任意两组三人必有重叠。那个重叠的人就是活档案:新的一轮一开口,他就会说「等等,上次已经定过九点了」。于是新决定永远推不翻旧决定,只能接着重复它。
第二个点子是「换届要查资历」。每次换组长都编一个届号,届号只增不减;想当新组长,必须先拿到过半数的票,而大家只投给记录不比自己少的人。这样新组长手上一定有全部已经定过的事,不会上任就把前任记下的东西抹掉。再加上「看到更大的届号就立刻退位」,老组长一发声就会被告知「你已经是上一届了」。
世界上流行的做法其实只有两套讲法:一套叫 Paxos,正确但出了名难懂;一套叫 Raft,故意把同一件事讲成一本能照着做的说明书,今天的 etcd、Kubernetes、CockroachDB、新版 Kafka 都用它。诚实的代价是:每一次写都要等过半数的人点头,五台机器里一旦坏掉三台,整个系统宁可停下来不写,也不愿意写出两个版本。
共识不是「少数服从多数的投票」,而是一套防反悔的规矩:凡事要过半数点头,而任何两个过半数的小团体必定有人重叠,这个人记得上次的结论——所以历史只能被接着写,不能被改写。
想看 Paxos 的两个阶段、Raft 的任期与日志、拜占庭容错到底贵在哪? → 切到精读版
共识(consensus)不是「多数投票表决」,而是让一群随时可能崩溃、消息随时可能延迟乱序的机器,对一串操作的顺序达成不可反悔的一致。本章把 Paxos、Raft、Zab 摆在一起讲,而它们最终都收敛成同一个形状:选一个领导者定序,再用多数派把他的日志钉死——所谓「难懂的共识算法」,难的不是选出一个值,是证明已经定下的值永远不会被后来者改写。
N 个节点里的 ⌊N/2⌋+1 个。0.5–2ms,同城跨可用区约 1–3ms,跨大洲常在 70–150ms。本章是 Part II「分布式系统」的收官,也是全书终章。上承 Ch9 故障检测(超时判死永远可能误判)、Ch10 领导者选举(选举本身并不保证唯一)、Ch11 复制与一致性、Ch13 分布式事务(2PC 要求全体同意,协调者一倒就全体持锁干等)。这一章把那些各自不完整的零件拼成一个能自证正确的协议,对应现实里 ZooKeeper、etcd、Consul、CockroachDB、TiKV 这类「集群大脑」。
前面每一章都留了个缺口:故障检测会把「慢」误判成「死」;选举可能在分区两侧各产生一个领导者;复制日志可能分叉;原子提交要求全体同意,于是一个节点失联就全线阻塞。共识要在这些缺口上给出一个同时满足三条的协议——一致性(不会有两个节点认定不同的值)、有效性(定下的值必须是某个节点真的提过的,不能凭空捏造)、可终止性(最终能定下来)。
前提很残酷:按 FLP 不可能性结论,纯异步网络里只要有一个节点可能崩溃,确定性算法就不可能同时保证安全性与活性。所以实用协议的做法都一样:安全性(不出错)无条件死守,活性(能推进)靠超时和随机化去「大概率」拿到——这就是为什么每个共识实现里都藏着一堆超时参数。
不解决会怎样?拿 Kubernetes 举例:整个集群的真相——哪个 Pod 该跑在哪台机器、Service 的后端有谁、谁持有某把锁——全部存在 3 到 5 个节点的 etcd 里。假如网络分区时两侧各自接受写入并都回「成功」,调度器就会按两份互相矛盾的真相去拉起容器:同一块云盘被两个进程同时挂载写入。共识是整个系统唯一真相的发源地,它错一次,上层所有正确性都跟着失效。
本章从广播切入,而不是直接扔出 Paxos。尽力广播(best-effort broadcast)只保证「我发出去了」,发送者中途崩溃就有人收得到、有人收不到;可靠广播(reliable broadcast)保证所有正确节点最终都收到,但顺序各不相同;原子广播 / 全序广播(atomic broadcast、total order broadcast)再加一条——所有节点收到的消息顺序完全相同。
关键洞见是:全序广播与共识是等价问题。有了全序广播,把「第一条被广播出来的值」当作被选定的值,就得到共识;反过来,对日志的每一个位置各跑一次共识,就得到全序广播。数据库真正要的从来不是「选一个值」,而是所有副本按同一顺序回放同一串写——所以共识在工程里永远以「复制一份完全一样的日志」的面目出现。ZooKeeper 的 Zab 协议就是照这个思路设计的:以 epoch(纪元号)划分领导权,分发现(新领导者找出最高的那份日志)、同步(推平给多数派)、广播(稳态逐条追加)三阶段。
Paxos 把节点分成三种角色(同一台机器可以同时扮演):提案者(proposer)发起提议,接受者(acceptor)投票并记住状态,学习者(learner)知悉结果。一轮完整的 Basic Paxos 要两个阶段:
n,问多数派「能接受 n 吗」。接受者若没见过更大的编号就承诺:此后拒绝一切编号小于 n 的消息,并把自己已经接受过的、编号最高的那个值一并回传。所以阶段一根本不是投票,而是抢占 + 打听:抢占是给所有在飞的旧提案发一张作废通知,打听是把可能已经定下的历史捞回来。多数派交集保证了这次打听不会漏:任何两个过半数集合必有共同节点,而那个节点记得上一次接受了什么。Paxos 的全部魔法就一句话——后来者不许改写历史,只能接着喊同一句话。
代价也很具体:每定下一个值要 2 轮 RTT + 2 次磁盘持久化;更麻烦的是两个提案者会互相抢占编号,理论上可以无限循环下去(活锁,livelock)。这两条正是下面所有变体的改进动机。
Multi-Paxos 的改法朴素而有效:既然每条日志都重跑阶段一太贵,就选出一个特定提案者(distinguished proposer,即领导者),让他对整段日志只做一次阶段一,此后每条命令只剩阶段二的一轮 RTT。领导者靠租约维持身份——这是共识协议里罕见的、依赖物理时钟的地方:租约期内别人不得发起选举,所以时钟漂移必须有界,否则安全性靠的就不再是算法而是运维。
三个值得记住的变体,各自卖掉一样东西换另一样:
⌈3N/4⌉ 而非过半),且两个客户端撞车时要回退经典路径重跑一轮——净收益只在冲突率低时成立。|Q1| + |Q2| > N。于是 N=4 时可取 Q1=3、Q2=2:稳态写只等 2 个节点确认,把成本挪到了「很少发生」的选举那一次上。Raft 的设计目标不是更快,而是可理解——作者明说,Paxos 的描述与一个真正能跑的系统之间差距太大,以至于每个团队都在各自发明细节。Raft 用三件事把这些细节钉死:
150–300ms 区间内随机取值)以打破平票,典型故障切换在几百毫秒级完成。日志只从领导者单向流向跟随者,跟随者冲突的尾巴直接截断重写——没有「互相合并」这回事。前面所有协议都假设崩溃-停止:节点要么正确,要么沉默。拜占庭故障则允许节点发出任意、互相矛盾的消息——被入侵、固件错误,或根本就是敌对参与方。PBFT 的代价直观可见:要容忍 f 个叛徒需要 3f+1 个节点(崩溃模型只要 2f+1),三阶段消息 pre-prepare / prepare / commit,消息量随节点数呈 O(N²) 增长。结论也很清楚:在自家机房、同一组织内运维的集群里,拜占庭假设通常不划算;它真正的舞台是互不信任的参与方,区块链即是其工业化版本。
所有方案的安全性保证是一样的(不会出现两个互相矛盾的已提交值),差别全在谁定序、稳态要几轮、以及实现有多难写对。
| 方案 | 稳态写代价 | 领导者 | 代表系统 | 代价 / 适用 |
|---|---|---|---|---|
| Basic Paxos | 2 轮 RTT + 2 次落盘 | 无 | 几乎不直接用 | 并发提案互相抢占会活锁;只作教学与基石 |
| Multi-Paxos | 1 轮 RTT | 有(租约) | Chubby、Spanner 的每个分片组 | 论文留白多,细节各家自己补,难对齐实现 |
| Raft | 1 轮 RTT | 有(强领导者) | etcd、Consul、TiKV、CockroachDB、Kafka KRaft | 日志必须单向流动,不能从多个节点并行写入 |
| Zab | 1 轮 RTT | 有(epoch) | ZooKeeper | 与 ZooKeeper 的会话 / 顺序语义深度绑定,较难剥离复用 |
| EPaxos | 冲突少时 1 轮 | 无 | 学术与少量实验实现 | 依赖图与恢复路径复杂;冲突率一高反而更慢 |
| Flexible Paxos | 可低至等 2 个节点 | 有 | 部分 Paxos 实现的调优选项 | 把成本挪给选举:恢复时要联系更多节点 |
| PBFT 类 | 多轮 + O(N²) 消息 | 有(视图) | 联盟链 / 跨组织账本 | 需 3f+1 节点,吞吐与规模都显著更差 |
集群要几个节点?多一倍节点并不等于多一倍好——写延迟取决于「第几快的那个节点」。
| 规模 | 容忍故障 | 每次写要等 | 典型取法 |
|---|---|---|---|
| N = 3 | 1 台 | 第 2 快的节点确认 | 单机房、延迟敏感;滚动升级期间等于没有冗余 |
| N = 5 | 2 台 | 第 3 快的节点确认 | 生产默认;可在升级期间仍容忍 1 台故障 |
| N = 7+ | 3 台 | 第 4 快的节点确认 | 很少值得:多数派更大、尾延迟更差,运维成本上升 |
跨地域部署时这张表会变得很刺眼:三个可用区之间 RTT 约 1–3ms,共识写还能接受;一旦把副本摊到跨大洲(RTT 70–150ms),每次写都要等一个跨洋往返——这正是 Spanner 要用 TrueTime、EPaxos 要做「就近提交」的原因。
共识在工程里的正确用法,可以浓缩成一句:让它管「小而贵」的东西,别让它管「大而多」的东西。小而贵指的是元数据与领导权——谁是分片的主、配置是什么、锁被谁持有。Kubernetes 把整个集群的真相放进 etcd,但那通常只有几十 MB 到几 GB,业务数据走的是另一条复制路径。一旦把每个业务写都塞进一个共识组,吞吐就被领导者的落盘速度加一次多数派 RTT 死死卡住。
工业界的标准解法是分组:CockroachDB 与 TiKV 把数据切成大量范围(CockroachDB 默认每个范围约 64MB),每个范围跑一个独立的 Raft 组,成千上万个组并行推进,于是集群吞吐随节点数增长,而每个组内部仍是教科书式的 Raft。另一个常见优化是租约读:持租副本本地应答读请求,不必为每次读再跑一轮共识。
10,000 writes/sec 的基准,Kubernetes 等系统直接把它当集群真相——证明单组 Raft 的量级足以承载元数据,但不是为海量业务写准备的。etcd 官方 README ↗64MB、默认三副本)各自维护一份分布式共识实例,并用与 Raft 领导权分离的「范围租约」让持租副本本地服务读——这是「把共识切小、再并行」这条路线的公开样本。CockroachDB design.md ↗① 共识 = 全序广播:数据库要的不是「选一个值」,而是所有副本按同一顺序回放同一串日志(复制状态机)。
② 多数派的意义是交集:任意两个过半数集合必有共同节点,它记得历史,所以已选定的值不可能被后来者改写。
③ Paxos 两阶段:阶段一是抢占编号 + 打听历史,阶段二才写值;一旦打听到旧值,提案者必须改提旧值。
④ Multi-Paxos 的核心优化:选一个领导者,阶段一只做一次,稳态每条命令只剩 1 轮 RTT;领导权靠租约维持,因此依赖「时钟漂移有界」。
⑤ 变体各卖一样东西:Fast Paxos 省一跳换更大的快速法定人数;EPaxos 去掉领导者换依赖图复杂度;Flexible Paxos 只要 |Q1|+|Q2| > N,把成本挪给选举。
⑥ Raft 的三根支柱:任期号作逻辑时钟、随机化选举超时(150–300ms)、选举限制保证新领导者日志最新——日志只能单向流动。
⑦ 拜占庭容错很贵:3f+1 节点、三阶段、O(N²) 消息,只有在参与方互不信任时才值得。
⑧ FLP 的现实翻译:安全性无条件死守,活性靠超时与随机化争取。
⑨ 工程落点:共识管元数据与领导权,数据走分组(multi-raft、每范围一组);别让每个业务写都过同一个共识组。