一:Raft的引入
在庞大的分布式系统中,往往会存在多个Replica来保证整个系统的高可用,高容错。比如数据库主从复制、日志系统的多节点同步、或者区块链中的多节点账本。然而,只要出现了节点宕机、网络分区或消息延迟,副本之间的状态就可能出现不一致。这就是我们常常说的分布式一致性问题。讨论分布式一致性问题的相关理论存在于大致以下三个维度:
·理论基础层:BASE理论,CAP理论
·协调机制层:2PC,3PC(事务一致性协议)
·共识算法层:Paxos->Multi_Paxos->Raft
(1)CAP理论
CAP理论是理解分布式系统一致性权衡的出发点,其内容是在一个分布式系统中,不可能同时满足CAP三个特征。即一致性(Consistency),可用性(Availability),分区容忍性(Partition Tolerance)。因为在分布式系统的实践中网络分区是不可避免的问题,所以分布式系统设计实际上就是CP和AP之间的权衡。CP系统(比如Raft,Etcd)选择一致性优先,暂时牺牲高可用;而AP系统(Dynamo、Cassandra)选择高可用优先,接受暂时的不一致。CAP理论可以说是共识算法的内在哲学之一了,我们今天所要介绍的Raft就是CP系统的一种,在发生网络分区时拒绝服务保证系统数据的一致性。这里很多朋友其实存在一个误解,就是在分布式系统正常运行的情况下,C和A是可以同时保证的,只有当出现网络分区故障时,才需要在C和A之间做权衡。如果你选择了 CP 系统,就必须实现一个能在分区条件下仍保证一致性的算法,这正是 Raft 的使命。
(2)BASE理论
BASE理论实际上是对CAP理论的一个延伸,是大规模互联网在CAP约束下妥协的产物。是一种放弃强一致性,追求可用性和性能的思路。也就是我们前面所介绍的AP系统。BASE的含义是BA(Basically Available):当部分节点异常时,允许通过服务降级,牺牲部分可用性来保证整体模块仍然可用。S(Soft-state):状态可以延迟同步,不追求实时强一致。E(Eventually Consistent)最终收敛为一致的状态。业务中的异步补偿机制实际上就是BASE理论的践行,假如某个操作并不关键,如出现异常需要让主业务流程继续执行,最后对这个非关键操作进行补偿,比如 Redis 的异步复制、NoSQL 的多副本最终一致模型都属于 BASE 系列。但是Raft的理念和BASE恰恰相反,其通过严格的Leader选举和日志复制来保证线性一致性。
(3)2PC/3PC--早期的一致性协议
在数据库系统领域,为了在多节点系统中保证数据一致性,研发人员提出了2PC,3PC。其中PC的含义分别为Participate(参与者),Coordinator(协调者)。2PC即两阶段提交,主要分为准备阶段和提交阶段:准备阶段时,协调者会询问每个参与者是否准备好,如果所有参与者都发回“可以”的响应,那么进入提交阶段,协调者提交广播命令,否则回滚。但是这样做的缺点是显而易见的,容错性很差,如果协调者宕机或者发生了网络分区,那么参与者会陷入无限等待,这样会导致系统阻塞。
为了提升2PC的容错性,后来又引入了一个中间阶段,由此出现了3PC。其核心是在参与者和协调者之间都引入了超时机制,且提交之前引入了“预提交”这个中间状态。预提交其实并不会真正的将事务提交,而是将redo信息和undo信息记录在事务日志中。其超时重试的机制解决了2PC的同步阻塞问题。但是实际上,2PC,3PC只是针对单轮事务的协调协议,而接下来要介绍的Paxos/Raft才是持续多轮的共识机制。
(4)Paxos--共识算法的理论奠基
在 2PC/3PC 之后,人们意识到:只靠协调者驱动的事务协议无法在异步系统中保证安全性。
1990年Lamport 提出了 Paxos,用数学方法证明了共识问题在容错环境下是可解的。其关键创新在于提出了多数派投票的规则,可视为2PC,3PC的容错升级版。但是其角色模型复杂,极度依赖隐式规则,通信开销较大,并不适合工程落地。正因如此,后来发展出Raft。维基百科强调了一句话我觉得很有意思:“Paxos常被误称为“一致性算法”。但是“一致性(Consistency)”和“共识(consensus)”并不是同一个概念。Paxos是一个共识算法”。在分布式系统中,一致性是一个很宽泛的概念,而共识是一个非常具体的问题,共识只关心三个核心性质:一致性(所有最终决定的值必须相同),合法性(决定的值必须是某个节点真正提出过的),终止性(只要多数节点存活,就一定能决定出一个值)。它不关心读写语义、不关心事务隔离、不关心线性化,只关心“多数节点决定一个值”。
(5)Raft--工程化的共识算法
Raft在Paxos的理论基础上进行了系统工程化的设计,其明确了强Leader的模式,并分离出了Leader选举,日志复制,状态持久化三大模块。并引入了任期和日志匹配来保证整个过程的安全。我的理解是,如果说Paxos是数学家写给论文的算法,那么Raft就是工程师写给系统的算法。在MIT 6.824 Lab中,Raft算法以库的形式存在。在一个分布式服务的节点上,由应用程序来接收并处理来自客户端的RPC请求,而各节点通过Raft来保证整个系统的一致性。
从 CAP/BASE 的取舍,到 2PC 的协调,再到 Paxos/Raft 的共识,分布式系统一致性经历了从“协调协议”到“共识算法”的进化。Raft 之所以重要,不是因为它更聪明,而是因为它让一致性真正可被工程实践。接下来我们就来结合各种论文资料,来详细介绍Raft算法的理论细节:
二.Raft系统结构
(1) 节点角色
在Raft集群中,节点被划分为三种角色:Leader(领导者),Follower(跟随者),Candidate(候选人)。正常情况下,在一个Raft集群中,有且只有一个Leader,其余的服务器节点都是Follower。Leader负责接受客户端请求,向Follower复制操作日志并决定日志的提交时机,通过向Follower节点发送心跳以建立权威。而Follower会对Leader的日志复制请求进行响应,接收Leader的操作日志并持久化;并对Leader发来的心跳进行监控。而Candidate是一个选举阶段的临时角色,负责触发选举,并收集选票。下图展示了Raft中节点角色转换的过程:

