先讲个我踩过的坑。前几年给一个数据处理平台做高可用改造,三个节点组成一个集群,平时看起来很稳。结果某天半夜流量上来,一个节点磁盘写满直接掉线,另外两个节点竟然同时进入"自认为主"的状态,各自对外提供写服务。订单数据一查,两边都写进去了,后来整整折腾了一整天去对账。那会儿我才真正体会到,分布式系统里"一致性"不是论文里的名词,而是实打实的钱和命。
后来我系统地把一致性协议翻了一遍,印象最深的还是Raft算法。它干的事情其实很朴素:让一群节点像一个人一样做决策,少数服从多数,并且任何情况下都不允许出现"各说各话"的混乱局面。这篇教程就是想把Raft算法掰开揉碎讲清楚,从角色、任期、选举,到日志复制、安全性、成员变更,最后落到实操和排坑。适合刚接触大数据、分布式系统,或者面试前想快速搞懂一致性协议的人,也适合已经用过etcd、Consul这类组件但没时间读论文的同学。
1. 为什么分布式系统离不开Raft
1.1 副本越多,麻烦越大
先想一个问题:一台服务器的数据要存几份?为了保证高可用,至少得存三份,最好跨机房。数据有了副本,某个节点挂了,其他副本还能顶上。但麻烦也来了——三个副本上的数据不一致怎么办?
举一个最典型的电商例子。一个商品的库存只剩1件,三个节点的订单服务同时收到请求。节点A说"扣掉了",节点B也说"扣掉了",节点C也说"扣掉了",数据库最终显示库存是-2。这可不行。要解决这个问题,核心就是要让集群里的节点对"谁先执行、各自是什么状态"达成一致。这就是分布式共识问题,而Raft就是解决这个问题的算法之一。
在大数据架构里,类似的场景太普遍了。比如HDFS的NameNode元数据、Kafka的控制器选举、etcd里的配置存储,背后都是同一类需求:多个节点必须协作,不能让数据出现分歧。只要你做过多节点部署,迟早会遇到"到底谁说了算"这个问题。
1.2 Paxos太"硬核",Raft选择"可理解"
说到分布式共识,绕不开的其实是Paxos。Paxos是Lamport在1990年代提出的,理论非常漂亮,但现实中不少工程师读论文读到怀疑人生。其中最大的问题是:Paxos只描述了如何对一个提案达成一致,但没有给出完整的状态机实现方案,很多细节需要自己去脑补。有人调侃说,Paxos论文是用来膜拜的,不是用来实现的。
Raft就是在这种背景下出现的。2014年,Diego Ongaro和John Ousterhout发表了《In Search of an Understandable Consensus Algorithm》,核心目标不是"更聪明",而是"让更多人能看明白、能实现"。Raft把共识问题拆成了几个相对独立的子问题:领导人选举、日志复制、安全性、成员变更、日志压缩。每个子问题都有清晰的规则和步骤,不像Paxos那样绕。
这个设计选择背后有一层很重要的思路:在一个分布式系统里,算法的正确性固然重要,但可维护性同样关键。如果一个一致性协议只有极少数人看得懂,那团队维护起来就是灾难。Raft的选择是牺牲一部分理论上的"优雅",换来工程上的"好用"。从后来的效果看,etcd、Consul、TiKV、CockroachDB都用Raft做了底层共识引擎,说明这条路线是走通了的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Raft的三大角色和一任期的逻辑
2.1 Leader、Follower、Candidate,谁干什么活
Raft把集群里的节点划成三种角色:Leader(领导者)、Follower(跟随者)、Candidate(候选者)。这三者的关系,很像团队里的日常协作。Leader是拍板的人,负责接收客户端的写请求、把数据发给所有节点、等多数派确认后宣布"这条数据生效"。Follower是执行者,平时不主动干活,只响应Leader的RPC请求,同时给候选者投票。Candidate是个临时身份,当一个Follower迟迟联系不上Leader,它就会变成Candidate,发起新一轮选举,拉票拉赢了就转正为Leader。
这个分工解决了分布式系统里一个特别实际的问题:如果所有节点都能随意接受写请求,那冲突几乎无法避免。Raft的做法是"平时只有一个Leader,所有写操作都走Leader",从设计上把并行写变成了串行写,冲突概率大幅降低。当然,读请求在特定业务场景下可以从Follower读,但前提是你接受"可能读到旧数据"。
在工程实现里,每个节点内部其实是一个状态机。当前是什么角色、当前任期是多少、给谁投过票、日志里存了哪些条目,这些状态都要被维护好。特别要注意的是,currentTerm、votedFor、日志条目这三样东西必须持久化到磁盘,因为节点重启后要靠它们来恢复身份和避免重复投票。其他状态比如commitIndex和lastApplied,可以重启后通过日志重新推导出来,不需要单独落盘。
2.2 任期(Term):分布式世界的逻辑时钟
有一个细节特别容易被刚入门的人忽略,那就是任期(Term)。Raft把所有时间切成了一段一段的任期,每一段任期以一次选举开始。任期号是一个单调递增的整数,相当于分布式世界的"逻辑时钟"。
为什么要搞任期?举个例子就明白了。老Leader因为网络抖动跟集群失联了,集群选出了新Leader。这时候老Leader的网络忽然恢复,它以为自己还是Leader,继续给Follower发指令。没有任期机制的话,Follower根本判断不了这个指令是真是假。有了任期号,Follower一看消息里的任期比自己当前任期小,直接拒绝并告诉对方"你已经过期了"。这个设计非常朴素,但就是能有效防止"僵尸Leader"捣乱。
每个节点收到任何RPC时,首先要比较消息里的任期号。如果消息里的任期号比自己的大,说明集群已经进入下一个任期了,节点要立刻更新自己的任期,并且如果自己是Leader或Candidate,要马上退化成Follower。如果消息里的任期号比自己的小,直接拒绝。这个"先比任期,再干活"的习惯,是Raft所有节点通信的默认第一步。
2.3 领导人选举:随机超时与多数派投票
选举的触发条件是选举超时(election timeout)。每个Follower都有一个计时器,如果在规定时间内没有收到Leader的心跳,它就认为Leader可能挂了,于是把自己变成Candidate,任期加一,投自己一票,然后向其他节点广播RequestVote请求。
候选者能不能当选,取决于它能不能拿到"多数派"的投票。三个节点就要至少两票,五个节点至少三票。这个"多数派"是整个Raft算法的生命线,因为任意两个多数派必然存在交集,这个交集会保证集群在任何时刻最多只会有一个Leader出现,彻底杜绝"双主"。
而选举时间为什么要随机化?是因为如果所有Follower同时超时,同时发起选举,大家各自拿到一部分票,谁也凑不够多数派,然后又开始新一轮同时选举,陷入死循环。解决办法就是每个节点的选举超时时间是一个随机值,比如150ms到300ms之间随机。这样总有一个节点先发难,先发难的节点更容易拿到其他节点的票。实际工程中,心跳间隔要显著小于选举超时,比如心跳设100ms,选举超时设300ms到500ms,否则正常的网络抖动都会引发无意义的选举。
选举流程里还有一个很容易忽略但极其重要的细节——选票不是盲目的。投票者在决定投给谁之前,要检查候选人日志的新旧程度。具体规则是:候选人的最后一条日志任期号更大,或者任期号相同但日志索引更长,那这个候选人的日志才算"足够新"。只有日志足够新的候选人才值得投票。这条规则直接保证了新Leader不会丢已经提交的数据,后面讲安全性的时候还会再展开。
3. 日志复制与安全性:Raft的立身之本
3.1 一条日志从客户端到状态机的完整旅程
Raft的核心机制是日志复制。客户端发一条写请求,比如"把key的值设为value",Leader会把这条操作封装成一个日志条目,包含两个关键字段:索引(index)和任期号(term)。Leader先把这条日志追加到本地,然后并行发送AppendEntries RPC给所有Follower,带上自己这个Follower应该从哪里开始同步的nextIndex。
Follower收到AppendEntries请求后,会先做一致性检查:看自己日志里对应索引的条目是不是和Leader给的"前一条日志"匹配。如果匹配,就追加新日志并返回成功;不匹配,就返回失败,Leader会把自己的nextIndex往前回退,然后再试。这个过程是一次次协商的过程,直到双方在某条日志上对齐为止。
当日志条目被复制到了多数派节点上,Leader就认为它是"已提交"状态,可以把它应用到状态机里,并且给客户端返回成功。注意,这里不需要所有节点都确认,只要多数派就够了。这就是Raft能容忍部分节点故障的原因——三个节点挂一个,系统照常工作;五个节点挂两个,系统照常工作。
这里我说一个实操中容易忽略的点:commitIndex的推进要保守。Leader在推进commitIndex时,只能推进到自己当前任期内已经被多数派复制的那条日志。至于之前任期的日志条目,即使已经被复制到多数派,也不能直接凭此推进commitIndex。为什么要这样?因为旧任期的日志可能由于种种原因没有复制到当前Leader上,如果贸然推进,并应用到了状态机,后面发现日志不全会造成灾难。Raft的保守策略是:等当前任期有一条日志被多数派复制了,顺便把之前所有未提交的日志一并提交。理解了这个约束,你就理解了很多Raft实现的边界判断。
3.2 谁有资格当选领导人?日志完备性限制
前面提到投票时会检查候选人的日志新旧,这里展开细说。RequestVote请求里会携带候选人的最后一条日志的任期号和索引。投票者比较的标准是:谁的更"新",就投给谁。
这个规则看起来很简单,但它是Raft安全性的基石之一。假如没有这个限制,会出现什么情况?假设集群里有个Follower长期掉线,它的日志落后一大截。网络恢复后它发起选举,另一个日志完整的节点还没来得及响应。掉线的节点拉到了几个同样落后的节点的票,成了Leader。它上台后开始给所有Follower强制同步自己那份残缺的日志,已经提交的条目就被覆盖了。这就是数据丢失。
有了日志完备性限制,日志落后的候选者根本拉不到足够多的票,因为每个投票者都会拿自己的日志和候选人的日志做比较,发现候选人比自己还旧就直接拒绝。这就像公司选拔负责人,候选人简历上缺了关键项目经历,评审委员会自然不放心把团队交给他。这个限制不是额外负担,而是和日志复制规则配合,从源头杜绝了"新Leader会丢已提交日志"的可能。
3.3 已提交日志永不丢失
Raft有一个核心性质,叫"领导人完备性":如果一个日志条目在某个任期被提交了,那么它一定存在于之后的所有Leader日志中。这个性质保证了,只要客户端收到了成功响应,这条数据就不会丢。
为什么这个性质能成立?理解起来也不复杂。假设一个日志条目在第T任期被提交,意味着它已经被当时的多数派节点复制了。之后第T+1任期选出新的Leader,新Leader要获得多数派选票。既然是多数派和多数派,两组节点里至少有一个重叠的节点。那个重叠的节点在第T任期就存着这条已提交日志,所以它会在投票时判断:候选人的日志如果不够新,就不给票。换句话说,没有这条日志的候选人过不了投票关。
这个性质的实际意义是:你不需要在所有副本上都同步完才给客户端确认,只需要多数派确认就可以。多数派确认之后,即使某些副本的数据还没追上,新Leader也一定会带上这条数据。这样既保证了安全,又保证了可用性,是可用性和一致性之间的一种巧妙平衡。
在工程中还有一个必须强调的规则:Leader永远不能覆盖或删除自己的日志,只能追加。如果Follower的日志和Leader冲突,以Leader为准,将其强制覆盖。这个"以Leader为准"的策略可能让人觉得粗暴,但它确保了集群在任意时刻只有一个权威数据源,只要权威数据源本身是安全的,强制覆盖就没有问题。
4. 集群扩容与缩容:怎么安全地改配置
4.1 配置变更为什么危险
集群不是一成不变的,跑着跑着要加机器扩容,或者缩容踢掉一台故障机。改配置看起来很简单,把节点列表改一下就行,但在Raft里这是一个高风险操作。
原因在于,配置变更一旦发生,集群里不同的节点可能在不同时间点知道了新配置。有的节点还在用旧配置,有的已经用新配置了。这时候如果发生Leader选举,旧配置的多数派和新配置的多数派可能分别选出两个Leader,又回到"脑裂"的问题。
举个例子,原有三个节点A、B、C,现在要扩成五个节点A、B、C、D、E。假如配置切换不是原子的,A、B只认旧配置,C、D、E只认新配置。旧配置的多数派是2个节点,A和B就能选出老Leader;新配置的多数派是3个节点,C、D、E就能选出新Leader。两边同时认为自己是Leader,写请求就会写到两份数据里。所以配置变更必须设计得比日志复制更谨慎。
Raft论文给出了两个解决方案:一个是联合共识(Joint Consensus),操作起来很重,需要先切到临时过渡配置,再切到最终配置;另一个是单节点变更(Single-Server Changes),工程上更常用,因为它简单且可以证明是安全的。
4.2 单节点变更:把风险降到最低
单节点变更的核心思路是:每次只添加或移除一个节点。这样变换前后的新旧配置对应的多数派集合必然有交集。比如旧配置是3个节点,新配置是4个节点,旧配置的多数派至少要2个节点,新配置的多数派至少要3个节点,2加3等于5,大于新旧配置总节点数4,因此新旧多数派必然重叠。有这个交集的存在,就不可能同时选出两个互不知晓的Leader。
在实际操作时,Leader收到配置变更请求,先在自己的日志里追加一条特殊日志,记录新的配置,然后把这个日志复制给所有节点。这条日志一旦被新配置下的多数派确认,配置就生效了。这里要注意,配置变更期间Leader如果挂掉,选举逻辑会变得复杂,因为新选出来的Leader可能是从旧配置里选出来的,也可能是从新配置里选出来的。很多成熟的Raft实现会在选举时同时接受两种配置的投票请求,也就是在配置变更过程中允许"双配置投票"。这个细节如果自己动手写Raft,绝对是踩坑重灾区,建议一开始就把它当作单独的测试用例来对待。
如果看etcd、TiKV这类项目的源码,会发现它们做配置变更时基本都是遵循单节点变更的原则:一次只变一个成员。这样实现简单,正确性也有保障。给生产环境做集群扩缩容时,我也建议按这个原则来,哪怕需要连续变更多次,也别图省事一步到位。
5. 动手实践Raft:学习路线与排坑实录
5.1 三步走:看、拆、写
想真正掌握Raft,光看文章和示意图是不够的,必须动手。我自己的学习路线是三步:看、拆、写。
第一步,看可视化。网上有个经典的Raft可视化演示(raft.github.io),它把选举、日志复制、分区、恢复的过程都动态画出来了。你可以手动给某个节点制造故障,看其他节点怎么触发选举,怎么恢复。我先花一下午把各种场景都点了一遍,脑子里对"谁在什么情况下做什么事"有了直觉。
第二步,拆开源项目。etcd里有完整的Go语言Raft实现,不过它为了通用性做了不少抽象,直接读源码门槛有点高。TiKV的raft-rs是Rust实现,结构相对清晰。我的建议是先看日志复制和选举这两个核心模块的测试用例,如果你能看懂测试期望的结果,就说明你已经理解了大部分机制。
第三步,自己写一个简化版。语言选什么不重要,重要的是按顺序做:先实现选举模块,让三个节点能选出一个Leader;再实现日志复制模块,让客户端可以写入一条命令,多数派确认后apply到状态机;然后加上持久化;最后做快照(Snapshot)解决日志无限增长的问题。每一步都配一个网络分区测试。哪怕最终只支持三个节点,这个过程也会把论文里所有模棱两可的细节逼出来。
5.2 常见问题速查表
我在学习Raft的过程中收集了一些高频问题,这里整理成一张速查表,有同样困惑的朋友可以直接对照排查。
| 问题 | 原因 | 建议做法 |
|---|---|---|
| 选举超时为什么必须随机 | 如果所有Follower同时发起选举,选票容易分裂,反复选不出Leader | 每个节点在固定区间内随机选一个超时值,区间下限要远大于心跳间隔 |
| 两个节点能不能组成一个Raft集群 | 多数派就是2个,任意一个节点故障就无法提交,集群可用性为0 | 可以运行,但只适合测试;生产环境最少3节点,最好5节点 |
| Follower的日志比Leader还完整 | 新Leader可能只包含了多数派确认过的日志,而某些Follower有全部日志 | Follower的日志以Leader为准被覆盖;已提交部分会先被复制到Leader再补全 |
| Leader被网络分区隔离时集群为什么还能工作 | 分区后,多数派一侧能选出新Leader并提交数据,旧Leader拿不到多数派 | 旧Leader恢复后会发现自己任期太旧,自动退化成Follower,被强制同步新日志 |
| 日志无限增长怎么办 | 客户端请求过多,日志条目越积越多,内存和磁盘压力大 | 引入快照机制,将已经应用的状态机做一次完整快照,快照前的日志可以丢弃 |
| 旧Leader恢复后会不会干扰新Leader | 会,但任期机制会挡住一切过期消息 | 每个RPC首先比较任期号,旧Leader的请求会被Follower拒绝,旧Leader自身也会掉回Follower |
还有一个新手经常理解错的概念:commit和apply的区别。commit指的是"日志条目被复制到多数派,保证不会丢",apply指的是"把这条日志里的命令真正执行到状态机里",比如写入变量、更新数据库。commit先于apply,apply之后客户端才能看到结果。很多看似奇怪的行为,追根到底都是这两者之间有时差导致的。
再补充一个实用技巧:生产环境里给Raft心跳超时和选举超时调参时,别照着论文的数值直接抄。论文给的是通用场景的参考值,实际要考虑网络平均延迟、磁盘读写耗时、GC停顿时间。如果节点频繁发生无意义的Leader选举,优先检查是不是选举超时设置得太接近心跳间隔了;如果节点故障后集群恢复太慢,再考虑是不是超时设得过长。调参的标准很简单:正常情况下零选举,异常情况下能快速选出Leader。
最后再分享一点个人体会。我最初学Raft时总想把论文里的每个证明都看懂,结果越看越头疼。后来换了策略,先看可视化演示建立直觉,再看论文里的"规则"部分,最后自己动手写代码。写完几轮测试再回头看证明,才发现其实没那么难。如果你也卡在某个证明上,可以暂时往前放一放,先把选举和日志复制两个主流程跑通。分布式系统的很多概念,写一遍代码胜过读十遍论文。
