raft thesis阅读记录

之前读过一些raft工程源码,如etcd,braft,Dragonboat中都对进行了相应的工程优化,如并行落盘,batch,pipeline等,witness节点等,实现了一些小论文中没有详细描述的功能,leader变更,成员节点变更等。这些功能及优化在raft作者的博士论文《CONSENSUS: BRIDGING THEORY AND PRACTICE》中都有提及,近期阅读了其中部分段落,记录一下。

1. transfer leader

leader节点的变更在工程中还是一个比较重要的功能。按照论文中所描述,在某些情况这个功能是很有用的:1)leader节点要下线的情况,如果不主动进行leade节点切换,靠election超时,会有一段不可用的时间。2)某些节点更适合作为leader节点,比如某些节点的硬件性能更好,离用户更近RTT更低。

方法也很简单,就三个步骤:

  1. 将要切出的leader停止接受新的请求
  2. 将所有的日志同步给目标节点
  3. 发送一个TimeoutNow请求给目标节点,触发目标节点变为candidate

当然在raft这种本来实现就比较复杂的代码中,想优雅的实现transfer leader功能还是挺难的,有一些细节需要处理,比如transfer超时了,那么旧leader节点需要能够重新接受新的请求。

2. 成员变更

这个是小论文中描述的不太清晰的一个点,在大论文中对该点进行了详细论述。
首先该功能是存在风险的,即如果变更的成员数量超过一个,那么可能会存在脑裂的问题。配置变更也是通过日志复制的形式进行的,leader将包含新配置的日志复制给follower,过半复制后提交,但是如下图所示的情况,新配置在三个节点上完成了提交,但是还有两个节点使用旧配置,旧配置中一共只有三个节点,这时候可能形成majority选出一个旧配置的leader,形成脑裂。
在这里插入图片描述
论文中提出的解决方案有两种:

  1. 每次只添加或者删除一个节点,等待变更成功后再进行下一次变更
  2. 使用joint算法,增加一个中间过程,从旧配置,先变更到新旧配置共同起作用,然后再变更到新配置。

首先介绍了第一种算法的安全性,这个很容易证明,配置的变更其实和append log是一样的,leader将新的配置复制给所有节点,过半复制后完成提交,提交的节点将采用新配置,如果每次只变更一个节点,那么新配置和旧配置的majority必定存在重合,意味着新配置一旦提交,旧配置就形成不了majority,不会脑裂,参考下图,无论是向奇数或者偶数节点的集群中删除或者增加一个节点,新旧配置的majority都必定存在交集。
在这里插入图片描述
不过这个方法问题挺多的,在论文中都有讨论,比如现在的leader是将要被删除的节点,这个方法就不好使,当然可以先transfer leader再进行上述操作,但是如果新旧集群无重合的情况下,这个方法也不好使,而且变更慢,根据实现细节,还有一些其他问题,比如被删除的节点没有正确收到配置信息,可能会不断的影响集群可用性,参考论文4.2.3。

论文后续又介绍了第二种方法,这个方法会更复杂一些,如下图所示,在变更中增加了中间状态,在中间状态时,新旧配置共同生效。步骤如下:

  1. leader发起配置变更,写入具有新配置和旧配置的日志,复制给新旧配置中包含的所有节点,该日志需要新配置和旧配置中的分别达到majoprty才能提交,提交后进入JOINT状态
  2. leader进入JOINT状态后再次发起配置变更,本次近发起具有新配置的配置变更,本次日志提交仅需要新配置中达到majority
  3. 如果提交的新配置的leader不被包含在新配置中,自身需要step down

在这里插入图片描述

JOINT状态接受到的请求都需要新旧配置分别majority才能commit。
分析一下这样做为什么是安全的,不会出现脑裂问题。因为旧配置和JOINT阶段可能存在交集,但是如果JOINT阶段能选出leader,则证明旧配置中有过半节点接受了JOINT阶段的日志,那么旧配置肯定无主,而JOINT阶段和新配置存在交集,如果JOINT有主,则必定有过半新配置的节点仍然处于JOINT状态,新配置无法选主,反之,新配置达到majority,则JOINT阶段的节点一定无法选主,所以必定不会出现脑裂问题。