这里我想解释一个比较有意思的点:按照Raft论文原文的介绍,Follower完全是个被动的角色,完全不接收来自客户端的请求,只对Leader的请求进行响应。也就是说,标准Raft就是由Leader处理所有读写请求。因为如果允许Follower接收读请求,由于日志复制过程存在延迟,有读到旧数据的可能性,这违反了Raft线性一致性的设计目标。但在工程实践中,确实存在为了提高读性能而让 Follower 处理读请求的方案,但这需要额外的协议保证(如 Leader lease、ReadIndex、心跳确认、no-op 日志等)。MIT 6.824 的最终 Lab 也要求实现线性一致读,因此读请求仍然必须由 Leader 来处理。
(2) 任期与心跳机制
在Raft集群中,时间的单位是Term(任期),Term 是 Raft 中用于标识不同选举阶段的单调递增逻辑时钟。每当集群进入新一轮选举,Term 就会自增。所有节点都会记录 Term,其核心作用是对集群状态进行“时代分隔”,即判断消息的“新旧”,判断谁是合法的 Leader,以及拒绝来自过期任期的RPC。不难发现,Term 是由选举驱动的,而不是由时间驱动的。和字面意思一样,Term 是一个时代,而 Leader 是这个时代的君主。随着后文对 Leader 选举与日志复制的展开,我们将看到 Term 是如何作为整个协议的全局基准,贯穿 Raft 各个安全性机制的。

