引言
分布式系统除了提升整个体统的性能外还有一个重要特征就是提高系统的可靠性。
提供可靠性可以理解为系统中一台或多台的机器故障不会使系统不可用(或者丢失数据)。
保证系统可靠性的关键就是多副本(即数据需要有备份),一旦有多副本,那么久面临多副本之间的一致性问题。
- 比如,一台机器上的磁盘损坏,数据丢失,可以从另一台机器上的磁盘恢复(分布式系统会对数据做备份)
- 比如,集群中某些机器宕机,整个集群还可以对外提供服务
什么是RAFT
Raft is a consensus algorithm for managing a replicated log.
Raft是一种管理复制日志的一致性算法。
它的首要设计目的就是易于理解,所以在选主的冲突处理等方式上它都选择了非常简单明了的解决方案。
Raft将一致性拆分为几个关键元素:
- Leader选举
- 日志复制
- 安全性
Raft算法
所有一致性算法都会涉及到状态机,而状态机保证系统从一个一致的状态开始,以相同的顺序执行一些列指令最终会达到另一个一致的状态。
以上是状态机的示意图。所有的节点以相同的顺序处理日志,那么最终x、y、z的值在多个节点中都是一致的。
角色
在Raft中,节点有三种角色:
- Leader:负责接收客户端的请求,将日志复制到其他节点并告知其他节点何时应用这些日志是安全的。
- Candidate:用于选举Leader的一种角色。
- Follower:负责响应来自Leader或者Candidate的请求。
角色转换如下图所示:
- 所有节点初始状态都是Follower角色
- 超时时间内没有收到Leader的请求则转换为Candidate进行选举
- Candidate收到大多数节点的选票则转换为Leader;发现Leader或者收到更高任期的请求则转换为Follower
- Leader在收到更高任期的请求后转换为Follower
从状态转换关系图中可以看出,集群刚启动时,所有节点都是follower,之后在time out信号的驱使下,follower会转变成candidate去拉取选票,获得大多数选票后就会成为leader,这时候如果其他候选人发现了新的leader已经诞生,就会自动转变为follower;而如果另一个time out信号发出时,还没有选举出leader,将会重新开始一次新的选举。可见,time out信号是促使角色转换得关键因素,类似于操作系统中得中断信号。
在Raft协议中,将时间分成了一些任意长度的时间片,称为term,term使用连续递增的编号的进行识别,如下图所示:
每一个term都从新的选举开始,candidate们会努力争取称为leader。一旦获胜,它就会在剩余的term时间内保持leader状态,在某些情况下(如term3)选票可能被多个candidate瓜分,形不成多数派,因此term可能直至结束都没有leader,下一个term很快就会到来重新发起选举。
term也起到了系统中逻辑时钟的作用,每一个server都存储了当前term编号,在server之间进行交流的时候就会带有该编号,如果一个server的编号小于另一个的,那么它会将自己的编号更新为较大的那一个;如果leader或者candidate发现自己的编号不是最新的了,就会自动转变为follower;如果接收到的请求的term编号小于自己的当前term将会拒绝执行。
server之间的交流是通过RPC进行的。只需要实现两种RPC就能构建一个基本的Raft集群:
- RequestVote RPC:它由选举过程中的candidate发起,用于拉取选票
- AppendEntries RPC:它由leader发起,用于复制日志或者发送心跳信号。
leader选举
选举流程
1.首先,在初始状态下,集群中所有的节点都是Follower状态,并被设定一个随机 election timeout(150ms-300ms):
2.如果超时时间到期后没有收到来自Leader的心跳,节点就发起选举:将自己的状态切换为 Candidate,增加自己的任期编号,然后向集群中的其它 Follower 节点发送请求,询问其是否选举自己成为 Leader:
3.其他节点接收到候选人 A 的请求投票消息后,如果在编号为 1 的这届任期内还没有进行过投票,那么它将把选票投给节点 A,并增加自己的任期编号:
4.当收到来自集群中过半数节点的接受投票后,节点即成为本届任期内 Leader,他将周期性地发送心跳消息,通知其他节点我是Leader,阻止Follower发起新的选举:
如果在指定时间内(一个随机时间,每个节点都不同),Follower没有接收到来自Leader的心跳消息,那么它就认为当前没有Leader,推举自己为Candidate,发起新一轮选举。
通信方式
在 Raft 算法中,节点之间的通信采用的是RPC方式,在Leader选举中,需要用到两类RPC:
- 请求投票(RequestVote)RPC:由Candidate在选举期间发起,通知各Follower进行投票;
- 日志复制(AppendEntries)RPC:由Leader发起,用来进行日志复制和心跳。
任期
在选举流程中,我提到Leader节点是有任期的,每个任期由单调递增的数字标识,比如节点 A 的任期编号是 1。任期编号是随着选举的举行而变化的:
- Follower在等待Leader的心跳信息超时后,会推举自己为Candidate,此时会增加自己的任期编号;
- 如果一个节点发现自己的任期编号比其他节点小,那么它会更新自己的编号为较大的编号值。比如节点 B 的任期编号是 0,当收到来自节点 A 的请求投票消息时,因为消息中包含了节点 A 的任期编号,且编号为 1,那么节点 B 将把自己的任期编号更新为 1。
- 当任期编号相同时,日志完整性高的Follower(也就是最后一条日志项对应的任期编号值更大,索引号更大),拒绝投票给日志完整性低的Candidate。
日志复制过程
在 Raft 算法中,副本数据是以日志的形式存在的,Leader接收到来自客户端写请求后,处理写请求的过程就是一个日志复制的过程。
日志项
日志是由日志项组成的,那么日志项究竟是什么样子呢?
日志项是一种数据格式,它主要包含客户端的指令(Command),还有索引值(Log index)、任期编号(Term)等信息:
从上图可以看到,一届领导者的任期中,往往有多条日志项,而且日志项的索引值是连续的,
复制流程
- 首先,当Leader节点接收到客户端的写请求后,会创建一个新日志项,并附加到本地日志中;
- Leader通过日志复制(AppendEntries)RPC 消息,将日志项复制到集群其它Follower节点上;
- 如果Leader接收到大多数的“复制成功”响应后,它将日志项应用到自己的状态机,并返回成功给客户端。如果Leader没有接收到大多数的“复制成功”响应,那么就返回错误给客户端;
- 当Follower接收到心跳信息,或者新的AppendEntries消息后,如果发现Leader已经提交了某条日志项,而自己还没应用,那么Follower就会将这条日志项应用到本地的状态机中。
从上面这个过程可以看出,当Follower节点接受Leader的心跳消息或者AppendEntries消息后,会将日志项应用到自己的状态机。这个优化,降低了处理客户端请求的延迟,将二阶段提交优化为了一段提交,降低了一半的消息延迟。
一致性检查
在 Raft 算法中,Leader通过强制Follower直接复制自己的日志项,处理不一致日志。具体有 2 个步骤:
- 首先,Leader通过AppendEntries消息,找到Follower节点上与自己相同日志项的最大索引值。也就是说,这个索引值之前的日志,Leader和Follower是一致的,之后的日志是不一致的了。
- 然后,Leader强制Follower更新不一致日志项,实现日志的一致。
举个例子来理解下, 为了方便演示,我们引入 2 个新变量 :
- PrevLogEntry:表示当前要复制的日志项的前一条日志项的索引值。比如下图中,如果Leader将索引值为8的日志项发送给Follower,那么此时 PrevLogEntry 值为 7;
- PrevLogTerm:表示当前要复制的日志项的前一条日志项的任期编号。比如下图中,如果Leader将索引值为8的日志项发送给Follower,那么此时 PrevLogTerm 值为 4。
- 首先,Leader通过AppendEntries消息,发送当前最新的日志项到Follower,这个日志项的 PrevLogEntry 值为 7,PrevLogTerm 值为 4;
- 如果Follower在它的日志中,找不到PrevLogEntry 值为 7、PrevLogTerm 值为 4 的日志项,也就是说它的日志和Leader的不一致了,那么Follower就会拒绝接收新的日志项,并返回失败信息给Leader;
- 此时Leader会递减要复制的日志项的索引值,并发送新的日志项到Follower,这个消息的 PrevLogEntry 值为 6,PrevLogTerm 值为 3;
- 如果Follower在它的日志中,找到了 PrevLogEntry 值为 6、PrevLogTerm 值为 3 的日志项,那么AppendEntries消息返回成功,这样一来,Leader就知道在 PrevLogEntry 值为 6、PrevLogTerm 值为 3 的位置,Follower的日志项与自己相同;
- 最后, Leader通过AppendEntries消息,复制并更新覆盖该索引值之后的日志项(也就是不一致的日志项),最终实现了集群各节点日志的一致。