分布式存储系统通过维护多个副本来进行fault-tolerance
,提高系统的availability
。
带来的代价就是分布式存储系统的核心问题之一:维护多个副本的一致性。
一致性协议就是用来干这事的,即使在部分副本宕机的情况下。Raft
是一种较容易理解的一致性协议。
一致性协议通常基于
replicated state machines
,即所有结点都从同一个state出发,都经过同样的一些操作序列,最后到达同样的state。
正常情况下,所有server中只有一个是leader,其它的都是follower。server之间通过RPC
消息通信。follower不会主动发起RPC
消息。leader和candidate(选主的时候)会主动发起RPC
消息。
时间被分为很多连续的随机长度的term
(一段时间),一个term
由一个唯一的id
标识。每个term
一开始就进行leader election
:
followers
将自己维护的current_term_id
加1;candidate
;RequestVoteRPC
消息(带上current_term_id) 给其它所有server
。第一种情况:
自己被选成了 leader。当收到了 majority 的投票后,状态切成 leader ,并且定期给其它的所有 server 发心跳消息(其实是不带 log 的 AppendEntriesRPC )以告诉对方自己是 current_term_id 所标识的 term 的 leader 。每个 term 最多只有一个 leader , term id 作为 logical clock ,在每个 RPC 消息中都会带上,用于检测过期的消息,比如自己是一个过期的 leader(term id 更小的leader)。当一个 server 收到的 RPC 消息中的 rpc_term_id 比本地的 current_term_id 更大时,就更新 current_term_id 为 rpc_term_id ,并且如果当前 state 为 leader 或者 candidate 时,将自己的状态切成 follower 。如果 rpc_term_id 比本地的 current_term_id 更小,则拒绝这个 RPC 消息。
第二种情况:
自己是 follower 。如1所述,当 candidate 在等待投票的过程中,收到了大于或者等于本地的 current_term_id 的声明对方是 leader 的 AppendEntriesRPC 时,则将自己的 state 切成 follower ,并且更新本地的 current_term_id 。
第三种情况:
没有选出 leader 。当投票被瓜分,没有任何一个 candidate 收到了 majority 的 vote 时,没有 leader 被选出。这种情况下,每个 candidate 等待的投票的过程就超时了,接着 candidates 都会将本地的 current_term_id 再加1,发起 RequestVoteRPC 进行新一轮的 leader election 。
每个 server 只会给每个 term
投一票,具体的是否同意和后续的 Safety
有关。
当投票被瓜分后,所有的candidate同时超时,然后有可能进入新一轮的票数被瓜分,为了避免这个问题,Raft采用一种很简单的方法:每个candidate的election timeout
从150ms-300ms之间随机取,那么第一个超时的candidate就可以发起新一轮的leader election
,带着最大的term_id
给其它所有server发送RequestVoteRPC
消息,从而自己成为leader,然后给他们发送心跳消息以告诉他们自己是 leader。
当leader被选出来后,leader就可以接受客户端发来的请求了,每个请求包含一条需要被replicated state machines
执行的命令。leader会把它作为一个log entry
,append到它的日志中,然后给其它的server发AppendEntriesRPC
。当leader确定一个log entry被safely replicated
了,就apply这条log entry到状态机中然后返回结果给客户端。如果某个follower宕机了或者运行的很慢,或者网络丢包了,则会一直给这个follower发AppendEntriesRPC直到日志一致。
当一条日志是commited时,leader才能决定将它apply到状态机中。Raft 保证一条commited的log entry已经持久化了并且会被所有的server执行。
当一个新的leader选出来的时候,它的日志和其它的follower的日志可能不一样,这个时候,就需要一个机制来保证日志是一致的。如下图所示,一个新leader产生时,集群状态可能如下:
最上面这个是新leader,
a~f
是 follower,每个格子代表一条 log entry,格子内的数字代表这个 log entry 是在哪个 term 上产生的。新leader产生后,log就以leader上的log为准。其它的follower要么少了数据比如b,要么多了数据,比如f,要么既少了又多了数据,比如d。
需要有一种机制来让leader和follower对log达成一致,leader会为每个follower维护一个nextIndex
,表示leader给各个follower发送的下一条log entry在log中的index,初始化为leader的最后一条log entry的下一个位置。leader给follower发送AppendEntriesRPC消息,带着(term_id, (nextIndex-1))
, term_id即(nextIndex-1)这个槽位的log entry的term_id,follower接收到AppendEntriesRPC后,会从自己的log中找是不是存在这样的log entry,如果不存在,就给leader回复拒绝消息,然后leader则将nextIndex减1,再重复,直到AppendEntriesRPC消息被接收。
以leader和b为例:
初始化,nextIndex为11,leader给b发送AppendEntriesRPC(6,10),b在自己log的10号槽位中没有找到term_id为6的log entry。则给leader回应一个拒绝消息。接着,leader将nextIndex减一,变成10,然后给b发送AppendEntriesRPC(6, 9),b在自己log的9号槽位中同样没有找到term_id为6的log entry。循环下去,直到leader发送了AppendEntriesRPC(4,4),b在自己log的槽位4中找到了term_id为4的log entry。接收了消息。随后,leader就可以从槽位5开始给b推送日志了。
Raft保证被选为新leader的server拥有所有的已经committed的log entry,这与ViewStamped Replication
不同,后者不需要这个保证,而是通过其他机制从follower拉取自己没有的commited的log entry。
这个保证是在RequestVoteRPC阶段做的,candidate在发送RequestVoteRPC时,会带上自己的最后一条log entry的term_id和index,server在接收到RequestVoteRPC消息时,如果发现自己的日志比RPC中的更新,就拒绝投票。日志比较的原则是,如果本地的最后一条log entry的term id更大,则更新,如果term id一样大,则日志更多的更大(index更大)。
A time sequence showing why a leader cannot determine commitment using log entries from older terms. In (a) S1 is leader and partially replicates the log entry at index 2. In (b) S1 crashes; S5 is elected leader for term 3 with votes from S3, S4, and itself, and accepts a different entry at log index 2. In (c) S5 crashes; S1 restarts, is elected leader, and continues replication. At this point, the log entry from term 2 has been replicated on a majority of the servers, but it is not committed. If S1 crashes as in (d), S5 could be elected leader (with votes from S2, S3, and S4) and overwrite the entry with its own entry from term 3. However, if S1 replicates an entry from its current term on a majority of the servers before crashing, as in (e), then this entry is committed (S5 cannot win an election). At this point all preceding entries in the log are committed as well.
关于算法的正确性证明见:Raft implementations
在实际的系统中,不能让日志无限增长,否则系统重启时需要花很长的时间进行回放,从而影响availability。Raft采用对整个系统进行snapshot
来处理,snapshot之前的日志都可以丢弃。
snapshot技术在Chubby和ZooKeeper系统中都有采用。
committed log entry
(已经apply到了状态机)进行snapshot,snapshot有一些元数据,包括
last_included_index
,即snapshot覆盖的最后一条commited log entry的 log index,和
last_included_term
,即这条日志的
termid
。这两个值在snapshot之后的第一条log entry的AppendEntriesRPC的
consistency check
的时候会被用上,之前讲过。一旦这个server做完了snapshot,就可以把这条记录的最后一条log index及其之前的所有的log entry都删掉。
snapshot的缺点就是不是增量的,即使内存中某个值没有变,下次做snapshot的时候同样会被dump到磁盘。
当leader需要发给某个follower的log entry被丢弃了(因为leader做了snapshot),leader会将snapshot发给落后太多的follower。或者当新加进一台机器时,也会发送snapshot给它。
发送snapshot使用新的RPC,InstalledSnapshot
。
做snapshot有一些需要注意的性能点:
1. 不要做太频繁,否则消耗磁盘带宽。
2. 不要做的太不频繁,否则一旦server重启需要回放大量日志,影响availability。统推荐当日志达到某个固定的大小做一次snapshot。
3. 做一次snapshot可能耗时过长,会影响正常log entry的replicate。这个可以通过使用copy-on-write
的技术来避免snapshot过程影响正常log entry的replicate。
Raft将有server加入集群或者从集群中删除也纳入一致性协议中考虑,避免由于下线老集群上线新集群而引起的不可用。集群的成员列表重配置也是一条log entry,log内容包含了集群成员列表。
老集群配置用 Cold 表示,新集群配置用 Cnew 表示。
当集群成员配置改变时,leader收到人工发出的重配置命令从 Cold 切成 Cnew ,leader 给其它server复制一条特殊的log entry给其它的server,内容包括 Cold⋃Cnew ,一旦server收到了这条特殊的配置log entry,其后的log entry会被replicate到 Cold⋃Cnew 中,一条log entry被认为是committed的需要满足这条日志既被 Cold 的majority写盘,也被 Cnew 的majority写盘。一旦 Cold⋃Cnew 这条log entry被确认为committed,leader就会产生一条只包含了 Cnew 的log entry,同样复制给所有server,server收到log后,老集群的server就可以自动下线了。
上图说明只要给个5ms的区间,就能避免反复的投票被瓜分。超过10s没有leader的情况都是因为投票被瓜分的情况。
150-300ms的election timeout区间,没有leader的时间平均287ms。
系统推荐使用150ms~300ms
由于Go语言内置RPC,Channel,goroutine等高级编程组件,实现一个相对于其他语言还是容易些,这里有一个Go的实现 Raft
In Search of an Understandable Consensus Algorithm