如果说 Raft 中的Term是一段统一的时代,那么这一时代的 Leader 就是封建时期由诸侯推举出的君主。君主的权威并不是一劳永逸的,而是需要持续向地方诸侯传达“我仍在履行统治职责”。在古代,这种维持统治合法性的方式往往表现为定期向民间颁布“安民书”。在 Raft 集群中,这种“安民书”式的存在便是 Leader 的Heartbeat(心跳)。心跳机制有一个隐含作用:维持秩序的连贯性。其消息实际上并不携带重要内容,但它承担着十分重要的职责:向所有 Follower 宣布“当前任期内我仍然是合法的 Leader,秩序不需要重建,新的选举无需发生”。只要 Follower 能够按时收到这一信号,它们便会安心地保持 Follower 身份,避免进入新的选举阶段,否则就会触发新的选举。此处按下不表,具体的细节我们在Leader选举机制中详细介绍。
(3) Raft中的RPC
Raft 里的一切状态转换与协作,都是通过 RPC 实现的。Raft中定义了三类RPC,相信大家在做lab的时候会留意到,分别是:RequestVoteRPC(Leader选举,永远由Candidate发出),AppendEntriesRPC(日志复制+心跳,永远由Leader发出),InstallSnapshotRPC(快照传播)。Raft 的安全性从不依赖 RPC 的可靠传输,即便所有 RPC 都失败,也不会破坏一致性。RPC 不保证消息可靠,但保证状态转移的逻辑可靠。在Raft中,每个RPC都必须带Term,这样Raft 通过 Term 检查来阻止“僵尸节点”干扰集群。
三.Leader选举机制
(1) 选举的触发
Leader的选举始于Follower对Leader的不信任,前面我们有介绍过,当 Follower 长时间没收到 Leader 的“安民书”(心跳),它就会发起选举。每个Follower节点中会维护一个选举定时器,里面的超时时间是在一个区间范围内随机选取的。如果Follower在超时时间内没有收到来自Leader的心跳,选举定时器超时,那么就会立刻触发选举。
可能这里朋友们会有疑问:为什么要设计为“随机定时器+立即选举”?实际上这种可靠性设计是为了避免 split vote(票数平均分导致无人当选)而考虑的。Raft 的核心思想之一就是:“让定时器来做冲突规避,而不是逻辑判断”。我们必须让每个节点超时时间不一样,让不同节点分散开来触发选举。理论上,选举定时器超时时间的随机范围越大,冲突概率越低。
(2) Candidate的选举步骤
当Follower中的选举定时器超时后,节点会立刻转为Candidate状态,term自增1,给自己投一票后并发向其他节点发出投票请求。这个投票请求不是投票给其他人,而是请求让其他Follower投票给自己。这也意味着选举过程的真正开始!
Candidate 自己的一票是确保“自己至少有一票”的基础条件,代表“竞争者永不为零”。假设集群中有N个节点,那么多数票就是(N/2)+1;如果Candidate不给自己投票,最多只能从其它节点获得 N–1 票。而 其它节点在同一 term 内都可能无法投票给它(网络延迟、日志落后等原因),那么 Candidate“理论上可能”永远拿不到多数。可能会有朋友担心自投会造成“自投”为 Leader、不安全,不公平。但是实际上Raft机制保证“自投”只是计票的“起点”,真正当选需要得到多数节点的确认,其实也就是说自投不可能造成脑裂(split-brain),因为多数票原则天然阻止了两个 Leader 共存。
在选举过程中,也存在一些并发方面的问题值得我们探讨:假设有个Candidate A发出投票请求,但是遭遇网络延迟;此时出现了一个Term更高的Candidate B,被多数投票,成为了Leader;此时Leader B开始发送心跳,A收到后发现Term>它的Term,此时A必须降级为Follower并放弃选举!这个并发点是Raft安全性的关键:Term 是绝对权威;看到更高 Term 必须认输。当Candidate降级为Follower时,必须丢弃之前的投票结果。
(3) 投票的判定规则
Leader选举过程中最关键的问题并不是谁站起来先喊“我要当Leader”!而是谁能获得多数票,Follower在什么条件下愿意把自己唯一的一票投出去。实际上Follower在投票时绝对不是随机或者无条件的傻瓜,因为投票的本质或者最大目的不是选举本身,而是保护日志的安全性! Follower投票时会进行严格的检查:首先Candiate的Term必须大于等于自己当前的Term,这是旧任 Leader 没有资格在当前 Term 里竞争的依据。
其次是一个初学者非常容易忽略的点,就是Candidate的日志必须至少和自己一样新,这个规则保证永远让日志最新的节点当选Leader,否则如果由日志落后者当选,会覆盖掉已提交的日志,从而造成数据安全的问题。这里我们先暂时忽略这个逻辑是怎么判定的,在日志复制模块我们会详细介绍。
这时候我们来探讨一个比较深层次的问题,在Raft集群中,保证“多数派”当选的意义是什么?实际上远远不像大家想的那么简单。实际上多数派保证的是:如果一个日志条目被提交,则未来所有的 Leader 选举都必须包含该日志条目。也就是说不能存在旧数据覆盖的现象,这是Raft中最不可容忍的!
总的来说,Follower 只投给“Term 至少不小、日志至少不旧”的 Candidate。
(4) 选举失败的情况
有以下几种选举失败的情况值得我们进行分析:
首先我们假设有A,B,C,D,E五个节点,假设节点A和节点C同时转为了Candidate,在投票过程中,假设节点A拿到了A,B两票,节点C拿到了C,D两票,而E的投票因为网络延迟没有响应,此时并不存在多数票,选举失败。这实际上是因为多个 Candidate 并发出现,把多数票“分散”了,没有一个候选人能独占多数。此时所有Candidate会降级为Follower,等待选举定时器再次触发。这里就体现了随机化选举定时器超时时间的重要性了,由于每个节点定时器的超时时间随机,下次基本上只有一个Follower的定时器最早Timeout,大大降低了Candidate并发出现的概率。
假设集群中的Leader节点是A,其向集群中的Follower节点发送心跳以“维持权威”,但是因为瞬时的网络抖动产生了延迟,此时有一Follower节点D的选举定时器超时转为Candidate,而此时网络恢复,节点D收到了来自老Leader节点A的心跳。此时选举也会失败!D会立即从Candidate降级为Follower,并重置选举计时器,这种情况其实是一种“自然失败”,说明选举本不应该发生。
最后一种选举失败的情况就是我们前面介绍的Follower投票时,进行检查发现不满足条件(Term和日志不够新)。这类失败很关键,这是Raft保证日志安全性的核心机制之一!Candidate 会等待下一个 timeout,日志更新后再次尝试。
(5) Leader当选后的情况
实际上,Raft的Leader当选并不仅是选举的终点,更是整个系统稳定运行的起点。Leader当选后会做三件关键事情:首先是立即发心跳以广播权威,其次是更新和日志有关的下标(此处暂且按下不表,在日志复制部分进行介绍)。之后开始接受来自客户端的RPC请求。
四.日志复制
(1) Raft日志结构与基本原则
当Leader选举完成之后,Leader便会通过日志复制的方式,向Follower同步日志条目。 Raft的日志复制并不依赖Candidate,而是在新Leader诞生后由其统一管理。在深入了解日志复制的过程之前,我们首先需要对Raft的日志结构建立清晰的认知。Raft中的日志结构主要包含以下三个部分:首先是当前Leader的Term,这是日志中最为重要的信息,用来判断新旧日志;其次是下标log index,以及要应用到状态机的实际操作Command,其会被封装为Entries。每一个Entries的核心字段就是Term和Command。若一个日志条目,已被大多数节点认为,则会被认为是可提交的(Commit),任何已提交的日志,只要产生了新的 Leader,它一定包含这些日志。实际上,Raft中的日志是对操作进行排序的一种巧妙手段,因为在复制状态机中,操作的执行是需要严格依赖某种顺序的。下图展示了Raft的基本结构,朋友们可以参考:

