共识算法这玩意儿属于那种“不看觉得玄乎看完觉得也就那样真上手写一遍又开始怀疑人生”的典型。我在过去几年里做过配置中心改造、分布式锁服务、还有一个带元数据强一致的存储层三次都绕不开 Paxos、Raft、Zab 这三兄弟。有意思的是三次的技术选型完全不一样一次是直接用现成的协调服务一次是基于 Raft 库自己拼还有一次是想改 Paxos 但最后放弃了。踩过的坑足够多多到我觉得有必要把这些经验摊开写一写——这篇文章不是论文导读而是想把“为什么这么设计”“工程上哪里会炸”“参数该怎么定”这几个问题讲透让刚接触分布式的同学能建立正确的直觉也让已经在维护集群的同学能对得上号。1. 先搞清楚这三个算法各自在解决什么层次的问题1.1 共识的本质多数派、持久化、全序很多人一上来就背 Paxos 的两个阶段、Raft 的三个子问题背完了却说不清楚“共识”这个词到底指什么。我给它的定义是一组机器对某个值或某个操作序列达成一致并且这个一致结果一旦达成就不会被推翻。注意这里有两个关键词一个叫“一致”一个叫“不可推翻”。前者靠多数派投票保证后者靠持久化保证缺一个都不行。多数派这个思路其实非常朴素。5 台机器里任意两个“多数派”各 3 台必然有交集交集里的机器记得上一次投了什么票就能阻止矛盾的决定产生。这就是为什么集群规模通常是奇数也是为什么 3 节点集群只能容忍 1 台故障、5 节点只能容忍 2 台。你如果搞个 4 节点集群容错能力和 3 节点一样都是 1 台但成本更高纯属浪费。但光有多数派还不够。假设一台机器投完票就把状态放在内存里重启之后忘光了那另一个多数派完全可以再投出一个不同的结果。所以每一张票、每一个被接受的提案在回复对方之前必须落盘。这也是为什么所有共识算法的性能瓶颈最后都落在fsync上——网络反而是次要的。我见过太多人以为换个更好的网卡、上 RDMA 就能把共识层性能拉起来结果发现磁盘的 fsync 延迟纹丝不动地卡在那里。再说全序。共识算法真正有价值的产出不是一个“值”而是一个所有副本都认可的、顺序完全一致的日志序列。有了这个全序日志你把它喂给状态机所有副本就能得到一模一样的最终状态。这就是“复制状态机”模型Paxos、Raft、Zab 全都建立在这个抽象之上。理解了这一点你就明白为什么 Raft 论文开头要先讲复制状态机为什么日志索引和任期这两个字段如此关键。1.2 三者的谱系关系一个祖先两个分支从学术谱系上看这三者有明确的血缘。Paxos 是最早把“多数派 两阶段”这套东西形式化的工作它的贡献在于定义了问题边界和正确性证明的框架。但它描述的是单值共识Single-Decree Paxos也就是大家只对“一个值”达成一致。而工程上需要的是对“一连串值”达成一致这才有了 Multi-Paxos 的种种工程化变体。Raft 的设计者做的事情本质上是把 Multi-Paxos 拆解成三个可以被独立理解、独立实现的子问题领导者选举、日志复制、安全性。这个拆解极其重要因为 Multi-Paxos 在论文里只是一句话“选一个稳定的 leader 就可以省略第一阶段”但这句话背后的边界条件、异常处理论文里根本没写全。Raft 把这些边角全补上了还配了明确的正确性约束比如那条著名的“只能提交当前任期的日志”规则。Zab 则走的是另一条路。它服务的目标是一个主备模式的原子广播系统所以它的设计从一开始就绑定了“必须有一个 Leader、写请求必须走 Leader、Follower 只做备份和读”这个前提。这让 Zab 的结构比 Raft 更“专”少了一些通用性但在它自己的场景里非常高效。从协议形态看Zab 的广播模式和 Raft 的日志复制高度相似差异主要在任期epoch的推进时机和恢复流程的细节上。我的一个判断是如果你是做通用共识库选 Raft 的模型如果你是做特定系统内部的复制协议Zab 的思路往往更省事。因为 Zab 不需要考虑“任意节点都能当 leader 且日志可能千奇百怪”这种通用性问题它的恢复流程可以做得更直接。1.3 选型决策先问自己四个问题技术选型最容易犯的错是“拿着锤子找钉子”。我一般会先问四个问题把答案写下来再决定。第一个问题你需要的是共识还是只需要主备切换如果你的场景是“一个主节点写备节点同步主挂了切备”而且能接受切主期间短暂不可用那你可能根本不需要严格的共识算法一套带租约的主备方案就够了。共识算法带来的是“不丢数据、不脑裂”的强保证代价是写延迟至少多一个 RTT 加一次 fsync。第二个问题读多还是写多共识算法优化的是写路径读路径要靠额外的机制ReadIndex、lease read、follower read。如果你的场景是 99% 读 1% 写那共识层的开销其实可以忽略重点应该放在读的扩展性上。第三个问题集群规模会不会变成员变更在所有共识算法里都是最难写对的部分。如果你的集群规模是固定的那就别实现成员变更能省掉一大半的 bug。Raft 的单节点变更方案虽然简单但在变更期间如果发生选主还是有不少边界要处理。第四个问题你能接受自己写吗我的建议是除非你在做数据库或者基础设施产品否则不要自己从零写共识。用成熟的库把精力放在状态机和业务逻辑上。自己写的共识算法第一版能跑通第二版能过测试第三版才可能在生产上不炸这个周期通常是半年到一年。2. Paxos祖师爷级别的存在但工程上极少照抄2.1 Basic Paxos 两阶段在防什么Basic Paxos 的角色分三类Proposer 提提案Acceptor 投票Learner 学习结果。整个流程两个阶段每个阶段都是一次“请求—多数派响应”。第一阶段叫 Prepare。Proposer 生成一个全局唯一且递增的提案编号 n发给所有 Acceptor。Acceptor 收到后做判断如果 n 比自己记录的最小承诺编号还大就承诺“我不再接受编号小于 n 的提案”同时把自己已经接受过的最高编号提案编号和值一起返回。如果 n 不够大直接拒绝。第二阶段叫 Accept。Proposer 收到多数派的承诺后要做一个关键判断如果这些响应里有人已经接受过提案我必须用编号最大的那个提案的值而不能用我自己的值。这一步是 Paxos 安全性的核心——它保证了“一旦某个值被选定后续的提案只能提这个值”。确认值之后发 Accept(n, v)Acceptor 判断 n 不小于自己承诺的编号就接受。多数派接受这个值就算被选定chosen。我把它写成代码会更直观class Acceptor: def __init__(self): self.min_proposal 0 self.accepted_proposal 0 self.accepted_value None def prepare(self, n): # 提案号必须严格大于已承诺的编号才承诺 if n self.min_proposal: self.min_proposal n return True, self.accepted_proposal, self.accepted_value return False, None, None def accept(self, n, v): # 注意这里是 因为同一个提案可以重复 accept if n self.min_proposal: self.min_proposal n self.accepted_proposal n self.accepted_value v return True return False这段代码里有个细节特别容易被忽略accept里判断用的是prepare里用的是。这不是笔误。因为一个提案在被接受之后可能因为响应丢失而重发重发时编号相同必须允许而 Prepare 阶段承诺的是“不再接受更小的”等于当前编号的提案如果已经承诺过再承诺一次也没意义所以用严格大于。Basic Paxos 有一个臭名昭著的问题活锁。两个 Proposer 同时提案A 用编号 1 走完 PrepareB 用编号 2 抢先A 只好用编号 3 重新来B 再用 4 抢先……谁都提交不了。解决办法有两个一个是提案前随机退避一段时间另一个是选一个稳定的 leader 只让它提案。后者就是 Multi-Paxos 的起点。2.2 Multi-Paxos 到底改了哪里Multi-Paxos 的核心思想可以用一句话概括选一个稳定的 leader让它长期担任唯一的 Proposer这样第一阶段只需要跑一次。因为 leader 稳定之后它发出的提案编号一定比所有其他节点见过的都大Acceptor 没有理由拒绝所以后续每个日志槽位都可以直接跳到第二阶段。但“只需要跑一次”这句话在工程实现里会变形。真实的实现要考虑几种情况第一种是 leader 需要为每个日志槽位维护一个“我承诺过的最大编号”一旦发现有人用了更大的编号说明可能存在另一个 leader必须重新走一次 Prepare 来确认自己的地位。这套机制本质上就是 Raft 里的“心跳 任期”检查。第二种是日志空洞问题。Multi-Paxos 的各个槽位是独立达成共识的可能出现槽位 5 已经选定、槽位 4 还没选定的情况。这时候状态机没法 apply必须先补上 4。工程上常见的做法是让 leader 尽快为空洞位置提一个 no-op 值或者由新 leader 在当选后把所有空洞填满。第三种是日志截断与快照。日志不能无限增长需要定期做快照然后把旧日志删掉。但删日志意味着一个落后的节点可能追不上这时候得传快照而不是传日志。这套机制在 Paxos 的原始论文里完全没有全是工程实现自己补的。我个人的看法是Multi-Paxos 更像一个设计范式而不是一个可实现的协议规范。你在不同系统里看到的 Multi-Paxos 实现细节差异可能比 Raft 和 Zab 之间的差异还大。这也是为什么后来 Raft 能迅速流行——它把那些“每家都自己补”的部分标准化了。2.3 Paxos 落地时最容易翻车的三个点第一个坑是把“accepted”当成“chosen”。Acceptor 接受了某个值不代表这个值被选定。只有在多数派都接受之后才算选定。但很多实现会在本地 accepted 之后就把值返回给客户端这在单机测试里永远不会出问题一上多节点、一遇到网络分区就会丢数据。正确的做法是 Proposer 收集到多数派 Accept 响应后再异步通知所有 Learner包括那些没参与投票的Learner 更新本地状态。第二个坑是提案编号的唯一性。编号必须全局唯一且递增如果两台机器用了相同的编号安全性直接被破坏。工程上通常用“机器 ID 本地单调计数器”来拼或者干脆用时间戳加随机数。但用时间戳要注意时钟回拨用机器 ID 要注意机器 ID 冲突两边都得防。第三个坑是持久化的边界。Acceptor 在回复 Promise 之前必须把 min_proposal 落盘在回复 Accepted 之前必须把 (accepted_proposal, accepted_value) 落盘。少落一次机器重启后就可能忘记自己的承诺从而接受一个更小的编号安全性直接崩掉。我在 review 别人代码时最常见的 bug 就是“为了性能把这两次写做成异步的”看起来 QPS 上去了实际上是在赌机器不重启。如果一个 Paxos 实现号称性能很高但没提 fsync 策略先去看它的持久化边界在哪里八成能找到问题。3. Raft把共识拆成三个可独立实现的子问题3.1 选举任期、随机超时与投票约束Raft 里每个节点有个单调递增的任期term可以理解成“朝代的年号”。任期把时间切分成一段一段每段最多只有一个 leader。节点之间所有通信都带任期号收到比自己任期大的消息立刻降级为 Follower 并更新本地任期收到比自己任期小的消息直接拒绝。选举的触发靠心跳超时。Leader 周期性发心跳AppendEntries 空包Follower 在选举超时时间内没收到心跳就认为 leader 挂了自己变成 Candidate任期加一给自己投一票然后向所有节点发投票请求。拿到多数派投票就当选。这里有个关键设计选举超时必须随机化。如果所有节点用同一个超时时间leader 一挂所有节点同时变 Candidate 同时投票选票被瓜分谁也拿不到多数派然后进入下一轮再次同时超时……这叫“选票分裂”会导致选举反复失败。Raft 论文建议在 150ms 到 300ms 之间随机实际工程里要看你的网络 RTT 调整。投票还有一个约束是保证安全性的关键Follower 只会把票投给日志“至少和自己一样新”的 Candidate。比较规则是先比最后一条日志的任期任期大的更新任期相同再比索引索引大的更新。// 日志新旧比较先比 term再比 index func logUpToDate(candTerm, candIdx, myTerm, myIdx uint64) bool { if candTerm ! myTerm { return candTerm myTerm } return candIdx myIdx }为什么要有这个约束因为如果让一个日志落后的节点当上 leader它就会用自己的旧日志覆盖其他节点上已经提交的日志已提交的数据就丢了。有了这个约束任何当选的 leader 一定包含所有已提交的日志条目。还有一个在实际部署中很重要的补丁预投票Pre-Vote。设想一个节点因为网络抖动被隔离了一会儿它的任期因为反复发起选举而涨得特别高。等网络恢复它带着高任期回来会立刻把正常的 leader 逼退引发一次不必要的选主。预投票的做法是Candidate 先发一轮“我想竞选你们同意吗”的询问只有多数派同意才真正增加任期发起选举。被隔离的节点因为拿不到多数派同意任期就不会涨网络恢复后也不会打断集群。3.2 日志复制提交规则里藏着的那个大坑日志复制看起来很简单客户端请求发给 leaderleader 追加到本地日志并行发给所有 Follower多数派确认后提交并 apply 到状态机然后回复客户端。但这里有一条极其隐蔽但必须遵守的规则就是 Raft 论文图 8 讲的那个场景。规则是Leader 不能仅凭“某条旧任期日志被复制到多数派”就提交它必须等自己当前任期的一条日志被复制到多数派才能连带提交之前任期的日志。我用一个具体场景说明为什么。假设五个节点任期 2 时 leader 把一条日志写到了 S1、S2、S3 上但还没提交就挂了。然后 S5 当选新 leader任期 3它没有这条日志。S5 写了一条自己的日志到本地然后也挂了。接着 S1 当选任期 4它把任期 2 的那条日志复制到了 S1、S2、S3。如果这时候 S1 认为“多数派有了可以提交了”就出问题了——因为 S1 随时可能挂掉而 S5 有可能在下一轮选举中当选S5 的日志任期是 3比 S1 的 2 大一旦 S5 当选就会把那条日志覆盖掉。已提交的日志被覆盖一致性直接崩了。所以正确的做法是S1 必须在任期 4 里写一条自己的日志并被多数派确认才能连带把任期 2 的那条提交掉。这样 S5 就不可能再当选了因为它的日志比 S1 落后。这个规则听起来很细但实现时如果漏了测试用简单的随机故障注入很难测出来往往要在特定时序下才会复现。我在 code review 时会把这条规则单独拿出来问答不上来的实现基本可以判定有隐患。3.3 成员变更与快照工程落地的硬骨头成员变更的难点在于变更过程中新旧两个配置的多数派可能同时存在导致选出两个 leader。假设集群从 3 节点扩到 5 节点旧配置的多数派是 2新配置的多数派是 3。在某个瞬间旧配置的 2 台机器可能选出一个 leader新配置的 3 台机器可能选出另一个 leader直接脑裂。Raft 论文给了两种解法。第一种是单节点变更一次只加一台或减一台。这样旧配置的多数派和新配置的多数派必然有交集不会出现两个独立的多数派。实现简单但扩缩容要一台一台来运维上不优雅。第二种是联合共识先切到一个“新旧配置都要多数派同意”的中间状态再切到新配置。理论上更通用实现复杂度高不少。大部分 Raft 库选的是单节点变更因为简单可靠。但你得注意单节点变更在变更期间如果发生选主新 leader 可能不知道变更正在进行需要额外的机制比如把配置也当成日志条目来复制来保证一致性。这也是为什么配置变更必须走日志不能走旁路。快照解决的是日志无限增长的问题。做法是状态机定期把当前状态序列化成一个快照文件然后告诉 Raft“这个索引之前的日志可以删了”。落后的 Follower 追不上怎么办Leader 直接把快照传过去。这里有几个实操细节快照传输不能阻塞正常的心跳否则 follower 会以为 leader 挂了快照文件要分块传不然大快照会把网络打满快照传输期间 leader 要继续保留被删日志的元信息以便判断 follower 需要的是日志还是快照。3.4 线性一致读的三条路径写走日志是共识算法的本分但读怎么办如果每次读也走一遍日志复制性能直接砍半。Raft 生态里发展了三条路径各有取舍。第一条是Log Read读写都走日志。最简单最安全但每次读都要一次磁盘写加一轮多数派确认延迟高。只适合读极少的场景。第二条是ReadIndex。Leader 收到读请求后记录当前的 commitIndex然后向所有 Follower 发一轮心跳如果多数派响应了说明自己还是 leader没有更新的任期出现就可以等到状态机 apply 到 commitIndex 之后返回结果。这条路径不需要写磁盘只需要一轮心跳延迟大约是一个 RTT。第三条是Lease Read。Leader 在选举成功后假设自己在一个“租约期”内一定是 leader因为 Follower 不会在选举超时之前发起选举租约期内直接读本地状态机连心跳都省了。性能最好但依赖时钟如果各机器时钟漂移超过租约时间就可能读到脏数据。所以我一般建议只在同机房、时钟同步良好的环境里用 Lease Read跨机房部署老老实实用 ReadIndex。还有第四条路Follower Read。Follower 收到读请求后向 leader 要一个 ReadIndex等自己 apply 到这个索引后返回。这样读请求可以分散到所有节点扩展性最好。代价是多一次 Follower 到 Leader 的往返。4. Zab为原子广播而生的协议4.1 zxid 的设计巧思Zab 里最重要的一个概念是zxidZooKeeper Transaction ID它是一个 64 位整数高 32 位是 epoch朝代低 32 位是计数器朝代的第几件事。这个设计的精妙之处在于用单个整数同时编码了“哪个 leader 提的”和“这个 leader 提的第几个”两个信息。这带来两个直接好处。第一zxid 的大小顺序天然就是事务的全局顺序不需要额外的排序逻辑比较两个 zxid 谁大谁小就够了。第二从 zxid 的高 32 位可以直接看出是哪个 epoch 提的恢复流程里判断日志新旧极其方便。对比一下 RaftRaft 用 (term, index) 两个字段来表达同样的信息。功能上等价但存储和比较上多了一点开销。Zab 用单个整数是一种很紧凑的设计代价是 epoch 和计数器的位宽固定计数器溢出2^32 次写在理论上存在实际中靠 epoch 递增来规避。另外一个细节zxid 由 leader 分配且严格单调递增。这意味着所有写请求的全局顺序在 leader 分配 zxid 那一刻就确定了后续的广播、提交、apply 都必须按这个顺序。这是 Zab 能保证“原子广播”语义的基础。4.2 恢复模式选主、发现、同步Zab 的恢复模式分三个阶段每个阶段都有明确的目标。第一个阶段是选主。每个节点把自己的 (epoch, lastZxid, myid) 广播出去收到别人的之后按字典序比较先比 epoch再比 lastZxid最后比 myid。谁的这三元组最大谁就有资格当 leader。这个比较规则和 Raft 的日志新旧比较本质一样都是为了选出“日志最全”的节点。加 myid 是为了打破平局保证一定能选出唯一 leader。第二个阶段叫发现Discovery。新 leader 当选后它并不知道集群里其他节点的日志状态也不知道上一任 leader 是不是已经把某个事务提交了。所以它要先收集所有 Follower 的 acceptedEpoch 和 lastZxid据此确定一个新的 epoch通常是最大 epoch 加一然后把自己的历史发给 Follower。这一步的核心目的是确定新 epoch并让所有 Follower 认可这个新 leader。第三个阶段叫同步Synchronization。Leader 根据 Follower 的日志状态决定用哪种方式同步如果 Follower 只差几条日志就发 DIFF增量如果差太多就发 SNAP全量快照如果 Follower 有 leader 没有的日志说明上一条是被提过但没提交的就发 TRUNC 让它截断。同步完成后Follower 的日志和 leader 完全一致集群进入广播模式。这三个阶段的顺序不能乱。我见过一些简化实现把发现和同步合并在单机测试里没问题但一旦出现“上一个 leader 提了但没提交”的场景就会把不该保留的日志保留下来导致已提交事务被回滚。4.3 广播模式与 Raft 的细节差异广播模式下Zab 的流程和 Raft 的日志复制高度相似Leader 收到写请求分配 zxid生成 proposal 发给所有 Follower收到过半 ACK 后发 COMMITFollower 收到 COMMIT 后 apply。差异主要在几个细节上。第一个差异是epoch 的推进时机。Raft 里 Candidate 每次发起选举都会把 term 加一即使选举失败 term 也涨了。Zab 里 epoch 只在恢复模式确定新 leader 时才递增选举过程中的中间状态不占用 epoch。这导致 Raft 的 term 可能出现很大的跳变比如经历多次失败选举而 Zab 的 epoch 更“紧凑”。这个差异对正确性没影响但会影响日志比较的逻辑——Raft 比较时用 (term, index)不能用 term 单独判断。第二个差异是新 leader 的日志处理。Raft 的新 leader 不需要“清理”自己的日志因为它一定是日志最全的节点只需要把自己的日志推给其他人。Zab 的新 leader 也可能有“提过但没提交”的日志但它在同步阶段会通过 TRUNC 让 Follower 截断不匹配的部分自己的日志则保留。这个流程更接近“对齐”而不是“覆盖”。第三个差异是Observer 角色。Zab 支持的 Observer 参与日志复制但不参与投票可以用来扩展读能力而不影响写可用性因为投票节点数不变quorum 不变。Raft 的 Follower Read 能达到类似效果但没有一个独立的“非投票成员”概念实现上要靠额外的标记。5. 横向对比与参数调优5.1 一张表看清三者差异维度Paxos (Multi-)RaftZab领导者选举论文未定义工程自定随机超时 任期投票Fast Leader Election比较 (epoch, zxid, sid)任期语义提案编号无“朝代”概念term每次竞选自增epoch仅选主成功后自增日志标识槽位索引(term, index)zxid 单整数编码提交规则多数派接受即选定只能提交当前任期条目过半 ACK 后 COMMIT成员变更无标准方案单节点变更 / 联合共识支持通过配置节点动态调整读一致性需自行设计Log Read / ReadIndex / Lease Read / Follower Read默认读可能陈旧sync 读走 leader典型实现难度高边界模糊中规范清晰中耦合特定系统适用场景学术、特定优化场景通用共识库、元数据管理主备复制的协调服务这张表我最想强调的一行是“任期语义”。很多人以为 Raft 的 term 和 Zab 的 epoch 是一回事其实不是。Raft 的 term 是“竞选次数”Zab 的 epoch 是“成功任期数”。这导致两者在日志比较、过期消息判断上的逻辑有微妙差别移植代码时特别容易出错。5.2 关键参数怎么定共识层的参数不多但每一个都很关键。我按重要性排一下。心跳间隔。经验值是略大于一个 RTT。同机房 RTT 通常 1ms 以内心跳可以设 50-100ms跨机房 RTT 20ms心跳就得设到 100-200ms。心跳太频繁会浪费带宽和 CPU太稀疏会导致故障发现慢。选举超时。必须显著大于心跳间隔业界经验是 10 倍左右并且要在一定范围内随机。比如心跳 100ms选举超时可以设 1000ms 到 2000ms 随机。如果选举超时只比心跳大一点点网络稍有抖动就会误判 leader 挂了引发选主风暴。raft: heartbeat_interval: 100ms election_timeout_min: 1000ms election_timeout_max: 2000ms max_append_entries_per_rpc: 64 snapshot_threshold_entries: 10000 pre_vote: true check_quorum: true单次 RPC 携带的日志条数。这个参数影响吞吐和延迟的平衡。条数太少一次复制要很多轮 RPC吞吐上不去条数太多一条大 RPC 会阻塞心跳导致 follower 误判。实践中 64 到 256 条是比较常见的区间还要配合一个字节数上限比如 1MB一起限制。快照阈值。日志积累到多少条开始做快照。太小会导致频繁做快照浪费 CPU太大会导致重启回放慢、磁盘占用高。我的经验是让日志文件总大小控制在几百 MB 以内按这个倒推条数。check_quorum。这个开关的作用是让 leader 定期检查自己是否还能联系到多数派如果联系不上就主动退位。它能避免“孤立 leader 一直以为自己还是 leader”的问题。代价是在网络抖动时可能误退位。跨机房部署我一般会打开同机房视情况。5.3 性能瓶颈通常不在算法本身这一点我必须单独说。很多人优化共识层性能第一反应是换更好的共识算法、换更高效的序列化、加机器。但我实际 profiling 下来八成的时间花在三个地方磁盘 fsync、状态机的 apply 速度、以及日志的批量提交策略。fsync 是绕不开的。每条日志都要落盘才能回复 ACK这是共识算法的安全底线。优化手段只有三个用更快的盘NVMe 比 SATA SSD 快一个数量级、批量刷盘把多条日志攒一起做一次 fsync用 group commit、把日志和快照分盘。第三条经常被忽略但效果很明显——快照写的时候会和日志写抢 IO分盘之后毛刺能少很多。状态机 apply 是第二个瓶颈。日志复制很快但 apply 到状态机如果涉及复杂计算或者写另一个数据库就会堆积。解决办法是把 apply 做成批量的一次 apply 多条或者把状态机设计成纯内存的靠快照持久化。如果状态机本身就要写磁盘那得考虑异步 apply 加读己之写read-your-writes的补偿机制。批量提交是第三个。共识层的吞吐很大程度取决于“一次 RPC 能带多少条日志”和“一次 fsync 能刷多少条”。把客户端请求在 leader 侧攒一小段时间比如 1ms再打包发出去吞吐能提升好几倍代价是延迟增加 1ms。这个 trade-off 在很多系统里是划算的尤其是写密集场景。6. 线上排障实录与避坑清单6.1 选主风暴的四种典型成因选主风暴是我遇到过最多的线上问题表现是集群里 leader 频繁切换日志里全是“开始选举”“当选 leader”的记录客户端请求大面积超时。原因通常是四类。第一类是网络抖动。机房间的网络丢包率突然升高心跳丢了几次follower 就认为 leader 挂了。这种问题最难查因为从应用层看网络是通的只是偶尔丢包。排查方法是看心跳的 P99 延迟和丢包率如果心跳延迟的波动超过选举超时的一半就说明参数设得太激进了。第二类是GC 停顿。如果实现语言有 GC比如 Go 或 Java一次长时间的 stop-the-world 会让节点在几百毫秒内完全无响应心跳自然发不出去。这种问题的特征是“选主风暴和 GC 日志的时间点完全吻合”。解决办法是优化 GC减少大对象分配、调小堆、或者把选举超时设得比最大 GC 停顿更长。第三类是磁盘 IO 阻塞。Leader 在写日志时如果遇到磁盘卡顿比如 RAID 卡电池故障导致写缓存失效fsync 可能卡住好几秒期间心跳也发不出去。这种问题的特征是“选主风暴和磁盘延迟毛刺同时出现”。用 iostat 看 await 和 %util 就能定位。第四类是参数配置不一致。集群里不同节点的选举超时配置不一样会导致某些节点总是先超时、先发起选举把正常的 leader 逼退。这个是最容易忽略的部署时一定要检查配置文件的一致性。排查选主风暴的第一步永远是“对齐时间轴”把各节点的日志、GC 日志、系统监控CPU、磁盘、网络按时间对齐看第一次异常出现在哪个节点、哪个指标上。不要一上来就怀疑算法实现。6.2 日志不一致与快照相关的坑日志不一致的表现是“某个 follower 一直追不上 leader”日志里反复出现“日志不匹配回退索引”的记录。常见原因有三个。第一个是快照传输失败。Follower 落后太多leader 决定传快照但快照文件很大传输中途超时或者被网络中断重试又从头开始永远传不完。解决办法是让快照传输支持断点续传或者把快照分块、每块单独校验。同时要注意快照传输不能阻塞正常的心跳通常要开独立的连接或者用流式接口。第二个是日志被提前删除。做快照之后把日志删了但某个 follower 正好需要那段日志leader 只能改传快照。如果快照也没了比如快照文件被清理策略删了follower 就彻底追不上只能人工干预。所以快照文件的保留策略要比日志更保守至少保留最近两三个。第三个是apply 和 commit 的顺序错乱。有些实现为了性能把 apply 做成异步的结果读请求读到了还没 apply 的状态。这个在测试环境很难发现因为 apply 通常很快。要保证正确性读请求必须等 apply 到指定索引这就需要维护一个 apply 进度读的时候做等待。还有一个我踩过的坑快照期间修改了状态机。如果做快照时状态机还在被并发修改快照可能是不一致的状态。正确的做法是做快照时对状态机加读锁或者用 copy-on-write 的机制。6.3 测试怎么测才算数共识算法的测试是所有分布式系统里最难的因为要测的是“在任意故障组合下都不出错”。我总结了几层测试缺一不可。第一层是单元测试测日志比较、任期更新、投票判断这些纯函数。这层最容易写但覆盖不了并发问题。第二层是确定性模拟测试。用一个可控的模拟器把所有节点的时钟、网络、磁盘都虚拟化然后注入故障延迟、丢包、分区、宕机跑几万次随机的故障序列。这一层是发现 bug 的主力因为可以复现。业界有专门的工具做这件事核心思路是“用种子控制随机出问题就能用同一个种子重放”。第三层是混沌测试在真实集群上注入故障随机杀进程、随机断网、随机让磁盘变慢。这层能发现模拟器覆盖不到的真实问题比如文件系统行为、TCP 行为。第四层是线性一致性验证。用一个客户端并发地读写记录所有操作的时序然后用一个检查器验证“这些操作的执行结果是否能对应到某个合法的顺序执行”。这是最严格的一层能发现最隐蔽的 bug。很多共识库在发布前都会跑这一层。我的经验是前三层能过说明实现基本正确第四层能过才敢上生产。而且第四层的测试要长期跑每次改代码都跑因为共识算法的 bug 往往是“改了 A 影响了 B”。最后分享一个我在实际项目里坚持的做法任何共识层的改动都要先写一个能复现 bug 的测试再改代码。共识算法的 bug 有个特点就是“看起来改了就好了实际上只是把触发概率降低了”。没有可复现的测试你永远不知道自己是真的修好了还是只是运气好。这个习惯帮我省了不知道多少次回滚。