在这篇文章中,整理一下数据流的逻辑。我们要明确,raft状态机的驱动有以下几种方式:

  1. 逻辑层的ticker
  2. 传输层收到的各种包的step
  3. 逻辑层的各种propose

除了以上的方式,raft状态机无法被驱动。

当一个节点启动后,经过选举成为了Leader,该如何将自身的log复制到follower中?我们来梳理一下这部分逻辑。

Leader向Follower复制log

log replication

在raft状态机中,当成为了leader,就要向follower复制log,在raft论文中,我们也已经了解了,作为leader,需要维护每一个leader的match与next,分别是已经确认复制完毕的log索引与下一次将要发送的log索引。

在leader初始化的时候,已经把所有follower的progress进行了设置,设置为next是本地最新log的索引+1,同时progress的状态也会设置为probe状态,这个主要用于区别replicate状态,由于各个follower的日志情况不确定,我们需要不断的进行重试来确定follower的真实日志状态。

在raft状态机成为leader的初始化过程中,还设置了pendingConfIndex,主要用于在恢复整个集群的时候,忽略所有集群变更信息,只有当所有在pendingConfIndex之前的日志被apply后,才可以进行集群变更操作。那么这里就是leader所有的日志被apply之后,才能进行集群变更操作。

在成为leader之后,必须向日志中追加一条noop日志,该操作主要是防止出现raft论文中提到过的非当前任期的log被大多数节点接受后是不能确定该日志被commit的,只有自己任期内的log被大多数节点复制了,才能确定该条日志被apply。所以在成为leader后,追加一条noop,由于是当前任期内append的,所以只要这条noop被集群的大多数节点复制了,那么我们可以把这条noop的index作为commitIndex。

在noop被append到log中,会尝试发送日志,也就是调用bcastAppend 。在该函数中,主要是依据各follower的progress中的next来确定发送位置。首先会从log中获取next-1的日志term,主要是保证该日志前所有的日志都复制完成了,这部分主要使用了数学归纳法来证明,具体可以看raft论文。

在初始化的时候,leader下所有follower的progress都被设置为append noop前的last log index,所以在广播log的时候,所有follower的next其实是指向了新append的noop的,所以第一次尝试是不会发送快照的,因为该log肯定在log中。于是一条MsgApp会被初始化,其中的index和term被初始化为上一条log的信息,同时带上整个log的commitIndex。

这里有些需要注意,在etcd的raft实现中,持久化了commitIndex,而在raft论文中其实没有这种要求,该属性是属于volatile的。其实两种都可以,我们可以分析一下两种情况。

第一种情况是按照raft论文,不持久化commitIndex。在这种情况下,leader启动后,commitIndex是被初始化为0的,在这种情况下,leader会不断的尝试给follower发送日志,直至在某个log index后accept后,那么我们就可以确认从哪条日志可以开始复制了。当所有的集群都确认完毕后,只要集群的当前match的log index的term和leader相同,那么就可以确认该条log被commit了。主要的差异在于在commitIndex没有被集群确认前,整个集群的commitIndex为0,也就是无法在这段时间中进行log的apply。

第二种其实情形其实和第一种差不多,但是commitIndex是被持久化的,在确认最新的commitIndex之前,log就可以继续的被apply。

我们回到之前谈论复制noop的部分。在第一条noop被发出之前,我们需要确认follower的复制状态,初始化的状态是probe,也就是在探查该follower的log状态来进行replicate。在这种情况下,我们会将该follower的progress设置暂停,只发送这么一次,防止还未确认follower的log情况下持续发送日志。

之后这条日志就会通过逻辑层被transport层发送。follower在收到这条日志的时候,假设MsgApp的index小于本节点的commitIndex,那么会直接回复accept,index带上了本节点的commitIndex。想想为何如此处理,因为假设日志被commit了,那么代表该日志肯定已经被集群的大部分节点复制完成了,肯定已经落盘了,所以commitIndex之前的数据已经在日志中了。由于MsgApp中的index是前一条log的index,那么该log中的数据中的第一条日志,肯定是follower节点的commitIndex处的日志,而MsgApp可能是批量的log,这里为何要做这个处理?因为对于follower来说,commitIndex之前的肯定是不可被覆盖的,不然raft的一致性就被打破了,所以在commitIndex之后的,才可能会被覆盖,这部分日志是属于不稳定的状态的。

leader向follower传输数据的时候,初始状态都处于probe状态,用于探查follower中匹配的日志条目,从而可以从该日志条目继续发送后续的日志,来达成leader和follower日志的一致。commitIndex之前的日志条目不可被覆盖,所以在leader传来了commitIndex之前的位置后,就可以直接告诉leader,下次复制直接从commitIndex之后复制就可以了,这部分数据还没有被commit,所以可以被覆盖。这时候leader的progress中的match就可以更新至follower的commitIndex。当match被更新后,就可以继续发送next之后的日志了,而此时处于probe状态的follower,将会进入replicate状态来继续正常的复制。在接收到MsgAppResp的时候,也会进行leader节点的commitIndex的更新工作,假设更新了,那么也会向集群来广播最新的commitIndex。