Raft中有两条重要的日志匹配原则:(1)如果两个日志中的条目具有相同的索引和任期号,那么它们存储的命令是相同的。一个Leader在其任期内,只能在某个特定的index位置创建日志条目,并且这个日志条目的索引是固定不变的。(2)如果两个日志中的条目具有相同的索引和任期号,那么这两个日志在该条目之前的所有条目也完全相同。在日志追加时,即发送我们前面介绍到的AppendEntriesRPC时,Follower会有针对Term和Log index的一致性检查。Raft必须保证Follower 的日志必须和 Leader 的日志从头到尾一致(直到某个点),不能出现分叉。也就是说,Follower 认为:我要跟 Leader 对齐的前缀必须完全一致(Log index & Term)才接受后续日志。这里我必须解释一下,日志一致性并不代表Follower 的日志和 Leader 一模一样,而是Follower 的日志和 Leader 在 “相同Log index 的 Entry” 中必须具有完全相同的 Term!
(2) 日志复制
前面在“Raft中的RPC”模块我们介绍了日志复制时由Leader发出的RPC:AppendEntriesRPC,该RPC主要有以下三个核心作用:心跳(无Entries),日志复制(带Entries),日志一致性的校验。这里有一个非常重要的话题就是:Raft中日志一致性的校验是怎么做的?
在 AppendEntries 里有两个非常关键的参数:PrevLogIndex(Follower需要从 PrevLogIndex 之后衔接我的 entries)和PrevLogTerm(Leader 期望 Follower 在 PrevLogIndex 那一条日志的 Term 值)。若校验不通过,便会拒绝追加并进行回滚,由 Leader 根据返回的冲突信息回退 nextIndex 并重新尝试。也就是说,失败后不会直接改 Leader 的状态,而是 Follower 带着冲突信息告诉 Leader 如何回退。
Leader 为每个 Follower 维护两个重要变量:nextIndex[f]:表示下一次向该 Follower 发送日志时,从哪个Log index 开始。初始为 Leader 日志的末尾;若收到冲突,Leader 根据冲突信息跳跃式回退 nextIndex。matchIndex[f]:表示该 Follower 已经和 Leader 完全匹配的日志位置。
Leader 会根据所有 matchIndex 计算 CommitIndex(下个章节会介绍)。最终,Leader 持续调整 nextIndex,使每个 Follower 的日志逐渐追上 Leader,并让 matchIndex 达到多数,从而推进日志提交。
(3) 日志冲突处理
在Raft集群中,正常情况下Leader和Follower的日志始终保持一致,也就是说,通过AppendEntriesRPC进行的一致性检查通常是会成功的。但是一旦Leader崩溃后,可能会造成日志的不一致。问题可以归结为原 Leader 没来得及把日志复制到多数派,而接下来新 Leader 可能来自 没有完整日志的节点。下图就向我们展示了日志冲突的情况(f节点):