此外,还有一些细节的处理,对于新加入的节点有catchup阶段,即新节点需要赶上原有节点的日志进度,这需要同步snapshot或者日志才能完成,需要一定的时间,在这段时间内,可能会对可用性,造成影响,所以日志提交或者选主计算majority时,不需要计算还在catchup状态的节点。

工程实现核心:

  1. 日志变更配置的生效时间。leader,在发出日志前就应该生效,follower在持久化日志完成后就应该生效
  2. 被剔除的节点处理方式,参考了braft源码,如果旧的leader不在新配置中,在配置变更结束后会主动step_down,且旧的leader参与了joint部分,新配置写入了旧leader的日志中,旧的leader是知道自己已经被剔除的,在发起超时发起选举时通过配置判断自己被剔除了,无法发起pre_vote。如果是被剔除的follower,其不会参与joint阶段,节点上保留的配置是{old,new}共同生效的配置,这时候发起pre_vote需要新旧节点共同生效,但是该节点的日志数量是少于新配置节点的日志的(新配置节点起码多一条{new}配置信息),所以旧节点自选举永远无法获得过半新节点的投票,无法成为leader

参考文章:
raft配置变更

3. 快照与日志回收

在论文中用一章的篇幅来论述了该功能,在工程中如何进行trade off。本质上日志是否回收,并不影响raft算法的正确性,但是在工程实现上会影响系统的可用性:

  1. 日志无限膨胀,占用存储资源
  2. 日志太多,新增节点或者节点重启时,需要重放所有的日志,浪费大量时间

一般来说日志回收都是伴随着snapshot一起进行了,将状态机的当前状态dump下来持久化起来,那么之前applied的日志就不再被需要了,可以被回收掉。

回收的方案也比较简单,无非就是定时回收或者定量回收,工程中根据需要选择即可。举个例子:

  1. 在etcd的实现中(etcd-raft本身只提供了compact日志的接口,需要应用层决定何时回收,回收多少日志)是在snapshot时进行日志回收,snapshot结束后,选择snapshot包含的最后一条日志的索引号减去5000,得到的index就是回收索引,在该index之前的日志都需要被回收。etcd的snapshot策略是日志条目超过一定量之后,就会执行snapshot,整体上相当于按量回收日志。
  2. braft的实现是定期回收日志,braft会定期执行snapshot操作,每次snapshot结束后,回收上一次snapshot之前的日志信息,即braft最多保存两个snapshot间隔的日志,比如每30秒执行一次snapshot,那么braft最多保存1分钟内的日志条目。

可以看到上述两者的实现都是snapshot后进行回收,但是回收的日志会比最新snapshot的日志更旧一点,这是工程上的优化,防止leader刚做完snapshot就把所有日志清理掉,而后follower节点拉取日志时拉取不到,就转变为发送快照,加大了系统的网络和IO开销。

从这个角度考虑,为同步慢的follower保存多少日志,也是一个取舍问题,保存多了浪费磁盘,保存少了容易同步不上,导致install snapshot。

在工作中就遇到过类似情况,某个进程中有大量的braft node,这个进程挂掉一段时间后重启,进程中的braft node都是慢节点,其对应的leader节点们会向他们发起install snapshot,直接将该进程的网卡和IO打满了,导致所有节点完成install snapshot都很慢,上面我们提到过,braft按照时间来回收日志,一旦follower完成install snapshot请求的时间超过了日志回收周期,那么follower完成install snapshot后,leader将follower需要的日志又删除掉了,又要发起install snapshot,循环往复,系统无法恢复正常。这个问题在cockroachDB的issue中也提及了,激进的日志回收会导致重复发送snapshot。

4. 读优化

raft本身是一个线性一致性系统,很容易理解,读写请求都是通过向leader节点写日志来实现的,相当于是个单点单进程的程序,肯定能线性一致。不过把读请求也作为日志写入leader,走一次raft协议,这个操作太重了,论文中对读进行了一些优化。
首先,第一个优化算法是read index,过程如下:

  1. leader收到一个读请求,记录下当前commit index为readIndex
  2. leader广播一轮心跳,如果收到过半回复,进入下一步
  3. 等待状态机apply到readIndex,然后读数据并回复给客户端