除了正常的accept leader的MsgApp,我们来看一下reject的情况。当对于follower来说,此条消息中的index大于等于commitIndex的日志被收到,也就是代表着MsgApp中含有的entries,肯定在commitIndex的entry之后。此时follower会尝试将该日志应用到自己的日志中,首先就是要看该消息的index与logTerm是否与自己的日志相符。

假设不符合,则直接reject,并会带上自己日志中最新的日志号作为提示。

假设符合,则将应用后最新的日志号返回。

我们再次转回leader的角色,来看看leader的处理。当leader收到follower的reject回应之后,会更新该follower的progress,尝试将发送的index变小,我们看到了,etcd的raft在这儿将follower的最新的log index传回了,这个主要是用于寻找下一次发送的位置。假设没有这个信息,那么假设follower缺失了很多数据,那么leader必须逐次的减小下一次发送的索引,才可能会找到匹配的位置,所以有follower最新的日志号,我们可以直接将下一次的位置调整为最新的日志号,会大大加快查找的速度。

上面谈到的情况,其实是leader传输了follower没有的日志后会发生的一些情况。我们来看看假设leader传输的这块数据与follower不一致的情况(follower上的日志是别的任期的leader复制的)。TODO。

随着复制状态由probe转为replicate,那么leader到follower的复制已经初始化完毕,之后会随着MsgAppResp的回包来驱动下一次的数据不断的进行发送。另一个驱动的地方就是心跳包了,当该follower的日志比较旧的时候会主动触发数据的发送。

snapshot

看完了log的复制,我们来看一下,何时会进行snapshot的复制。我们知道,由于log是一些增量的日志,在整个集群的生命周期中,随着操作的增加,数据量会越来越多,为了避免这种情况,我们需要将一些log进行snapshot来降低log的大小。而对于leader向follower的复制,假设follower的复制位置在leader中已经不存在了,那么leader必须将包含该位置的snapshot发往follower来继续复制,也就是全量+增量的数据恢复方式。

我们来看一下以下的场景,一个follower由于某种原因被临时的关闭了,而整个集群还在对外提供服务。当follower重启后,leader会向follower复制自身的日志。当leader向follower拷贝需要的日志的时候,会发现该日志已经从log中移除了,于是leader会向follower传输一个最新的快照。快照的发送只有在follower节点处于active状态才会触发,也就是follower发送了MsgAppResp或者是MsgHeartbeatResp后才会进行触发。

当快照发送被触发后,对应的progress会切换为snapshot状态,被由逻辑层发送。

当follower收到了snapshot消息之后,假设snapshot应用失败,则继续发送MsgAppResp,其中的index为自己的commitIndex;假设应用成功了,则返回应用成功后最新的index。follower在应用快照的过程中,流程如下:

  1. 首先需要通过commitIndex来确定快照是否比自己的日志新
  2. 假设该snapshot的term&&index已在本地的日志中,也就是快照的数据都在本地的日志中,则可以认为snapshot的index已被commit了(被commit并且apply的部分,才会生成快照),直接将日志的commitIndex设为snapshot的index
  3. 应用到unstable日志中,并且更新commitIndex,通知逻辑层,并且通过MsgAppResp带上snapshot的index来告知leader自己已经应用成功这部分日志。逻辑层的这部分处理也是需要注意先后顺序的,必须等待snapshot被应用成功后,才能发送MsgAppResp。同时逻辑层需要应用完毕snapshot后清理所有的entries,之后的第一个将要被写入的entries则是snapshot的index+1

当leader收到follower的MsgAppResp之后,则会将对应的follower的状态转为replicate状态,继续剩下日志的复制。

配置变更

配置变更也是其实比较晦涩的部分,我们还是从leader角色的转换时刻开始看,上面已经提到了,在变为leader的时候,会将pendingConfIndex变为最新的本地log的索引,这是为了安全地在本地日志没有被commit并且apply之前,不处理所有的新的配置变更信息。

raft状态机中,只有第一次启动才会使用StartNode来初始化raft状态机,剩下所有的启动只会使用RestartNode来创建raft状态机,这有何不同呢?

主要差异在于第一次启动的时候,raft状态机接收的参数中有peers参数,指明了整个集群的节点信息,而restart并不需要该参数。

第一次启动raft状态机

第一次启动,需要提供节点列表。第一次启动之后,后续的成员变更,都需要通过调用状态机的接口,而不是随便改变运行参数之类的。在第一次启动的时候,raft状态机会将节点信息作为conf change信息记录到本地日志中,同时会将自身的commit调整为最后一条日志,因为所有的节点启动后必定会先写入这几条信息,所以这些信息肯定是会被commit的。在commit之后,会调用addNode来新增节点,这里的applied是不会更新的,所以这部分数据会被逻辑层通过ready取得后继续通过状态机的ApplyConfChange 再次添加一遍的。根据注释,解释说是为了能让leader选举尽快的进行。

所以在处理committedEntires的时候,假设是一个配置变更消息,在逻辑层的处理中,我们需要将其持久化

未完

共 0 条回复
暂时没有人回复哦,赶紧抢沙发
发表新回复

作者

sryan
today is a good day