当旧 Leader 恰好在写入日志后、复制到多数派之前就崩溃,那么这些“未提交”的日志可能只留在少数节点上。接下来由一个日志较短的节点当选为新 Leader 时,就会出现两份不同的日志前缀,导致冲突:
(1)缺日志冲突:Follower 由于网络延迟未能接收到旧 Leader 的最新日志。
(2)任期冲突:旧 Leader 写入的未提交日志处于一个较早的 term,新 Leader 在同一 index 处写入了新的日志,导致 term 不一致。
因此,Raft 要求 Follower 在接受日志前必须进行一致性检查:如果 PrevLogIndex 或 PrevLogTerm 与 Leader 不一致,Follower 必须拒绝该日志并返回冲突信息,由 Leader 主动回退 nextIndex,直到找到双方共同的日志前缀。随后 Leader 会覆盖(truncate)Follower 的旧日志并追加新的 Entries,最终将日志恢复为完全一致的状态。
五.日志提交与状态机应用
(1) 日志提交机制 --CommitIndex的推进
前面我们介绍的Raft 日志复制是为了让所有节点都拿到一份一致的“指令序列”,但这并不代表这条日志可以被真正执行。执行一条日志意味着修改状态机(state machine)的内部状态。日志提交(commit)是 Raft 对一条日志达成“最终一致”的决定。只有被提交的日志才允许被应用到状态机。
在上个章节,我们有提到过CommitIndex(Leader维护,Follower接收)这个概念。事实上,Leader可以通过观察前面我们介绍的matchIndex来判断某个日志条目是否被多数节点拥有,如果该index在多数节点中存在,且index 对应的日志任期(Term)是当前 Leader 的 Term,Leader就会推进CommitIndex,认为对应的日志条目是可提交的。Leader 是唯一能推进 CommitIndex 的节点。Follower 只能被动接收 CommitIndex。我们需要注意,Leader只能提交属于自己Term的日志。Leader 在心跳(AppendEntries RPC)里携带 CommitIndex,通知所有 Follower:“这些日志已经提交了”,之后Follower 更新自己的 CommitIndex。
(2) Leader&Follower的应用 LastApplied--的推进
这里我们需要注意一个点:提交≠执行!提交意味着“日志不会丢了”,而应用则意味着“把指令真正执行到状态机”。事实上,执行的顺序是很重要的,如果不控制执行顺序,会出现灾难性问题:不同节点可能在不同时间执行不同命令,这会造成系统层面的不一致。Raft 保证的不是最终一致,而是线性一致。在Leader和Follower中都会维护一个LastApplied,代表已经执行到状态机中的日志。只要 LastApplied<CommitIndex,就把日志拿去 apply。
可能不少朋友会和我一样,对于上面提出的概念看的云里雾里。没关系,我借用ChatGPT总结了一张Raft出现的易混淆概念表,供大家理解参考:

