1. 从一次日志错乱开始聊聊Raft为什么值得学
事情是这样的,几年前我在一个团队里接手一套内部自研的分布式任务调度系统。系统本身不复杂,但有一个隐患:多个调度节点通过数据库里的悲观锁来抢任务,某个节点只要拿到锁就能执行任务。后来业务量上来了,数据库成了瓶颈,大家决定引入一套独立的协调服务,让多个副本保持一致。我们当时第一反应是ZooKeeper,但ZooKeeper的ZAB协议对团队来说像个黑盒,出了问题只能靠经验猜。后来有人提议能不能自己写一套基于Raft的小型一致性组件,至少能看懂、能排查、能控制。那是我第一次系统性地把Raft从论文搬到工程里。
这件事给我留下了很深的印象。Raft这个名字在分布式系统里几乎无人不知,但很多人对它的了解止步于“共识算法”“Paxos的简化版”这类标签。真正动手实现过、或者在生产环境里排查过Raft问题的人其实不多。这篇文章想做的,不是把论文翻译一遍,而是从一个实践者的角度,把Raft的核心机制、实现要点、工程落地经验,以及它和区块链网络(尤其是Fabric)之间的关系,一次讲透。
适合谁来读?如果你正在学习分布式系统原理,或者准备面试时被问“Raft和Paxos有什么区别”,又或者你所在团队正打算用Raft做复制状态机、KV存储、配置同步,这篇文章都能给你一套从原理到落地的完整参考。我尽量少掉书袋,多讲“为什么”,因为Raft这种算法,搞懂为什么比记住是什么重要得多。
代码和配置我会尽量给出可以直接用的片段,但更核心的是背后的权衡逻辑。毕竟,光会照着调API是没用的,你得知道它为什么这么设计。
2. 先搞懂Raft解决了什么问题
2.1 为什么需要共识:从数据冗余说起
很多人会把共识和数据副本混淆。单机数据库挂了就挂了,最多丢几分钟数据,如果能接受,那确实不需要共识。但分布式系统的前提假设通常是:不管哪台机器宕机、网络分区还是进程崩溃,系统整体对外仍然可用,并且不丢数据、不错数据。实现这个目标最朴素的办法就是多副本。
多副本带来的第一个问题:多个副本之间如果状态不一致,到底以谁为准。比如三个副本同时收到写请求,A写入了“x=1”,B写入了“x=2”,C还没收到请求,这时任何一个副本对外提供服务,客户端得到的答案都不一样。这不仅仅是脏读问题,更严重的是,当故障恢复时,系统无法确定哪份数据才是“事实”。
所以我们需要一种机制,让所有节点哪怕从不同状态出发,最终也能收敛到同一个结果。这种机制,就叫共识。
Raft做的事情,一句话概括:在存在故障的异步网络里,让多个节点就一系列操作日志达成一致,并保证这个一致结果在已提交之后不会再被回滚。注意“已提交之后不会再被回滚”这条,这就是Raft安全性的核心,也是很多人在面试时没答到点上的地方。
2.2 Raft和Paxos的选型逻辑
提到共识算法,绕不开Paxos。Paxos的设计极其优雅,Lamport的论文写得也很妙,但合格的工程师都知道,优雅和可工程化之间有一条鸿沟。Paxos在论文层面谈论的是如何就“单个值”达成一致,但实际系统需要对“一串值”达成一致,这涉及日志复制、日志压缩、节点配置变更等等复杂问题。Paxos论文没有给出完整的工程语义,导致每个实现Paxos的团队都有自己的一版理解,彼此还不互通。
Raft的出发点恰恰是“可理解性”。Raft是Tony Arlind等人2014年发表的论文《In Search of an Understandable Consensus Algorithm》提出的,目标不是比Paxos更快、更高效,而是让更多人能够正确理解和实现共识算法。Raft把共识问题拆成了三个相对独立的子问题:
- 领导者选举:Leader宕机后如何快速选出新Leader。
- 日志复制:Leader如何把客户端的操作同步到所有副本。
- 安全性保障:如何保证任何已提交的日志条目都不会丢失。
这种拆解的工程价值很大。你可以在不破坏整体正确性的前提下,先实现选举,再实现日志复制,最后补安全性约束。出现问题也更容易定位,不像Paxos那样所有逻辑纠缠在一起。
如果你在团队里做技术选型,我的建议很直接:除非有极其特殊的性能优化需求,否则Raft比Paxos更适合自研。Raft的协议语义更清晰,社区实现多,参考资料多,踩坑成本低。
2.3 Raft的三个子问题不是孤立的
Raft的三个子问题听着各自独立,但它们之间是咬合在一起的。没有选举,日志复制就没有固定的写入来源;没有日志复制,选举就失去意义;安全性约束则是前两者的黏合剂,它规定了选举和复制必须遵守的边界条件。
举个例子,一个节点在选举中拿到了多数票,并不代表它一定能当上Leader。候选者必须拥有最新的已提交日志,否则即使当选,也会因为日志落后而无法服务写请求,甚至产生错误提交。所以选举过程里必须携带日志的任期号和索引信息,这是Raft“投票限制”的体现。
再说日志复制,Leader把一条日志推到多数节点后就可以提交。但提交日志时,Leader必须保证自己当前任期内的日志条目至少有一个被成功复制到多数派。否则,一个日志落后的节点在下个任期当选Leader后,可能会覆盖掉之前任期已经“半提交”的日志,导致数据丢失。这背后的设计思路就是:日志提交必须连带推进当前任期的状态。
三个子问题串联起来看,一个完整的Raft请求生命周期就有了:
- 客户端把请求发给Leader。
- Leader把请求追加到本地日志,然后并行向所有Follower发送AppendEntries RPC。
- Leader收到多数派成功响应后,把日志条目应用到状态机,再返回客户端。
- Follower在收到新的心跳时,得知该日志已被提交,然后将其应用到状态机。
3. 日志复制这条主线,一切围绕它转
3.1 日志条目的基本结构
日志是Raft所有机制的中枢。理解Raft,最直接的方式就是盯住日志是怎么产生、怎么复制、怎么提交、怎么应用的。
一条日志条目包含三个关键字段:
- Term:产生该条目的Leader任期号。
- Index:日志条目在日志中的位置,从1开始递增。
- Command:具体要执行的操作,比如“set key=value”。
Term和Index共同唯一标识一条日志。节点在回复AppendEntries、发起投票时,都会带上自己的当前任期和最新日志的Term/Index,接收方依据这些信息判断自己的日志是否落后、是否应该拒绝请求。
每个节点在内存里维护一个日志数组,已提交的日志会按顺序应用到状态机。日志的应用顺序是全局唯一的,这就保证了所有节点的状态机最终收敛到同一状态。这是“复制状态机”的核心思想。
3.2 日志复制流程:一个完整请求的旅程
假设现在系统里有一个Leader(节点A)和两个Follower(节点B、C),客户端发来一个写请求:
- 客户端把请求发给A(或者发给Follower,Follower把请求转发给A)。
- A在本地追加一条日志,Term为当前任期,Index为当前日志长度加一,Command为客户端请求。
- A向B、C发出AppendEntries RPC,带上Leader的当前任期、上一条日志的Index和Term、要追加的日志条目、Leader的已提交索引。
- B、C收到RPC后,先做一致性检查:自己上一条日志的Index和Term必须和Leader携带的prevLogIndex、prevLogTerm一致,如果一致则追加日志,返回成功;否则返回失败。
- A收到多数派成功响应后,把这条日志的提交索引更新,并应用到自己的状态机,然后返回客户端成功。
- 下一个心跳周期,A把新的commitIndex广播给B、C,B、C在收到心跳后把自己的commitIndex追平,并应用日志到状态机。
这中间最容易让人忽略的是第4步的一致性检查。它保证了Follower不会把日志追加到一个“断层”里。如果Follower发现自己的日志和Leader的前一条日志不匹配,说明它落后了或者说它本地有多余的日志,它会拒绝请求并让Leader把prevLogIndex往前回退,直到找到共同的前缀点。这个过程就是“日志回溯”。
日志回溯的效率在极端情况下可能不高,Leader每次只回退一条日志。在Raft的进一步优化里(如etcd的优化实现),Leader可以通过Follower返回的冲突Term和第一个冲突索引,直接跳到更靠前的位置,避免一条一条回溯。生产级实现里这一步优化基本是标配。
3.3 为什么多数派就够了:少数派日志会怎样
可能有人会问:既然要保证不丢日志,为什么不让所有节点都成功写入再提交?答案是可用性。在异步网络里,节点可能宕机、分区、慢响应,如果要求所有节点确认,任何一个Follower“失联”,整个系统就卡死了。
多数派(Majority)的数学依据是:任意两个多数派集合必有交集。这意味着任何一条被提交的日志,一定存在于某个包含多数节点的集合中;而后续选举产生的新Leader,也必然得到多数派投票,两个多数派交集处至少有一个节点持有这条已提交日志。正是这个交集,保证了已提交日志一定有机会被新Leader看到并保留下来。
举个实际例子,5个节点的Raft集群,最多容忍2个节点宕机。当只有3个节点回应时,就构成多数派,系统仍能对外服务。这就是很多生产系统把Raft集群规模定为奇数且至少3个节点的原因。
但多数派本身也带来一个副作用:少数派上的日志可能“被覆盖”。如果某个Follower因为网络分区没有收到某条日志,当拥有该日志的Leader宕机了,这个Follower在新选举中可能当选Leader,但它的日志里没有那条已提交日志。为了防止这种场景,Raft的选举限制要求候选者必须拥有所有已提交日志才能当选。它要求投票者比较自己和候选者的日志新鲜度,只有候选者日志不落后于自己才投票。这就是Raft里“Up-to-date”的判定:先比较Term,Term大的优先;Term相同则比较Index,日志更长的优先。
3.4 日志复制的冲突处理:一个容易踩坑的地方
日志冲突是实际运行中最常见的问题。简单描述一下:一个Leader在任期n写入了几条日志,还没完全复制到所有Follower就宕机了。新Leader在任期n+1产生,把之前的日志清掉或覆盖。这时如果旧Leader恢复,它手里还有任期n的日志。如果它再次当选,它可能会把这些日志当成自己的新日志推给其他节点,造成已经被覆盖的日志“复活”。
Raft解决这个问题的思路是:旧Leader在恢复后,经过一轮选举发现自己不是最新日志的拥有者,它的日志会被新Leader强制覆盖。而且新Leader在当选后,会把“空日志”(即一条只包含当前任期、没有命令的特殊条目)追加到日志末尾作为占位,通过这个占位条目的提交来否决之前任期的未提交日志。这个机制叫“无操作日志”(no-op),它在Raft工程实现中几乎是必须的,否则新Leader无法快速提交上一任期的日志,系统会出现“提交停滞”。
这是Raft工程实现中最常见的Bug来源:忘记在当选Leader后追加no-op日志。很多自研实现之所以出现“选完Leader后写请求卡住”的现象,就是这个原因。
4. 脑裂场景推演:如果不做日志一致性检查会怎样
4.1 模拟一个5节点集群的灾难现场
纸面上讲日志冲突不好理解,我们来做一个灾难推演。假设有一个5节点的Raft集群,节点S1到S5。
某个时刻,S1是Leader,当前Term是1。S1收到一批写请求,日志Index从1写到100。它把这100条日志复制给了S2和S3,但因为网络抖动,S4和S5只复制到了前50条。随后S1和S2、S3所在的网络区域与S4、S5所在的网络区域发生分区。
分区后,S1和S2、S3构成一个多数派(3/5),它们仍能继续处理写请求,S1继续产生Index 101、102等日志。但S4、S5收不到S1的心跳,它们开始发起选举,因为收不到S1的响应,S4和S5会不断增大自己的任期号,并互相投票,最终选出一个新Leader(假设S4)。但S4和S5只有2票,永远凑不够3票,所以它们无法真正成功当选Leader,也无法提交任何日志。S4和S5只会不断增大自己的Term,导致当分区恢复时,S1发现自己的Term已经低于S4、S5,它只能退位成Follower。
这还不是最可怕的。最可怕的是:如果S4和S5中有一个节点在实际场景里拿到了来自其他网络区域的第3票(比如S2或S3断连后又恢复了),那么S4就可能当选Leader。此时S4的日志停留在Index 50,而S1的日志已经到Index 200。S4当选后,它的日志复制强制让其他节点回退到自己的Index 50,之前100到200之间的日志全部被丢弃。主流程数据直接丢了一半,业务上无法接受。
但Raft的选举限制能拦截这个场景:S4的日志只到Index 50,而S2、S3的日志到Index 200,S2、S3在投票时会比较候选者日志的新鲜度。S4的Term是2,S2、S3的Term是1,按Term优先原则,S4比它们的Term大,这会通过比较?不,这里是候选者S4的Term是2,但它日志只到Index 50;S2、S3的日志Term是1,但Index到200。Raft比较日志新鲜度时,先比最后一条日志的Term:S4最后一条日志的Term是2,S2、S3最后一条日志的Term是1,所以S4日志被认为是“更新的”。但这里有个陷阱,如果S4最后一条日志是任期2的无操作日志,S2、S3并不会因为Index更长就拒绝投票,它们会投给S4。但那些已经提交的Index 1到200的日志,至少有一条是任期1的日志,S4的日志里没有它,Raft为何还能保证安全?
答案在于,S4要成为Leader,必须有3票。如果S1分区、S2、S3在线,它们会拒绝投票给日志落后的节点吗?不会,因为Raft的投票限制是候选者日志不落后于投票者即可,而S4在Term上领先,因此S2、S3会投给它。这个场景下Raft如何保证S4不会覆盖已提交日志?核心在于:S4最后一条日志的Term为2,这意味着它的日志和S2、S3有相同的“Term 2的无操作日志”吗?不对,S2、S3并不一定有任期2的日志。事实上,如果多数派里的S2、S3都没有任期2的日志,它们会拒绝投票。
这里面的关键在于:投票限制要求候选者的日志至少不落后于投票者,并且这个比较要使用“最后的日志任期优先,再看索引”。如果按照这个规则,S4最后一条日志任期是2,S2、S3最后一条日志任期是1,S4被视为更新,它们会投给S4。这样一来,已提交日志会不会丢?
不会丢。因为S4的日志里如果没有已经提交的任期1日志,它当选后无法提交这些日志,但它可以提交自己任期2的新日志,而且新日志会接在任期2的无操作日志之后。但原有的任期1日志(Index 101到200)是“未提交的”,即使是旧Leader产生的,也没有被提交。未提交日志是允许被覆盖的。至于Index 1到100的日志,其中前50条的Term可能是1,后50条的Term可能是1,它们复制给了S2、S3。S4的日志里没有后50条,但S2、S3里没人投票给它的话,它无法当选。如果S2、S3都投了票,说明它们认为候选者日志不落后,但由于S4最后一条日志Term为2,它们会投,这时已提交的Index 51到100的日志并没有复制到S4上。但Raft的安全性是靠Leader提交时“本任期日志至少复制到多数派”来保证的。旧LeaderS1在分区前提交了Index 51到100,它们复制到了S2、S3,这构成多数派,所以它们已提交。新LeaderS4的日志里没有它们,但它在当选后追加任期2日志,并在后续提交时,能不能从S2、S3那里把这些已提交日志捡回来?不能。Raft的日志是Leader覆盖式的,S4不包含这些日志,S2、S3会在S4的覆盖命令下删除自己多余的日志。那么这些已提交日志就丢了?
这个问题其实恰恰是Raft安全性证明的核心。它在论文里给出的结论是:任何已提交的日志条目必然出现在新Leader的日志里。因为提交需要多数派,投票也需要多数派,两个多数派交集里至少有一个节点同时包含已提交日志和投了票。投票者在投票时会比较候选者日志是否至少和自己一样新,而自己包含已提交日志,候选者要想通过比较,它的日志必须至少和自己一样新,也就是说候选者的日志要么包含这条已提交日志,要么后面的日志任期更大。但如果候选者最后的日志任期更大,它在逻辑上可以认为自己的日志“更新”,投票者会投给它。这时论文的安全性证明为什么还能成立?
我再仔细想一下这个例子:S4最后一条日志Term=2的no-op条目,是它自己当选后追加的。但在选举时,S4还没有追加no-op,它最后一条日志就是任期1的Index 50。投给它的S2、S3最后一条日志是任期1的Index 200。在选举时,S4日志Index 50、Term 1,S2、S3日志Index 200、Term 1,按比较规则,S4的Term不大于S2、S3,且Index 50<200,所以S2、S3会拒绝投票。这样S4就无法在S2、S3存在时当选。如果S2、S3真的不在线(分区),S4和S5组成2个节点,凑不够多数派,还是不能当选。所以安全。
看到这里应该明白了:Raft为了保障安全,牺牲了“任何时刻任意分区都能继续工作”的可能性。多数派是必要条件,而日志新鲜度是防止旧Leader复活覆盖日志的保险丝。理解了这一点,你就握住了Raft正确性的钥匙。
4.2 选举限制到底限制了什么
选举限制的本质是:投票者不投给日志没有自己新的候选者。这避免了一个日志落后的节点在没有任何新日志的情况下当选Leader,从而避免它覆盖已经提交的旧日志。
这个限制在工程实现里很容易被忽略,尤其是很多人做测试时只用3个节点、日志很少,根本触发不了这个问题。生产环境一旦发生网络分区、节点重启、日志回退,这个限制就是保命绳。
我在实现Raft时,最开始为了省事,只在AppendEntries里做了日志一致性检查,选举投票就简单比较了个任期,结果在混沌测试里跑出过“日志倒流”的Bug。后来对照论文逐条核查才补上投票比较逻辑。这里有一条经验:Raft里有三个地方必须严格做日志新鲜度比较,缺一不可——投票选举、Leader的心跳、Follower响应AppendEntries的一致性检查。这三处任何一处放松,安全性就会被破坏。
5. 选举、心跳、网络分区:Raft的全貌
5.1 Leader选举的细节
Raft把节点分为三种状态:Leader、Follower、Candidate。Follower被动接收来自Leader的心跳和日志;Leader负责处理客户端请求、日志复制、心跳维护;Candidate是选举中的临时状态。
选举的启动条件是Follower在选举超时时间内没有收到来自Leader的合法心跳。每个节点的超时时间都是随机的(论文建议150ms到300ms之间),这样多个Follower同时发起选举的概率会降低。一个节点从Follower变成Candidate时,它会把自己的任期号加1,给自己投一票,然后并行向所有其他节点发起RequestVote RPC。
收到投票请求的节点,会检查候选者的任期号是否不小于自己,以及候选者的日志是否至少和自己一样新。如果这两个条件满足,且它在本任期还没有投过票,就会投票给候选者。投票结果有三种可能:
- 赢得多数派票数,当选Leader,开始向其他节点发送心跳。
- 收到其他候选者成为Leader的消息,如果新Leader任期不小于自己,自己转为Follower。
- 没有人在一定时间内获得多数派票数,选举超时,重新开启新一轮选举。
第三种情况在现实里很常见,尤其是网络抖动频繁时。解决方法是随机化选举超时,并且每个节点的随机范围要有足够的分布宽度。如果集群里节点数很多,建议把随机范围拉大,比如300ms到500ms,避免多个节点同时超时导致“选票分裂”。
5.2 心跳不只是心跳
Raft的“心跳”(AppendEntries RPC)其实是日志复制的同一个RPC,区别在于是否携带日志条目。Leader即使没有新日志,也会在每个心跳周期发送空AppendEntries给所有Follower,作用包括:
- 维持Leader权威:Follower收到心跳后重置选举超时,并认可Leader任期。
- 携带提交信息:心跳里带有Leader的最新commitIndex,Follower据此把已提交日志应用到状态机。
- 传递变更:节点配置变更、快照安装也会通过类似的RPC通道传递。
很多人在实现里把心跳和日志复制分成两条独立路径,这样做容易导致复杂度和Bug同步上升。建议统一为一个AppendEntries处理函数,只是参数中日志条目是否为空不同。这个设计能把大部分复制逻辑收拢到一处,逻辑也更清晰。
5.3 网络分区:Raft会怎么表现
网络分区是Raft最经典的测试场景。假设5个节点分成两边:一边3个节点,一边2个节点。
3个节点的分区(多数派)可以正常选举Leader、接收请求、提交日志,集群对外仍可用。2个节点的分区(少数派)无法构成多数派,所以无论它怎么发起选举,都无法产生新Leader。这个分区里的客户端写请求会失败,但Raft的正确性此时体现在:少数派分区里不管攒了多少日志,一旦分区恢复,它们都会被新Leader强制回退,不会污染主流程的数据。
Raft在分区下的行为其实很适合做故障演练。你可以故意拔掉某个节点的网线,观察Leader是否在超时后自动切换,观察请求是否出现超时但数据最终一致。半分区测试会暴露很多基础实现的隐藏问题,比如投票逻辑错误、提交索引推进不正确等等。
6. 基于Raft的KV存储实战
6.1 从Raft算法到可用的存储系统
最常用的Raft入门实践就是实现一个基于Raft的KV存储。etcd、Consul的内核,本质上就是Raft加上状态机。理解这个组合方式,你能对整个分布式存储行业的常见架构有直观的认识。
先看整体分层:
客户端 | | HTTP/gRPC 请求 v API 层(解析请求、权限校验、参数路由) | v Raft 层(日志共识,决定哪些操作可以被提交以及提交顺序) | v 状态机层(执行引擎,把日志应用到内存KV或磁盘引擎)Raft层不关心你的Command具体是什么,它只关心“这些Command如何按顺序在所有副本间达成一致”。所以KV的“Set”“Delete”操作,在Raft眼里只是一串不透明的字节。这个设计叫“复制状态机”:所有副本从相同的初始状态出发,依次执行相同的日志命令,最终得到相同的状态。
我见过很多人把Raft和KV存储逻辑耦合在一起写,例如直接在自己实现的Raft函数里读KV存储的数据来做决策。这样做在测试的时候能跑通,但一旦要切换存储引擎或者做快照,就会搞得一团糟。Raft和状态机的接口应该只有两个方向:Raft提交日志后调用状态机“应用”,状态机在需要时向Raft提供快照。除此之外它们不该有任何直接交互。
6.2 State Machine 和 Commit 通道设计
实现KV存储时,最关键的一点是:Raft提交日志和状态机应用日志不能耦合在一个临界区里。
客户端写请求到了API层,API层调用Raft的Propose方法把日志条目交给Raft。Raft拿到后复制到多数派,标记为已提交,然后通过一个applyCh(Go语言中几乎是标准示范,其他语言对应一个订阅通道)把这条日志投递给状态机应用。状态机应用完成后,再通知API层返回客户端结果。
这个“异步apply通道”设计是Raft实现的标配,几乎所有的教学代码都使用这种方式。这样做的好处是:Raft的主循环不会被状态机执行速度拖住,日志复制的吞吐量不会因为某个磁盘IO慢而下降。
但异步设计有个副作用:客户端请求发出后,它不知道什么时候真正提交,所以必须有一个“等待提交确认”的机制。这个机制有两种常见实现:
- 每个Propose请求注册一个回调通道,应用完成后往通道里送结果。
- 用一个Map维护LogIndex到响应通道的映射,应用完成后取出通道发送结果。
第一种简单但不太好处理乱序和重复回调;第二种更常用,但要注意在宕机重启后清理未完成请求。除此之外,Leader在处理客户端请求时,还需要一个“转发”逻辑:如果Client把请求发给了Follower,Follower需要把请求转发给Leader,或者直接返回Leader的地址,让客户端重定向。
6.3 线性一致性与读请求处理
读请求比写请求隐蔽得多。如果不加处理,一个Follower直接读取本地状态机,很可能会读到过期数据。比如一个写请求刚被Leader提交,但还没通过heartbeat通知到这个Follower,Follower这时读出来的是旧值。
Raft实现线性一致读的主流方案是:
- ReadIndex:客户端读请求到Leader,Leader记录并等待当前已提交日志到最新,然后向所有节点发送一次心跳确认自己仍是Leader,通知所有节点“一个read index已经到了什么位置”,之后用该位置读取自己的状态机。
- LeaseRead:Leader在心跳间隔内认为自己是Leader,直接读本地状态机。这种方案性能高,但依赖时间假设,网络很不可靠的场景下可能读到过期数据。
- 通过日志复制读请求本身:每个读请求都走一次Raft日志提交,最简单但性能极差,生产环境一般不这么干。
工程实践里,Raft库(如etcd的raft-rs)默认支持ReadIndex,性能比日志读高很多,而且实现并不复杂。核心思想是Leader在返回读结果前,先“确认权威”:向多数派发送一个只包含提交信息的RPC,等它们回应后,Leader确认自己任期没变,然后直接读取本地状态机。读操作不需要写日志,这极大降低了读延迟。
6.4 日志压缩与快照安装
日志无限增长意味着磁盘空间和回放时间的无限增长。Raft必须定期截断日志,生成快照。快照是某个时刻整个状态机的完整状态。生成快照后,快照之前的所有日志都可以删除。
这里有个细节:快照由哪个节点生成?Leader和Follower都可以独立生成快照。Leader因为日志最完整,通常由它周期性做快照。Follower如果在收到下一个心跳时发现自己需要的日志已经被Leader截断,它会通过InstallSnapshot RPC从Leader拉取快照。
快照在工程上需要注意的事情很多。我做KV存储时,快照生成前要保证状态机处于一致的存储状态,否则两个节点从同一快照启动会得到不同状态。常见做法是:在生成快照前暂停状态机的apply,等快照写完再继续。暂停时间可能长达几百毫秒,对高吞吐的服务会造成抖动。优化方案是使用多版本存储引擎(如leveldb、rocksdb的snapshot机制),在引擎层面做备份,应用层不用暂停。这个优化,生产级系统基本都是必做的。
注意事项:
- 快照文件中要包含应用的元数据(比如最后的应用索引、当前配置)。
- 不要用“快照是整个状态机内存对象”这种粗暴序列化方式,大数据量下会直接OOM。
- 节点收到快照后,必须丢弃本地状态机,然后加载快照,再继续接收后续日志。这个过程叫“状态机替换”。
6.5 集群成员变更
最容易被忽略的Raft功能是成员变更。把节点从3个扩容到5个,或者把故障节点摘掉,都不是简单“告诉集群加一个节点”的事。
直接让所有节点同时切换到新配置,会出现安全风险:旧配置的多数派和新配置的多数派可能没有交集,导致两个Leader同时产生。Raft论文提出了“联合共识”(Joint Consensus):先从旧配置切换到“旧+新”联合配置,再切换到新配置。每个阶段都需要对应的多数派同意。
联合共识实现比较复杂,工程中常用另一种简化方案:一次只增删一个节点。通过每次变更只改变一个节点的配置,保证新旧配置的多数派一定有交集。etcd的实现基本就是这个思路,实现起来简单,安全性也够。如果真的需要一次性多个节点变更,建议还是按论文实现联合共识,不要偷懒。
7. Raft与Fabric网络:从拜占庭到可信环境
7.1 Fabric为什么选择Raft而不是PBFT
如果把“分布式系统探幽-Raft”的范围扩展到区块链网络,绕不开Hyperledger Fabric。Fabric 2.x版本的共识组件排序服务(Ordering Service)支持Raft,这是很多人学Raft时都会碰到的语境。Fabric网络里经常出现术语“共识过程示意图”,大多对应的是Raft的选举和日志复制过程。虽然Fabric的官网文档没有把这两者直接等价,但理解Raft确实能帮你一眼看懂Fabric的排序阶段。
Fabric选择Raft而不是PBFT(拜占庭容错)原因很简单:Fabric的信任模型假设网络中的参与者是“已认证但可能宕机/网络故障”的半可信节点,而不是有恶意行为者。在联盟链场景里,所有节点是经过身份认证的联盟成员,节点之间没有很强的对抗性。PBFT能够容忍恶意节点,但性能和复杂度都高得多,对于有KYC(了解你的客户)机制背书的企业间网络,用PBFT属于“过度设计”。
Raft在Fabric中的定位是“排序服务”,负责把交易排序成区块。每个区块本质上就是一组有序的日志条目,区块高度对应日志索引,所有Orderer节点通过Raft达成一致的排序和区块内容。
7.2 把Fabric的Raft行为映射到标准概念
理解了Raft,再来看Fabric里的几个名词会清晰很多:
- Orderer节点:对应Raft的节点。多个Orderer组成一个Raft集群。
- Channel:Fabric的通道在Raft语境里对应一个独立的Raft日志复制流。每个Channel有自己独立的日志和应用状态,Orderer节点可以同时参与多个Channel的Raft实例,但每个Channel的状态互不影响。
- 区块:Raft日志中一批条目的有序集合。Leader把哪些交易放进哪个区块,能以什么顺序排序,就是Raft日志的顺序。
- 世界状态(World State):Peer节点通过提交区块更新本地的世界状态,这对应Raft状态机的“应用”过程。区块被确认(即Raft中该区块对应的日志被提交)后,Peer执行交易并更新世界状态。
读Fabric源码时,你会看到Raft库里的Ready结构体、Propose调用、ConfChange配置变更,这些概念和非区块链Raft实现完全一致。只要把“区块”当成状态机的输入,把“世界状态”当成状态机输出,整条链路就通了。
7.3 Fabric Raft的配置和观察点
Fabric的Raft排序服务配置通常在configtx.yaml里定义ConsensusType为etcdraft,并配置ClusterOptions、TLS等相关参数。实际开发中,有一个非常实用的观察方式:通过 raft metrics 观察节点是否正常。常见的指标包括leader变化次数、节点是否处于活跃状态、待提交日志数量增长情况。
如果Fabric网络出现交易排序变慢的情况,先不要怀疑区块链本身的问题,而是去查Raft层。先用raft node metadata查看当前Leader是哪个节点,再看有没有节点掉线。很多时候问题只是某个Orderer节点因为磁盘满被踢出Raft集群,导致整体可用性下降。顺着Raft的思路排查,比在Peer层翻日志高效得多。
8. 实操中常见的“坑”清单
8.1 我反复踩到的5个问题
在我自己实现Raft和调试Raft集群的日子里,有一批问题反复出现,每次都要花很长时间排查。把它们列出来,希望能帮你少走弯路。
问题1:选举超时设置不当,导致频繁Leader切换症状:日志里经常出现“Leader变更”“选举开始”消息,客户端请求时不时超时。 原因:选举超时太短,或各节点超时时间过于接近,心跳稍慢一个RTT就触发了选举。 排查方法:对比各节点的超时时间和心跳间隔。常见配置是心跳间隔100ms,选举超时300ms-500ms,否则很容易误判Leader故障。 经验:节点多的集群,一定要把随机范围做得足够分散,不然同时产生多个候选者导致选票分裂的概率会显著上升。
问题2:日志一致性检查只检查Index不检查Term症状:数据最终不一致,两个节点在某些日志上的内容不一样,但客户端没感知,等到故障恢复时爆发。 原因:Follower在追加日志时,只比较了prevLogIndex是否匹配,没有检查prevLogTerm。如果一个日志条目Index相同但Term不同,说明Leader和Follower的日志在前缀处产生了分歧,不能简单追加。 排查方法:加强日志一致性检查,Index和Term必须同时相等才能追加。 经验:这条不止是实现细节,更是Raft正确性的基石,千万不要省略。
问题3:节点重启后日志回放速度过慢症状:一个节点宕机10分钟后重启,恢复时间比预期长很多。 原因:日志没有做快照,重启时需要从头回放所有历史日志。 排查方法:检查快照生成周期。生产环境里快照不能做得太频繁(会占用IO),也不能做得太少,要根据状态机大小调整。 经验:我推荐的做法是“日志长度每增长N条就自动做一次快照”,N根据内存占用和回放耗时来定。回放时间超过30秒就说明快照策略太保守了。
问题4:成员变更时直接把节点从配置里移除症状:集群节点数为偶数时,新老多数派无交集,出现双Leader。 原因:配置变更不是简单的“去掉一个节点”。在变更期间,新旧配置的多数派必须始终有交集,这要求保证“任一时刻多数派集合有交叉”。 排查方法:使用论文中的单节点变更方案,一次只增删一个节点。 经验:成员变更出问题很难复现,因为它只发生在特定网络状态下。生产环境升级集群,建议所有节点经过严格的预演。
问题5:把心跳和日志复制分开实现,导致日志复制滞后症状:日志提交正常,但Follower的状态机应用速度明显落后于Leader,积压越来越大。 原因:Follower的AppendEntries处理里,心跳路径和日志复制路径独立,处理日志复制的并行度不够。 排查方法:检查Follower的apply通道是否阻塞。如果apply通道缓冲太小,应用线程处理不过来,日志就被堵在通道里。 经验:把apply通道的缓冲适当调大,并监控积压长度,是避免因应用线程抖动导致复制滞后的有效手段。
8.2 排查问题的工具和手段
在实际工作中,Raft节点出现问题时,我最常用的排查手段有三类:
- 日志历史查询:Raft库通常保留最近N条历史日志,可以先查日志的Term和Index分布,判断节点是否出现了“日志分歧”。对比不同节点上同一Index的日志Term,能快速定位是哪个节点上的日志分叉了。
- 指标监控:Prometheus+Grafana是标配。重点关注
raft_leader_changes_total、raft_term、raft_log_committed_index、raft_log_applied_index这类指标。当raft_log_committed_index和raft_log_applied_index长期不相等时,说明状态机应用出现了瓶颈。 - 故障注入:用网络延迟、丢包、节点暂停等方式主动制造故障,观察集群在故障恢复后是否仍然收敛到一致状态。这比事后排查更有价值,因为它能在问题发生前暴露设计缺陷。
9. Raft实现的代码结构参考
给一个Go语言的Raft实现结构,方便你把上面的原理对照到代码上。这不是完整的Raft实现,只是一个便于理解的分层参考。
// raft.go 核心状态 type Raft struct { mu sync.Mutex id int peers []string state StateType // Follower, Candidate, Leader currentTerm int votedFor int log []LogEntry commitIndex int lastApplied int nextIndex []int matchIndex []int applyCh chan ApplyMsg rpcCh chan RPCRequest snapshot []byte } type LogEntry struct { Term int Index int Command interface{} } type ApplyMsg struct { CommandValid bool Command interface{} CommandIndex int SnapshotValid bool Snapshot []byte SnapshotTerm int SnapshotIndex int }这个结构里最容易出错的是nextIndex和matchIndex的初始化和更新。Leader选举成功后,nextIndex要被设置为“自己的日志长度+1”,matchIndex初始化为0。之后每成功追加一条日志,matchIndex[i]更新为该Follower的最新日志索引;收到失败响应时,nextIndex[i]减小,直到日志一致。
// 选举超时判断 func (rf *Raft) isLeaderAlive() bool { rf.mu.Lock() defer rf.mu.Unlock() if rf.state == StateLeader { return true } return time.Since(rf.lastHeartbeat) < rf.electionTimeout }这套代码看起来简单,但它已经能跑通基础的选举和日志复制。要真正达到生产级,还需要加上持久化存储(Term、VotedFor、LogEntries都要落盘)、快照、数据文件恢复、网络传输协议等。我在学习时是先用内存版跑通了整个Raft流程,再逐步加上持久化存储和快照,这样每个阶段的问题都能被清晰暴露和解决。
10. 面试和工程中关于Raft的高频话题
10.1 如何向面试官讲清楚Raft
很多人在面试时讲Raft,容易陷入“先讲Leader选举,再讲日志复制,再讲安全性”的顺序。这个顺序对论文读者适用,但面试官往往更看重你能不能从“问题”切入。
建议的讲述逻辑是:先把“复制状态机”这个概念立起来。分布式系统需要多个副本逐步执行相同的命令序列,才能做到状态一致。然后提出“多副本如何安全地就命令顺序达成一致”这个问题。接下来的答案就是Raft。Raft把问题拆成三个独立子问题,然后逐个解决。每个子问题解决时,强调它要保护的安全边界是什么。最后讲一个具体的网络分区场景,展示Raft如何在分区和恢复后保持正确性。
这样讲有两个好处:一是逻辑链条完整,二是面试官能听出你真的理解Raft在解决什么问题,而不是背论文目录。
10.2 为什么Raft在很多系统里是“刚刚好”的选择
有些系统用Paxos,有些用ZAB,有些用Raft。选型的本质是:协议复杂度和系统面临的故障模型、团队维护能力是否匹配。
Raft的优势在于易理解和易维护,代价是性能上限可能不如精心调优的Paxos实现。但绝大多数业务的共识操作量没有大到用Paxos才能撑住的程度,反而因为Raft的清晰结构,团队能更快定位问题、做出性能优化。从“刚刚好”的角度看,Raft是多数自研分布式系统的最佳起点。
如果哪一天你需要比Raft更高的吞吐量,可以沿着Raft的优化方向走:批量复制、流水线日志、异步提交、快照压缩等。Raft不像Paxos那样很难“拆开优化”,它的模块化设计允许你在不改变核心正确性的前提下,针对某一个子系统专项优化。
10.3 Raft的边界和替代方案的对比
| 特性 | Raft | Paxos | PBFT/HotStuff |
|---|---|---|---|
| 容错模型 | 崩溃故障(非拜占庭) | 崩溃故障(非拜占庭) | 拜占庭故障 |
| 复杂度 | 中 | 高 | 高 |
| 性能优化空间 | 高 | 高 | 中 |
| 应用场景 | 数据库复制、配置中心、KV存储、联盟链排序 | 小部分系统内核 | 公链、弱信任联盟链 |
| 理解门槛 | 低 | 高 | 很高 |
当系统里存在真正恶意节点(会伪造消息、串通攻击)时,Raft是不适用的,这时需要BFT类算法。但在企业内部的分布式数据库、微服务配置中心、中间件组件里,Raft是最常见也最稳妥的选择。
11. 一些实操体会
文章写到这里,主体内容基本就结束了。最后分享一点我在实际项目中的体会。
第一次用Raft作为自研调度系统的一致性内核时,我犯过很多设计错误。比如一开始为了承诺给业务方“毫秒级切换”,把选举超时调得太短,结果网络稍一抖动就触发选举,业务日志里全是超时重试。后来才明白Raft的容错能力是建立在给故障一个合理的时间窗口上的,而不是越快越好的。把选举超时调到合理范围后,系统突然就稳定了。
还有一次是生产环境的KV存储出现了节点重启后状态机应用中止的问题。排查了很久,最后发现是快照加载后没有正确恢复lastApplied索引,导致重启后所有新提交日志都被当作旧日志跳过。修复前,我甚至怀疑过是Raft的选举逻辑出现了问题,但核心其实只是状态机的本地恢复顺序错了。
所以,如果你也在自己实现Raft相关的存储或中间件,我有个建议:先写好故障注入测试,再写功能测试。把节点杀掉、把网络断开、把日志文件删一半,看系统会不会自己恢复到一致状态。Raft不是那种“代码写完就完了”的组件,它是需要持续对抗故障的系统软件。把故障测试跑通过一遍,你对Raft的理解会提升一个量级。
最后,如果你真想深入了解Raft,读论文是必须的。最好配合一个具体的实现项目来读,自己敲一遍代码或者给开源项目修一个bug,比看十篇讲课文章都管用。我自己的经历就是,刚开始看论文觉得枯燥,直到实现了第一个测试通过的Leader选举,才真正对“共识”这件事有种“原来如此”的通透感。希望这篇内容也能帮你找到这种感觉。