该方法能够保证线性一致性。分析该方法之前,先简单定义一下线性一致性,即每次都能读到最新值,再说具体一点就是,如果在请求A完成之后,发起请求B,那么请求B一定可以看到A的修改。现在来分析一下readIndex为什么满足该约束:

  1. 记录当前commit index为read index,且等待状态机apply到该index。通过一个不等式来推理,请求A的结束时间<请求B的发起时间<取commit index的时间,请求A的index一定小于commt index,所以等待apply到commit index,读取数据一定可以看到请求A的修改。
  2. 为什么leader需要发起一轮心跳?这是为了确保收到这个收到读请求的节点依然是leader,集群没有选出其他leader。假设没有这个步骤,可能出现如下场景:客户端1发起写请求到新leader中并完成写入,客户端2由于路由信息没更新,发起读请求到旧leader中,旧leader中肯定查不到客户端1写入的数据,导致客户端2读取到过期数据。

该方法降低了读数据的吞吐,不需要将读请求也写入日志走一遍raft逻辑,但是还是需要一轮心跳,延迟方面没有优化。那么假如我们可以绕过广播心跳的方式来确认当前节点依然是leader,那就可以直接在leader节点取commit index,然后等待apply,然后回复客户端,这样延迟更低,开销更小,这就是论文中提出的lease read:即每个leader成功发起一轮心跳,收到过半回复后,可以给自己续上一个lease,集群保证在lease时间内,不会出现一个新的leader,那么,处理读请求的时候,直接判断当前leader是否在lease内,如果在,直接取commit index读数据即可。

采用lease read读数据时,leader节点如果在lease内,收到了读请求可以不需要网络通信,直接进行local read,即使这时候leader节点的网络已经被隔离了,无法进行过半节点通信,但是集群中其他节点在lease内也无法选出新主,这样就保证了不会有新数据被写入,这样lease read一定可以读到新数据。

这其实也是一个trade off,lease可能会导致leader宕机时,选出新leader更慢,增加了集群不可用时间。

当然不同节点间时钟有偏差,这个方法有一定的风险。在braft中,leader节点会通过心跳来给自己续lease,假如election timeout是10s,那么leader lease可以设置为9秒,braft leader node会记录向每个follower最后成功发送心跳并收到回复的timestamp(收到回复才算成功,记录的是发送心跳时的timestamp),需要计算lease时,会把所有节点的时间戳排序,取出中间那个值(假如有4个follower,那就会排序后时间戳从小到大依次为t1,t2,t3,t4,那就会去t3,这代表了近期过半通信中最早的时间点),在这个时间基础加上lease,得到一个时间戳,只要本地时间没超过该时间戳,那么就认为自己是leader。follower在收到leader心跳后,会记录本地时间戳,并且给该时间戳加上一个lease,为了防止脑裂,再给改时间戳加一秒钟,follower保证在该时间戳之前不给其他节点投票,这就保证了集群在lease时间内,无法选出新主。(所以braft靠的不是绝对时间,只要节点间时钟流失的速度差别不大,就不会出现问题)

最后还有一个follower read的优化,让从节点也可以支持读请求,步骤如下:

  1. follower收到读请求后,想leader发起一个readIndex请求
  2. leader收到readIndex请求后,将本地commit index,发送给follower
  3. follower收到index后,等待状态机apply到index,执行读请求并回复客户端

分析一下这为什么可以保证线性一致性。请求A的完成时间 < 请求B的发起实时间 < follower 发起readIndex的时间 < leader记录commit index的时间,所以commit index一定大于请求A的log index,请求B一定可以读到最新数据。

5. 效率优化

效率上的优化总共两个点,1是并行落盘,2是batch。
在raft小论文中描述提交日志的流程是,leader收到请求,将日志写入本地,然后复制给follower,过半复制后可以提交该日志,如下图a所示,那么这就需要等待起码两次sync(leader自身落盘,follower落盘)的时间。leader本地日志落盘和将日志发送给follower可以并行执行,反正最后统计复制数量时,过半复制就提交(甚至leader没完成落盘也可以提交,比如有5个节点,leader节点还在落盘过程中已经有3个follower完成复制并回复了,这时候就可以提交了)

在这里插入图片描述
第二点是batch,即发送raft log没必要一条一条的发送,可以一批一批的发送,正常情况下大部分发送日志都会成功的,失败了回滚next index就行。更激进的做法是可以并行发送,或者说 pipeline,不需要等到上次发送的日志收到回复,就可以继续发送后续的日志。这个感觉也没什么可说的,和TCP的滑动窗口很像,具体就看工程实现了。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值