(3) Leader崩溃情况下的提交安全性
我们前面有介绍过,Raft的核心目标之一,就是一旦某条日志对外“提交”,则在任何未来的 Leader 中,这条日志一定存在。但是在真实系统中,Leader随时有可能崩溃,Follower有的日志多,有的日志少,有时候也会因为网络延迟或者丢包导致Follower的日志不完整。如果提交规则不严格,新的 Leader 可能没有旧 Leader 已提交的日志,就会导致状态机出现不一致的致命错误。
Raft通过三个机制能够保证在Leader崩溃情况下,提交记录仍然安全。首先只有日志达到多数派时,Leader 才能提交。因为因为任何未来当选的 Leader 都必须拿到多数派的选票,而多数派中一定包含最新的日志,因此新 Leader 一定包含已提交日志。这是提交安全性的根本!其次是选举层面的限制,保证Candidate 的日志必须“至少不比投票方落后”。某个 Follower 如果拥有最新日志,它绝对不会投票给一个日志比它旧的Candidate,反之,它会把票投给更“新”的 Candidate。因此任何能赢得选举的 Candidate 的日志一定包含所有已提交日志。最后,新 Leader 会使用 AppendEntries 强制覆盖冲突日志。当新的 Leader 当选后,它会以自己的日志为准,使用 nextIndex 和 PrevLogIndex/PrevLogTerm,让Follower回滚冲突并覆盖旧日志,即使之前有未提交的杂乱日志,也会被清理掉。这保证了新 Leader 的日志是全局最新的;旧 Leader 的未提交日志一定会被覆盖。
六.日志压缩与快照机制
(1) 为什么需要日志压缩
随着系统运行时间的增加,日志会变得越来越长,因此,节点如遇故障重启,重放日志会变得非常慢,所以我们不能让日志的长度无限增加。比如说,某节点刚加入集群,Leader 需要从 Log index=1 开始发十万条日志,毫无疑问这种方式的效率是极低的。事实上,提交日志已经对系统状态产生了实际影响,重放这些日志与保存这些日志的原始内容已经没有必要。如果没有设计合理的丢弃过时日志的方案,也极容易拖垮整个系统。再设想一种情况,如果Follower的日志落后的太多,Leader需要不断向前尝试PrevLogIndex,一格一格进行回退非常低效。所以我们引出了快照机制--这种最简单的日志压缩方式解决上述问题。
其要点很简单:用一个快照(snapshot)来存储当前系统状态,并丢弃那部分日志条目。值得注意的是,快照操作是由每个服务器节点独立运行的,且仅针对已提交的日志。快照必须携带两个关键的元信息:LastIncludedIndex:快照包含的最后一条日志的索引,和LastIncludedTerm:该索引对应的日志条目的任期。这其实就是快照与日志之间的连接点。也就是说,快照覆盖了 [1 .. LastIncludedIndex] 这整段日志。快照的时机或者频率也是不可忽视的因素,如果太过频繁,会浪费网络带宽;如果快照太过缓慢,则会浪费大量磁盘存储空间,有存储耗尽的风险。

