最近需要设计一个分布式系统,需要一个中间件来存储共享的信息,来保证多个系统之间的数据一致性,调研了两个主流框架Zookeeper和ETCD,发现都能满足我们的系统需求。其中ETCD是K8s中采用的分布式存储,而其底层采用了RAFT算法来保证一致性,所以随便研究了下RAFT算法,这篇文章会从头到尾分析Raft算法的方方面面。
分布式系统通常由异步网络连接的多个节点构成,每个节点有独立的计算和存储,节点之间通过网络通信进行协作。分布式一致性指多个节点对某一变量的取值达成一致,一旦达成一致,则变量的本次取值即被确定。
在大量客户端并发请求读/写的情况下,维护数据多副本的一致性无疑非常重要,且富有挑战。因此,分布式一致性在我们生产环境中显得尤为重要。
总结来讲,分布式一致性就是为了解决以下两个问题:
常见的一致性算法包括Paxos算法,Raft算法,ZAB算法等,
本文我们主要介绍Raft算法,后续会对其他算法进行详细介绍。
Raft算法和其他分布式一致算法一样,内部采用如下图所示的复制状态机模型,在这个模型中,会利用多台服务器构成一个集群,工作流程如下图所示:
整个工作流程可以归纳为如下几步:
算法会保证变量的状态在整个集群内部是统一的,并且当集群中的部分服务器宕机后,仍然能稳定的对外提供服务。
Raft算法在具体实现中,将分布式一致性问题分解为了Leader选举、日志同步和安全性保证三大子问题,接下来我会对这三方面进行仔细讲解。
Raft 正常工作时的流程如下图,也就是正常情况下日志复制的流程。Raft 中使用日志来记录所有操作,所有结点都有自己的日志列表来记录所有请求。算法将机器分成三种角色:Leader、Follower 和 Candidate。正常情况下只存在一个 Leader,其他均为 Follower,所有客户端都与 Leader 进行交互。
所有操作采用类似两阶段提交的方式,Leader 在收到来自客户端的请求后并不会执行,只是将其写入自己的日志列表中,然后将该操作发送给所有的 Follower。Follower 在收到请求后也只是写入自己的日志列表中然后回复 Leader,当有超过半数的结点写入后 Leader 才会提交该操作并返回给客户端,同时通知所有其他结点提交该操作。
通过这一流程保证了只要提交过后的操作一定在多数结点上留有记录(在日志列表中),从而保证了该数据不会丢失。
Raft 是一个非拜占庭的一致性算法,即所有通信是正确的而非伪造的。N个结点的情况下(N为奇数)可以最多容忍(N-1)/2个结点故障。如果更多的节点故障,后续的Leader选举和日志同步将无法进行。
在了解基本的工作流程之后,首先看下Rsft算法的第一个问题,如何选举Leader。
如果定时器超时,说明一段时间内没有收到 Leader 的消息,那么就可以认为 Leader 已死或者不存在,那么该结点就会转变成 Candidate,意思为准备竞争成为 Leader。
成为 Candidate 后结点会向所有其他结点发送请求投票的请求(RequestVote),其他结点在收到请求后会判断是否可以投给他并返回结果。Candidate 如果收到了半数以上的投票就可以成为 Leader,成为之后会立即并在任期内定期发送一个心跳信息通知其他所有结点新的 Leader 信息,并用来重置定时器,避免其他结点再次成为 Candidate。
如果 Candidate 在一定时间内没有获得足够的投票,那么就会进行一轮新的选举,直到其成为 Leader,或者其他结点成为了新的 Leader,自己变成 Follower。
当Leader下线或者因为网络问题产生分区时,会导致再次选举。
下图总结了Raft算法中,每个节点的状态之间的变化:
Raft算法将时间分为一个个的任期(term),每一个term的开始都是Leader选举。
每一个任期以一次选举作为起点,所以当一个结点成为 Candidate 并向其他结点请求投票时,会将自己的 Term 加 1,表明新一轮的开始以及旧 Leader 的任期结束。所有结点在收到比自己更新的 Term 之后就会更新自己的 Term 并转成 Follower,而收到过时的消息则拒绝该请求。
在成功选举Leader之后,Leader会在整个term内管理整个集群。如果Leader选举失败,该term就会因为没有Leader而结束。
在投票时候,所有服务器采用先来先得的原则,在一个任期内只可以投票给一个结点,得到超过半数的投票才可成为 Leader,从而保证了一个任期内只会有一个 Leader 产生(Election Safety)。
在 Raft 中日志只有从 Leader 到 Follower 这一流向,所以需要保证 Leader 的日志必须正确,即必须拥有所有已在多数节点上存在的日志,这一步骤由投票来限制。
在介绍投票规则之前,先简单介绍下日志的格式,方便理解:
如上图所示,日志由有序编号(log index)的日志条目组成。每个日志条目包含它被创建时的任期号(term),和用于状态机执行的命令。如果一个日志条目被复制到大多数服务器上,就被认为可以提交(commit)了。
投票由一个称为 RequestVote 的 RPC 调用进行,请求中除了有 Candidate自己的 term 和 id 之外,还要带有自己最后一个日志条目的 index 和 term。Candidate首先会给自己投票,然后再向其他节点收集投票信息,收到投票信息的节点,会利用如下规则判断是否投票:
由于只有日志在被多数结点复制之后才会被提交并返回,所以如果一个 Candidate 并不拥有最新的已被复制的日志,那么他不可能获得多数票,从而保证了 Leader 一定具有所有已被多数拥有的日志(Leader Completeness),在后续同步时会将其同步给所有结点。
Leader选出后,就开始接收客户端的请求。Leader把请求作为日志条目(Log entries)加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC复制日志条目。当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回执行结果。
某些Followers可能没有成功的复制日志,Leader会无限的重试 AppendEntries RPC直到所有的Followers最终存储了所有的日志条目。
日志由有序编号(log index)的日志条目组成。每个日志条目包含它被创建时的任期号(term),和用于状态机执行的命令。如果一个日志条目被复制到大多数服务器上,就被认为可以提交(commit)了。
Leader 会给每个 Follower 发送该 RPC 以追加日志,请求中除了当前任期 term、Leader 的 id 和已提交的日志 index,还有将要追加的日志列表(空则成为心跳包),前一个日志的 index 和 term。具体可以参考官方论文中的描述:
在接到该请求后,会进行如下判断:
这里对步骤2进行展开说明,每个Leader在开始工作时,会维护 nextIndex[] 和 matchIndex[] 两个数组,分别记录了每个 Follower 下一个将要发送的日志 index 和已经匹配上的日志 index。每次成为 Leader 都会初始化这两个数组,前者初始化为 Leader 最后一条日志的 index 加 1,后者初始化为 0,每次发送 RPC 时会发送 nextIndex[i] 及之后的日志。
在步骤2中,当Leader收到返回成功时,则更新两个数组,否则说明follower上相同位置的数据和Leader不一致,这时候Leader会减小nextIndex[i]的值重试,一直找到follower上两者一致的位置,然后从这个位置开始复制Leader的数据给follower,同时follower后续已有的数据会被清空。
这里减少 nextIndex 的值有不同的策略,可以每次减一,也可以减一个较大的值,或者是跨任期减少,用于快速找到和该结点相匹配的日志条目。
在复制的过程中,Raft会保证如下几点:
Raft算法中引入了如下两条规则,来确保了
在上面投票环节也有介绍过,一个candidate必须获得集群中的多数投票,才能被选为Leader;而对于每条commit过的消息,它必须是被复制到了集群中的多数节点,也就是说成为Leader的节点,至少有1个包含了commit消息的节点给它投了票。
而在投票的过程中每个节点都会与candidate比较日志的最后index以及相应的term,如果要成为Leader,必须有更大的index或者更新的term,所以Leader上肯定有commit过的消息。
上面说到,只要日志在多数结点上存在,那么 Leader 就可以提交该操作。但是Raft额外限制了 Leader只对自己任期内的日志条目适用该规则,先前任期的条目只能由当前任期的提交而间接被提交。 也就是说,当前任期的Leader,不会去负责之前term的日志提交,之前term的日志提交,只会随着当前term的日志提交而间接提交。
这样理解起来还是比较抽象,下面举一个例子,该集群中有S1到S5共5个节点,
这个例子中,在c状态,由于Leader直接根据日志在多数节点存在的这个规则,将之前term的日志提交了,当该Term下线后,后续的Leader S5上线,就将之前已经commit的日志清空了,导致commit过的日志丢失了。
为了避免这种已提交的日志丢失,Raft只允许提交自己任期内的日志,也就是不会允许c中这种操作。(c)中可能出现的情况有如下两类:
在实际的系统中,不能让日志无限增长,否则系统重启时需要花很长的时间进行回放,从而影响可用性。Raft采用对整个系统进行snapshot来解决,snapshot之前的日志都可以丢弃。
每个副本独立的对自己的系统状态进行snapshot,并且只能对已经提交的日志记录进行snapshot。
Snapshot中包含以下内容:
当Leader要发给某个日志落后太多的Follower的log entry被丢弃,Leader会将snapshot发给Follower。或者当新加进一台机器时,也会发送snapshot给它。
做snapshot既不要做的太频繁,否则消耗磁盘带宽, 也不要做的太不频繁,否则一旦节点重启需要回放大量日志,影响可用性。推荐当日志达到某个固定的大小做一次snapshot。
很多时候,集群需要对节点进行维护,这样就会涉及到节点的添加和删除。为了在不停机的情况下, 动态更改集群成员,Raft提供了下面两种动态更改集群成员的方式:
在Raft中有一个很重要的安全性保证就是只有一个Leader,如果我们在不加任何限制的情况下,动态的向集群中添加成员,那么就可能导致同一个任期下存在多个Leader的情况,这是非常危险的。
如下图所示,从Cold迁移到Cnew的过程中,因为各个节点收到最新配置的实际不一样,那么肯能导致在同一任期下多个Leader同时存在。
比如图中此时Server3宕机了,然后Server1和Server5同时超时发起选举:
也就是说,以Cold和Cnew作为配置的节点在同一任期下可以分别选出Leader。
基于此,Raft提供了两种集群变更的方式来解决上面可能出现的问题。
单节点成员变更,就是保证每次只往集群中添加或者移除一个节点,这种方式可以很好的避免在变更过程中多个Leader的问题。
下面我们可以枚举一下所有情况,原有集群奇偶数节点情况下,分别添加和删除一个节点。在下图中可以看出,如果每次只增加和删除一个节点,那么Cold的Majority和Cnew的Majority之间一定存在交集,也就说是在同一个Term中,Cold和Cnew中交集的那一个节点只会进行一次投票,要么投票给Cold,要么投票给Cnew,这样就避免了同一Term下出现两个Leader。
变更的流程如下:
以上就是整个单节点的变更流程,在日志被提交以后,那么就可以:
对于同时变更多个节点的情况, Raft提供了多节点的联合共识算法,这里采用了两阶段提交的思想。
主要步骤分为下面三步:
这里有几个概念比较拗口,这里解释一下:
结合下面的图,我们可以走一遍整个集群的变更过程,在多点联合共识的规则之下,每一个任期之中不会出现两个Leader。
参考: