如果你接触过分布式系统,一定绕不开这个经典问题:一堆机器组在一起,要对外表现得像一台机器,多副本之间怎么保持一致?Raft算法就是目前被大规模落地的一致性协议之一,etcd、Consul、TiKV这些你耳熟能详的组件,底层核心逻辑靠的都是它。这篇教程我会用“脑补图解”的方式,把Raft从Leader选举、日志复制、安全机制到工程实践全部拆开,适合刚接触大数据和分布式系统的同学,也适合那些读过点理论但想把细节串起来的人。
写这篇东西之前,我先说句大实话:Raft的论文只有16页,但真正吃透它的人不多,因为网上大多数教程只讲了“选主”和“复制日志”这两件事,却忽略了背后的安全约束。而恰恰是这些约束,决定了Raft在断电、断网、节点宕机的混乱场景下能不能保证数据不错不乱。下面我开始讲正事。
1. 为什么大数据系统离不开Raft:从一致性难题说起
1.1 多副本到底解决了什么问题
先想象一个最简单的场景:你有一台数据库服务器,存着用户订单。某天服务器硬盘坏了,数据全丢。这时候你才意识到,靠单机扛所有数据是非常脆弱的。最简单的解法是复制:多放几台机器,每台都存一份完整数据。一台挂了,另外几台还能顶上。
副本多了以后,新的问题立刻出现:多个副本之间怎么保持“同一个状态”?客户端往A机器写入“订单金额=100”,B机器怎么知道自己也要改成100?如果B机器没收到同步消息,用户下次访问B时读到的还是旧的订单金额,这时候系统就出问题了。
这就是分布式系统里“一致性”的基本面:多个节点之间,数据要收敛到同一个结果,而且对外要表现得像是只有一个副本。Raft正是用来解决这个问题的一类协议——一致性协议。
1.2 两台机器都觉得自己是老大:脑裂的根源
很多人会想,那我让所有副本都接收写请求,每个节点写入后互相通知一下,不就行了吗?现实中这条路走不通,原因很经典,叫“脑裂”。
想象两个副本A和B,中间网络断开了,但两台机器本身都活着。客户端1还在A上正常读写,客户端2连上了B,B也觉得自己活得好好的。这时候系统里同时出现两个“主”,两边都在接受写请求,数据立刻分叉。等网络恢复,谁也说服不了谁改自己的数据,因为两边都干了很多“真事”。
破解脑裂的办法也很古老:多数派。三个节点的集群,网络分区把集群分成“1个节点”和“2个节点”两部分,只有拿到超过一半节点支持的“老大”才算合法。这样一侧只有一个节点,拿不到多数派,自然不敢接受写操作;另一侧有两个节点,成了合法的少数服从多数。这就是Raft最核心的思路基础:多数派仲裁。
1.3 Paxos的教训:大神们写得看不懂,工程拿去也不好用
聊Raft之前,不得不提它的前辈Paxos。Paxos在理论上是完备的,也是很多年间分布式系统教科书里的一尊神。但Paxos有个致命短板:太难理解,而且论文里的算法骨架离工程实现太远。Lamport本人当年用拜占庭将军故事打比方,结果多数人连故事都没看懂。后续做工程的人各自为战,同一个Paxos在各家实现里千差万别,正确性很难验证。
Raft的出现就是为了治疗这个痛点。它的作者Diego Ongaro和John Ousterhout在论文里开宗明义:设计目标不是发明一套新的共识算法,而是让共识算法能被更多人理解。Raft把问题拆成了几个相对独立的子问题:Leader选举、日志复制、安全性、成员变更。每个子问题都有明确的机制和规则,你可以一项一项学,不用一上来就啃高深的数学证明。这也是为什么现在提到“入门一致性协议”,大家第一推荐基本都是Raft。
2. Leader选举:Raft的决策核心是怎么运转的
2.1 三种角色与状态转换模型
Raft把集群里的所有节点划分为三种状态:Leader(领导者)、Follower(跟随者)、Candidate(候选人)。正常情况下,每轮任期内集群只有一个Leader,其余节点全是Follower。Follower被动接收来自Leader的消息,不主动发起任何写操作;Leader负责接收客户端请求、分发日志、推进提交;Candidate是Follower在选举超时后进入的临时状态,用来争取下一任Leader的位置。
这三个状态形成一个环:
Follower -> Candidate -> Leader ^ | |________ 任期结束/发现更高任期 ________|文字描述一下流程:所有节点启动时都是Follower,维护一个任期号Term。Follower如果在一个随机的超时时间内没有收到Leader的心跳(AppendEntries RPC),就认为Leader可能挂了,于是自增Term,切换到Candidate,并向其他节点发起投票请求。如果获得多数派支持,Candidate升为Leader;如果发现收到的响应里带着更高的Term,说明有别的节点在发起新一轮选举,自己会让步转为Follower;如果票被几个候选人瓜分,没有人拿到多数票,候选人会等待超时后再次发起选举,并随机重置超时时间以减少再次冲突的概率。
2.2 Term(任期)和随机化超时是Raft聪明的时钟
Term是Raft的一张逻辑时钟,从1开始递增,每次发生选举,任期号就加一。Term在Raft里是一个极其重要的标尺:Leader发心跳会带上自己的Term;Follower收到更高Term的请求,立刻承认新主;节点发投票请求时也带上当前Term,别人一看就知道这是哪一轮选举。可以说,Term贯穿了整个Raft的每一个判断。
选举超时怎么设置?Raft论文建议的时间是150ms到300ms之间随机选取。为什么一定要随机?因为如果所有Follower同时超时,它们会同时变成Candidate,同时互相投票,票数永远被摊分,集群永远选不出Leader。而随机的超时时间让每个节点重新发起选举的时刻错开,多数时候只有一个节点先发起选举,它最容易拿到多数票。
需要特别提醒的一点是:心跳间隔必须显著小于最小选举超时。生产环境中常见设置是Leader每100ms发一次心跳,而选举超时在300ms到500ms之间随机。这样正常运行时,Follower会被心跳不断刷新超时计时器,不至于频繁触发选举。我见不少新手实验时把心跳间隔和选举超时设得差不多,结果集群不断“选新主”,根本跑不稳。
2.3 RequestVote投票流程和日志比较规则
Candidate发起选举时,会并行给集群内所有其他节点发送RequestVote RPC,里面带两个关键信息:最后一条日志的任期号(lastLogTerm)和最后一条日志的索引(lastLogIndex)。这两个字段决定了候选人有没有资格当选。
Follower收到投票请求后,按两层条件判断:
- 如果自己的投票记录里本轮Term已经投给过别人,那这一票不能给。
- 如果候选人的日志不如自己“新”,那这一票也不能给。
什么叫“日志更新”?比较规则很简单:先比较lastLogTerm,谁的任期号更大谁更新;如果任期号相同,谁的lastLogIndex更大谁更新。这个规则背后是安全性的一个核心约束,我后面单独展开。这里先记住结论:Raft的选举不是看谁嗓门大,而是看谁的日志更接近完整状态。
2.4 平票场景与Pre-Vote机制
极端情况下还是会出现平票:一次心跳断了,两个Follower同时进入候选状态,集群分成了两组各投各的。Raft应对平票的招数就是前文说的随机超时:这次没选出来,下个随机超时到来时,某个节点先发起新一轮选举,通常就能打破僵局。这个机制简单粗暴但有效,也是Raft亲民的一个体现。
工程实现里,很多成熟框架还会额外加一个PreVote机制。PreVote的逻辑是所有节点进入选举之前先“预投票”:Candidate向别人问,如果我现在发起选举并且自增Term,你们会支持我吗?只有获得了多数预选票才真的发起选举。这么做的意义在于:一个被网络分区隔离的节点,短暂恢复后又马上断开,如果没有PreVote,它会反复自增Term,打断整个集群正常的工作。PreVote避免了这种无意义的Term膨胀。这个点论文里没有强制,但实际做分布式存储时基本必加。
3. 日志复制与提交:让所有节点保持一致的关键链路
3.1 一条写请求的完整旅程
集群选出了Leader,客户端写请求进来后,数据是怎么从一个点扩散到所有节点的?我按顺序拆一遍:
- 客户端把写请求发给Leader。
- Leader将请求追加为本地的日志条目(Log Entry),此时日志处于“未提交”状态,不能应用。
- Leader并行向所有Follower发送AppendEntries RPC,里面带上新日志条目。
- 每个Follower收到后,先做合法性检查,通过则把日志追加到自己本地,然后返回成功。
- Leader收到超过半数节点的成功响应后,将这条日志标记为“已提交”,状态机应用这条日志,然后向客户端返回写成功。
- Leader在下一轮心跳里顺带把commitIndex广播给所有Follower,Follower也在此时应用日志到状态机。
这个有序链路是Raft一切正确性的基石。注意第5步里“超过半数”是关键:只要多数派把日志落盘了,即使少数派丢失了这条日志,多数派内部依然能保证这条数据在后续选举中存活。
3.2 日志条目长什么样:term、index、command
每个日志条目实际上就是一个三元组信息:
- Term:这条日志产生时的Leader任期号。
- Index:日志在整体序列里的位置编号。
- Command:具体的操作内容,比如“set key=5”。
Term和Index合在一起,给每条日志在全局空间中一个唯一的坐标。Raft的日志匹配特性是这么描述的:如果两个节点上某条日志的(Term, Index)相同,那么在它之前的所有日志一定也相同。这个特性是Raft能高效同步日志而不用全量对账的基础。
为了维护这个特性,Leader向Follower追加日志时,AppendEntries RPC里还带了previousLogTerm和previousLogIndex。意思是:请你在覆盖我之前,先检查你的日志序列在指定位置是否和我一致。如果Follower发现自己的prevLogTerm对不上,会直接拒绝这条请求。Leader收到拒绝后,把自己要发送的日志往前回退一格,重新尝试,直到找到一个共同匹配点,再从这个点往后批量追加日志。整个过程类似一次回溯式的对齐。
3.3 提交的秘密:多数派确认才commit
提交(Commit)是Raft里最容易被误解的概念之一。日志被“写入”某个节点,并不代表它被“提交”了。被提交的定义是:Leader确认这条日志已经被复制到多数派节点上。只有已提交的日志才能应用到状态机,也才能向客户端返回成功。
这里有个很微妙的规则:Leader不能想当然地提交旧任期遗留下来的“未提交日志”。假设当前是任期5,Leader发现任期4有一条日志已经复制到多数节点,但它直接把它标成已提交,这可能是错的——因为这条旧任期日志在更早的任期里可能没有被复制到某几个关键节点,而随着新的写入,这条日志有可能被未来某个日志覆盖。Raft论文里给出的精确规则是:只有“当前任期的日志”被复制到多数派,Leader才能用当前任期的日志提交,顺带把前面旧任期的未提交日志一并提交。判断能不能提交,必须依赖当前任期的一次多数派确认来做担保。
这个规则初看有点绕,但背后逻辑其实是:最终提交一定得经过一次“现任领导确认”,避免半路杀出个旧数据把它覆盖掉。理解了这一点,你就基本摸到了Raft正确性的命门。
3.4 读请求:别以为读Leader就安全
Raft的写路径我们讲清楚了,但读请求一样有坑。如果客户端直接读Leader节点上的状态机,拿到的结果不一定是线性一致的。原因很简单:Leader可能在网络分区的角落里,已经不算多数派了,但它自己还不知道。此时旧的Leader上还留着旧数据,客户端来读,读到的是过期的数据,这在需要强一致性的系统里是绝对不允许的。
解决读一致性的通用方案是ReadIndex:Leader接到读请求后,先向多数派确认自己仍然是本任期有效的Leader,然后等自己状态机应用到了当前commitIndex,再返回本地状态机的结果。另一个常见优化是Lease Read,即利用心跳租约,在租约有效期内跳过多数派确认,直接读本地状态机。Lease读性能好,但对时钟稳定性有要求,如果节点间时钟跳跃过于夸张,租约判断可能出错。生产环境中,并不是所有系统都开Lease读,一些强约束场景宁可损失性能也要保证线性一致性。
4. 安全性设计:Raft如何避免脑裂和数据丢失
4.1 Raft的三条安全规则
很多讲Raft的文章把选举和日志复制讲完就收工,但少了安全性这部分,你对Raft的理解永远是残缺的。Raft能够保证正确性,靠的是两条贯穿所有机制的安全规则加上一条一致性前提:
- 选举限制:只有包含全部已提交日志的节点,才有资格当选Leader。
- 日志提交规则:旧任期的日志不能独立进行提交,必须依赖当前任期的一次多数派确认。
- Leader完整性:一旦日志在某个任期被提交,它一定存在于未来所有任期的Leader日志中。
这三条合起来,保证了“已提交的数据永不丢失”和“所有节点以相同顺序应用日志”这两个核心性质。用大白话讲:只要你的数据被系统确认“提交成功”,那么之后不管集群怎么折腾、怎么分离、怎么换主,这条数据都丢不了。
4.2 经典的反例:为什么日志旧的节点选不上
我用一个典型场景让你感受一下选举限制的意义。假设集群有5个节点,A是任期4的Leader,它收到了“key=value”这条写请求,并成功把它复制到了B和C两个节点。此时A、B、C三条日志有这条数据,但D、E还没有。这条日志已经算多数派复制,A会把它标记为已提交并返回客户端成功。
随后A宕机。E变成了Candidate,发起任期5的选举。E的日志里根本没有“key=value”这条日志,按我们反复提过的“日志新旧比较”规则,D、E自己的日志都比E“新”吗?不一定。但B和C手里有已提交的日志,而这部分日志的lastLogIndex比E大,所以B、C会拒绝给E投票。最终E拿不到多数票,选举失败。B或C由于日志较新,更可能在下轮选举中当选。
如果当初E靠着较低的门槛当选了Leader,它就缺少一条已提交日志,后面它当选后开始覆盖其他节点日志,而恰恰被覆盖的日志里包含那条已经确认给用户成功的“key=value”,数据就这样在用户眼皮底下消失了。Raft通过选举限制硬性堵死了这条路。
4.3 网络分区下的行为:Raft如何保证只有一个主
现在可以把脑裂场景完整梳理一遍。假设集群3个节点,A原本是Leader,网络分区把A单独隔开,B和C成为另外大半区。
分区瞬间,A还在任期内继续向客户端提供服务,但它发出的心跳到不了B、C,因此AppendEntries得不到任何响应。A只有自己一个节点,复制“成功”的日志远不够多数派,于是A无论如何都不能提交新数据,写请求在A这边的表现就是卡住或超时。
而在另一边,B、C收不到心跳后开始选举,B或C拿到两票中的多数支持,在一轮短暂混乱后选出了新的Leader,继续正常服务。客户端写这边,日志都能正常提交。当分区恢复,旧Leader A发现新Leader的Term比自己的高,就会认怂转为Follower,并把自己那些未提交的日志统统回滚。对外表现就是:分区期间旧Leader那边没写成功的数据最终不会留在系统里,而通过新Leader提交的数据一条不会丢。
这就是Raft应对脑裂的完整答案:不是阻止脑裂发生,而是用多数派和Term机制,让脑裂中的孤立侧完全无法提交数据,让大脑天然地归向多数派一侧。
5. 集群成员变更、日志压缩与快照:生产环境绕不开的进阶话题
5.1 成员变更的危险:为什么把配置改来改去会出大问题
一个分布式集群不会永远固定5个节点,扩容、缩容、节点替换都需要改集群配置,也就是“当前集群有哪些成员”。但配置变更不能随意执行,原因是一个典型的重叠多数问题。
假设现在集群是3节点,配置A是{c1, c2, c3},你要改成配置B是{c1, c2, c3, c4, c5}。如果直接把c1实例上的配置从A改成B,而c2、c3还在用A,那么可能出现:某个节点按A配置收集到两票,另一个节点按B配置也收集到两票。两个不同配置的多数派没有交集,就可能同时产生两个Leader。
Raft解决这个问题有两种主流方案。最简单的是“单节点变更”:每次只调整一个成员,比如从3节点变成4节点,再从4节点变成5节点。这样新旧配置的多数派之间必然有交集,因为旧多数派3个节点中的某些节点一定也在新配置的多数派里。另一种是论文里的Joint Consensus,同时使用新旧两个配置的多数派确认,变更过程中写请求必须同时满足两套配置的多数派,才能提交。Joint Consensus推广性强但实现复杂,生产系统里单节点变更基本够用。
5.2 日志索引膨胀:快照是怎么救场的
Raft的每条日志都要落盘复制,随着时间推移,日志量无限增长。如果不做清理,磁盘迟早爆,节点间同步日志的耗时也越来越长。解决方案是快照:把当前状态机的完整状态保存一份,并把之前的所有日志丢弃。
快照里除了状态机数据,还必须记录两个关键信息:最后一条被包含日志的Term和Index。这样新加入的节点或落后太久的节点拿到快照后,能判断自己离Leader的最新状态还差多少。对于滞后极严重的节点,Leader还会直接发送InstallSnapshot RPC把整个快照传过去,而不是逐条重放日志。
这里有个工程经验:别指望快照能频繁打,也别太晚打。快照太频繁,磁盘和CPU开销大;快照太稀疏,重放日志的时间就长。TiKV这类系统的快照间隔和日志保留期限都有专门配置,需要根据写入速度、故障恢复时间目标(RTO)来做权衡。还有一点,快照文件的传输通常要压缩,网络带宽和磁盘IO都会在打快照瞬间冲高,得错峰进行。
6. 从理论到落地:Raft在工程实践中的常见坑与选型建议
6.1 那些你用过但不知道在跑Raft的系统
Raft已经渗透到大数据和中间件领域的各个角落。最熟知的几个:
- etcd:Kubernetes的元数据存储,核心存储引擎就是Raft。
- Consul:服务发现和配置管理,它的共识部分用的也是Raft。
- TiKV:分布式事务数据库的存储层,基于Raft实现多副本强一致,支持自动故障转移。
- CockroachDB:NewSQL数据库,分布式事务和复制层同样是Raft。
- SOFAJRaft:蚂蚁集团开源的一个生产级Raft实现,适合Java系做分布式系统时直接集成。
这些系统无一例外选择了Raft而不是自己发明共识协议,主要就是图Raft的实现复杂度可控、调试和维护成本低。Paxos系在底层也有大量应用(比如Google的内部系统和部分数据库的Paxos实现),但一般团队根本不具备从零实现Paxos的条件。工程选型就是这么现实:你能搞懂、能维护、能证明它没写错的协议,才是好协议。
6.2 亲手实验的方法:从动画到Lab
想真正吃透Raft,光看文章是不够的,必须亲手跑起来。我推荐这条路径:
- 第一步:去Raft官网(raft.github.io)看那个交互式选举动画,把Term、心跳、随机超时、票数多数派这些概念先建立直觉。
- 第二步:参考MIT 6.824分布式系统课程的Lab,用Go或你用着顺手的语言实现一遍Raft核心逻辑。这个Lab在网上有大量资料,但建议自己写,别直接抄,否则完全达不到理解效果。
- 第三步:读一个生产级Raft实现的核心代码,比如etcd的raft模块或SOFAJRaft,看你自己的实现和工程实现之间差了哪些细节,比如PreVote、Batch心跳、网络乱序处理、磁盘同步策略。
当年自己做Lab,最大的体会是:写选举不难,难的是日志冲突处理和状态转换在并发环境下的正确性。等你写完一遍再回头看论文,会发现论文每句话都是有大用意的。
6.3 生产环境里那些不写进论文的坑
理论归理论,生产环境里翻车往往都在不起眼的细节上。我列几个常见的坑:
- 超时设置太激进。心跳间隔、选举超时、网络抖动要一起考虑。云环境下网络延迟并不稳定,超时设得太小会导致频繁选举,设置得太大则故障恢复时间太长。
- 用了系统墙上时钟而不是单调时钟。时钟回拨会让任期和租约判断出错,必须使用单调时钟(Monotonic Clock)。
- 持久化时机不当。Raft要求日志在收到多数派确认前必须持久化。如果为了性能批量写、落盘不及时,断电后可能丢失已“提交”的日志。生产实现普遍用批量fsync和group commit来平衡性能与安全。
- 快照和日志截断的边界处理错误。快照Index和日志起始Index之间的衔接不一致,会导致后续同步日志时反复失败。
- Lease读在跨洲跨机房场景下容易出问题。节点间真实时钟偏差超过租约时间,就可能读到过期数据,强一致场景慎用。
最后一个建议:不要把Raft当成万能药。它适合数据量可控、需要强一致、节点规模中等(通常不超过十几二十个)的存储系统。如果你需要做的是大规模海量数据分布式计算,关心的是吞吐而不是单写强一致,那直接上Raft反而是负担。选型之前,先把问题域定义清楚。
我个人学习Raft的经历也证明了那句话:看图千遍不如动手一遍。这篇文里所有机制,只有在自己机器上把节点杀一遍、把网络断一遍、把日志乱一下之后,才真正变成你的东西。把文章收藏没有用,找一天静下心,从那个动画开始,一步步走完这条路,收获绝对对得起你花的时间。