(2) InstallSnapshot RPC —— 用于“远程追赶”的快照同步机制
为解决前面所提到的Follower 太落后的问题,实际上,Leader需要直接发快照,而不是发日志条目。而InstallSnapshot RPC 是专门为应对这种情况而提出的。设想这种情况:Leader 发送 AppendEntries RPC发现 Follower 的日志太旧、不一致,因此Leader 的 nextIndex 会不断回退。如果 nextIndex 减到 LastIncludedIndex 以下,那说明 follower 落后到连快照覆盖前的部分都匹配不上了。此时只能发送快照!也就是说Leader内部会有一个判断逻辑,可以自动决定要发什么类型的 RPC。
Follower 收到 InstallSnapshot RPC之后,会进行检查。如果Term太旧,会拒绝。之后会将数据写入快照文件并持久化。之后Follower 会做三件事:首先会删除日志中所有Log index < LastIncludedIndex 的条目,若日志中有Log index == LastIncludedIndex 的条目且 Term 匹配,则保留后半部分;否则丢弃整个日志(因为快照权威)。Follower会使用快照文件重建状态机,避免重放大量日志。
七.持久化与崩溃恢复机制
(1) Raft需要持久化的关键状态
Raft 协议是基于日志复制的一致性算法,它的一个核心目标是:在任何节点发生崩溃后都能保证系统状态的正确性与连续性。为了实现这一点,Raft 明确规定了一些状态必须“持久化”,即必须写入可靠存储(通常是磁盘或可恢复的非易失性存储),以确保在崩溃、重启时能够正确恢复。Raft要求任何可能影响一致性的重要状态变化都必须是不可丢失的。因此持久化必须在逻辑生效之前发生。我们需要思考,到底哪些东西必须持久化?为什么必须?在Raft集群中,每个服务器必须持久化以下状态:
首先必须持久化CurrentTerm(当前任期号),为了满足选举安全性的要求:在同一个 Term 中最多只能产生一个 Leader。如果 CurrentTerm 不持久化,它在重启后会回到旧 Term,从而违反这个性质,因此必须持久化。其次是必须持久化VotedFor(当前任期内投给谁)。这是为了防止重复投票,保证每个 term 中最多一票。日志条目(Log entries)也必须持久化,这是为了防止提交过的日志丢失,保障状态机的线性一致性。最后,快照数据(snapshot data)也需要我们持久化。如果快照不持久化,一旦节点崩溃,就必须从头重放所有日志(甚至可能没有日志用来重放),导致无法恢复状态机,日志和快照的边界混乱。
(2) 节点的崩溃与恢复--Raft的恢复流程
在 Raft 中,节点崩溃并不能算异常情况,而是系统必须从设计层面应对的常态。Raft 的一个核心优势就是:只要大多数节点仍然存活,崩溃的节点可以在重启后快速恢复,并重新加入集群,而不会破坏一致性。
当节点崩溃并重启后,它必须立即从磁盘中读回所有被标记为“必须持久化”(我们上节所介绍的那几个)的状态。其中,加载快照的顺序是十分关键的。如果有快照,必须先恢复快照,再恢复快照之后的日志条目,最后把节点的 LastApplied、CommitIndex 调整到合理值,这样节点才有正确的一致性边界。
恢复后的 Follower 很可能 日志落后,需要追赶 Leader。日志追赶理论上可以通过两种方式进行:首先可以通过 AppendEntries RPC实现日志追赶,Leader 负责将日志补齐给落后的 Follower,由Follower进行一致性检查。如果 Follower 的日志太旧,Leader 会直接发送快照(InstallSnapshot RPC)。Follower 收到快照时,会丢掉快照之前的所有日志条目,并应用快照到状态机,截断自己的日志并从快照边界重新开始。这种方式让恢复节点可以用“压缩后的状态”快速追上集群。当Follower 完全追上 Leader时,恢复过程结束。
恢复完成后,节点会默认进入 Follower 状态。Raft针对刚恢复的节点有一条很重要的原则:节点重启后永远不能立即成为 Leader 或 Candidate。因为无法保证它是否拥有最新日志,持久化状态是否已经完全加载以及恢复时的 commitIndex 是否正确。因此,节点恢复后,必须必须开启 选举计时器,并等待来自 Leader 的心跳。只要节点再一次收到有效心跳,它就会认为现在的Leader还在,我只需接受日志复制即可。如果没有收到心跳,并且超过选举超时,那么节点才会自动进入 Candidate 状态去发起选举。
八.Raft的集群成员变更
在实际系统中,Raft 集群的节点数量并不是一成不变的。随着业务规模的变化,我们可能需要通过扩容提升吞吐量与容错能力,通过缩容降低成本或替换失效机器,通过迁移节点将服务从旧机器迁移到新机器。如果在这种情况下手动修改所有节点的配置文件、重启集群,不仅复杂,而且可能导致短暂不可用。更重要的是,错误的操作还可能破坏一致性。因此,Raft 设计了一套 安全的、基于日志提交的成员动态变更机制。成员变更本质上是一种特殊的日志条目,只要被集群的多数节点复制并提交,新的配置即可生效,整个过程无需重启节点,也不会影响系统可用性。
但是成员变更是个很麻烦的事情。在 Raft 中,所有一致性保证都依赖于:同一个固定配置的多数派必须对日志条目达成一致。但当集群要从 Cold(旧配置) 变为 Cnew(新配置) 时,麻烦来了。我们来看下面这张图:

集群成员变更问题的核心在于不能在集群扩缩容时产生两个不同 Leader(多配置交替导致脑裂风险)。在上面这张图中,左边绿色是Cold日志条目,右边蓝色是Cnew日志条目。黑框表示对应配置的日志条目在集群中生效的时间区间,问题出在中间这段:旧配置和新配置可能形成 两个互不相交的多数派!在绿色和蓝色交叉时期,Cold和Cnew都可以自己形成多数派,这意味着可能出现两个Leader,日志会出现分叉。
为了解决这个“双重多数”的问题,Raft 引入了 三阶段的安全过渡机制,如下图所示:

