论文阅读-Raft-2

Posted by keys961 on March 17, 2019

1. 集群变化

集群配置变化(如更改节点数量,更换复制级别)转换过程中,不能出现脑裂。

但是任何直接转换的方法都是不安全的,不可能所有的节点一次性自动转换完成,因为每个节点的转换时间不同,可能由于新旧配置不同导致同一任期下出现脑裂。

如下图,在同一任期中,该点在旧配置和新配置可能出现2个Leader:

根据Raft,旧配置下的Leader在任期中会收到新Candidate的VoteFor,但是任期号被重置,所以旧配置下的Leader在变化配置前,会拒绝那个Candidate的请求。

如下图:

  • 我们先启动了 S4 和 S5,成功启动;
  • 然后我们将新的配置发送到 S1,S2,S3 中,试图让他们应用新的配置;
  • 在某个时刻,S3 成功应用新的配置,同时,旧的系统发生了故障,并开始选举;
  • 5 台服务器一起开始选举,由于 S1 和 S2 还没有应用新的配置,所以,S1 和 S2 仍然以为只有 3 台服务器,并且在得到 2 张选票后,成功选出一个领导者
  • S3,S4,S5 应用了新的配置,并且获得 3 台服务器的认可,也成功选出了领导人

这时就有2个Leader(1和3),产生脑裂。

brain_split

为了安全,配置更改必须使用两阶段实现。

1.1. Joint Consensus

Raft使用联合共识(joint consensus),切换到一个过读的配置,一旦它被提交,则系统切换到新配置上,具体性质有:

  • 日志条目会被复制给集群中新、老配置的所有节点
  • 新老配置的节点都能成为Leader
  • 选举和日志条目提交需要得到新、老配置的大部分节点的同意

具体过程如下:

  • Leader收到新配置时,创建一条日志项$C_{old, new}$($C$为日志配置),并提交,提交需要大多数$C_{old}$和$C_{new}​$下的节点同意(共同决定)

    $C_{old, new}​$提交成功后,进入过渡的$C_{old, new}​$状态,任何日志的提交需要结果这2部分的大部分节点同意

  • 然后Leader创建新配置日志项$C_{new}$,并提交,提交需要$C_{new}$下的节点同意。成功后,切换到新配置下(节点一旦向日志写入$C_{new}$,则后面一直使用$C_{new}​$配置)

    这样不会同时有$C_{old}$和$C_{new}$独立做决定

js

1.2. 几个要考虑的问题

1.2.1. 提交$C_{old, new}$时,Leader失效

只有$C_{old}$或$C_{old, new}$的节点才能成为Leader,取决于获胜者是否收到了$C_{old,new}$:

  • 有就继续变更
  • 否则使用$C_{old}$配置

任何情况下,$C_{new}$都不可能做出单边决议(不能成为Leader),实际上可通过每次变更只改动一个节点实现,数学证明可参考这里,它也可以防止出现脑裂的情况。

此外,当$C_{old,new}​$提交后,也只有包含该日志项的节点才能成为Leader,这由Leader完整性保证(之前也证明了)。

1.2.2. 提交$C_{new}$时,Leader失效

若大部分节点收到$C_{new}$,则Leader必含有$C_{new}$(因为只含有$C_{old,new}$节点投票时不会得到含有$C_{new}$节点的同意),流程恢复;

若没有大部分节点收到$C_{new}​$,无论谁都能成为Leader,但无所谓,只有继续执行下面的流程即可。

1.2.3. 新节点没有任何日志存储

以没有投票权的身份加入集群(伪加入,即Leader可以复制日志给它们,但是计算投票数时不会包含它们),然后追赶Leader。追赶上了后才能真正加入集群,进行配置变更。

1.2.4. 原来的Leader不在新配置中

提交完$C_{new}$后,回退到Follower。

提交完$C_{new}​$,集群就可以以该配置下进行决定,此时可以进行Leader变更。

1.2.5. 移除节点干扰新集群

旧配置下的节点下线后(但是节点间仍然互联),收不到新配置下Leader的心跳,从而进行新一轮选举(根据旧配置),而任期号增加,导致新配置下的Leader变成了Follower。

解决方法

  • 节点收到RequestVote请求后,假如它确信Leader是谁,就不投票(即Leader的election timeout前收到了RequestVote请求时,不投票);
  • 此外下线节点(旧配置下的节点)可以在超时之后等待一段时间,这样可以让新配置下的Leader发送心跳(本质上还是延长了超时时间)

2. 日志压缩

2.1. 快照内容

快照是最简单的日志压缩方法——整个系统将某个时间段状态以快照形式写入持久化存储,然后该时间段之前的日志以及之前的快照就都可以清除。

Raft中,每个节点单独做快照,快照包括:

  • 状态机状态
  • lastLogIndex&lastLogTerm,记录快照最后一条日志项的索引位置和任期号
  • 集群的最新配置信息,以支持集群配置变更

2.2. 发送流程和规则

第1.2.3.节中,Leader通过发给Follower快照以让其追赶,这个操作叫做InstallSnapshot,Follower收到请求后:

  • 若Follower缺失快照中的日志(通过lastLogIndex&lastLogTerm判断),清空本地日志和状态机,状态机由该快照替代(配置也会被加载);
  • 若Follower拥有快照中的日志,删除快照日志之前的所有日志,保留之后的日志

Follower收到请求也会重置election timeout的计时器

具体接受者的流程如下:

  • 若请求的term小于本地currentTerm,直接返回
  • 有必要,需要创建新文件,然后根据偏移量写入快照,并保持
  • 删除之前小的lastLogIndex&lastLogTerm的快照
  • 如果当前本地日志的index(Follower应该指committedIndex)和term包含了快照的日志,保留后面的日志,并返回
  • 否则清除日志,用该日志重置状态机状态,并加载新的集群配置信息

如下图:

is

2.3. 性能考虑

  • 节点快照间隔要合适(若太频繁则会浪费磁盘I/O;若过于不频繁则日志会过于增长,导致节点恢复时浪费时间)
  • 写快照的时间很长,不能影响其它正常操作,可使用Copy-on-write技术

3. 客户端交互

3.1. 路由规则

客户端必须将请求发给Leader:

  • 请求若不是Leader(如初始化时),Follower会拒绝请求并重定向到Leader;
  • 若Leader崩溃,请求超时,那么客户端会重试上面的步骤,直到命中Leader。

3.2. 实现线性化

即保证调用和得到响应式,请求只被处理一次(exactly-once),请求似乎是立刻响应的

  • 客户端对每条指令赋予唯一的序列号,若节点失效则重试
  • 服务端跟踪这些序列号(可保持到日志项中):
    • 若已被执行,则直接返回结果
    • 否则执行命令

该场景可能读到老数据:

老Leader挂了,重新选举后,它依旧认为自己是Leader,于是客户端请求到它,得到老数据。

  • Leader需要知道哪些日志项已提交了
    • 正常下,Leader一直有已提交的日志项
    • 新一轮选举结束后,即任期开始时,尽管新Leader一定包含了旧Leader已提交的日志项,但是它不知道哪些是已提交的,所以必须提交一条日志条目(Raft中提交一个空白的日志条目)来知道这些信息(?)
  • Leader处理读请求前,要确定自己是否还是Leader,这可以通过发送心跳决定