首先Leader 首先追加一个特殊日志条目Cold,new,这使得配置不再是 Cold,也不直接变成 Cnew,而是变为一个临时联合配置。Cold,new 表示:任何日志条目必须被 Cold 多数派和Cnew 多数派都确认才能提交,这其实就是交集原则,彻底解决了前面那张图中的脑裂风险。当联合配置 Cold,new 被提交后,Leader 再追加下一个配置条目Cnew,提交后新的多数派 Cnew 取代 Cold,new,集群正式以 Cnew 运行;且如果 Leader 不在 Cnew 中(比如旧 Leader 被踢出集群),它必须立即下台。对于被移除的节点,它收到Cnew配置后,会主动降级成 Follower,且不再参与选举。如果它长期收不到心跳,也会拒绝发起选举,因为它不属于新配置。Raft 在此处保证了一致性但不保证可用性(被踢出的节点可能会断开一段时间)。
九.客户端交互与线性一致性保证
Raft 的最终目标不仅是让集群内部达成一致的日志状态,更重要的是向客户端提供线性一致性的语义。线性一致性意味着:无论客户端请求落在哪个节点、无论系统发生什么故障,客户端观察到的系统行为就像是所有操作按一个全局顺序依次执行过一样。这是分布式系统中最强的一致性模型,也是 Raft 选择的保证方式。
在 Raft 中,客户端的所有写操作都必须经由 Leader 处理。Leader 会将写请求封装成日志条目,复制给大多数节点,并在日志条目提交后再向客户端返回成功结果。这一机制确保了:只要客户端看到“成功”,该操作就已经在多数节点持久化,不会因为 Leader 崩溃而丢失。
对于读操作,Raft 也必须确保读结果不会“读到旧的数据”。为此,Raft 提供了两种读一致性策略:第一种策略是强制读通过 Leader:客户端将所有读请求发送给 Leader,而 Leader 在处理读请求前需要确认自己仍然是“合法 Leader”。常见做法是进行一次轻量级的“心跳确认”,确保当前任期内没有新的 Leader 出现。这样一来,Leader 能够保证自己的状态机已经反映了最新的已提交日志。第二种策略是线性一致读机制:Leader 会先获得当前的安全读索引,然后等到本机的状态机应用至少推进到该索引,再执行读请求。这样可以不强制写入日志,也能保证读到的内容是线性一致的。无论是哪种方式,它们共同保证:即使在网络抖动、节点崩溃、Leader 切换等复杂情况下,客户端永远不会得到“回到过去”的结果。
凭借严格的日志提交流程、Leader 的唯一写入口、以及读请求的线性一致性保证,Raft 能够向客户端提供一个看似“单机顺序执行”的抽象接口。这不仅简化了应用层的设计,也让构建强一致性的系统(如分布式数据库、锁服务、配置管理系统等)变得可控和可靠。
十.扩展与进阶:Raft 在工业界的演化
自 2014 年论文发表以来,Raft 迅速从教学案例成长为工业界分布式系统的主流共识算法之一。相比历史更悠久的 Paxos,Raft 更强调工程可实现性与可读性,这也使它在工业系统落地时具备明显优势。经过近十年的发展,Raft 在多个维度上不断演化和扩展,形成了比原论文更成熟的工程生态。
首先,大型分布式系统对一致性的需求越来越高,Raft 在数据库、服务发现、配置管理等领域成为事实标准。例如 etcd、Consul、TiKV、CockroachDB 等核心组件均采用 Raft 作为基础的共识保障层。许多工程化实现还对 Raft 做了深度优化:如批量日志复制、pipeline 化 AppendEntries、租约读、ready-state 机制、快照流式复制等。这些优化并不改变 Raft 的理论模型,却显著提升了吞吐量和稳定性,使其足以支撑云原生架构下的大规模分布式数据一致性场景。
其次,随着系统规模和配置变动需求的增长,Raft 的集群成员变更机制在实践中得到了广泛采用,甚至成为云服务动态扩容和运维自动化的基础能力。工业实现通常会对变更流程加入额外的保护机制,如自动故障转移、健康检测、增量 Peer 加入与数据同步等,让运维对共识组的操作更加平滑、安全。
在存储演进方面,快照、日志分段、WAL 分离、异步落盘、增量快照等技术被集成到 Raft 系统中,使得持久化效率和重启恢复时间显著缩短。例如 TiKV 的 Raft engine(RaftStore)使用多层结构化日志和异步 IO,大幅减少了写放大与锁竞争。
此外,许多工程系统还探索了多 Raft Group(sharding + Raft)的架构,将多个独立的 Raft 组横向扩展,实现分布式存储的高性能与高可扩展性。每个 shard 一个 Raft group,这种设计如今已成为分布式 KV 数据库的主流,比如 TiKV 和 CockroachDB。为了管理大量 Raft group,调度层往往引入 region balance、自动 split/merge、仲裁优化等技术,使整个系统呈现“无限水平扩展”的能力。
最后,随着云原生时代到来,Raft 逐渐从“底层算法”演变成一种分布式基础设施能力。它不仅用于强一致 KV,还被封装为更通用的组件,用于 metadata 管理、分布式锁、任务协调、服务注册与发现、高可用控制面等场景。这种演化趋势让开发者可以在不深究底层实现细节的情况下,直接构建出具备强一致性的复杂系统。
总的来说,Raft 在工业界的演化并非改变其核心原理,而是通过大量工程实践验证和优化,使其从“可理解的共识算法”成长为“可落地、可扩展、可运维的分布式一致性协议”。它的生态仍在不断发展,也将继续作为现代分布式系统的基石之一。
2万+






