Raft声称是一种易于理解的分布式一致性算法。有很多工程师们翻了它的论文,参考了不少资料,最后只好怀疑本身是否是智商有问题。java
Raft一直以来是不少高级资深程序员技术上的天花板,捅破至关有难度。每次刚刚拿起时汹涌澎湃,过不了多久便偃旗息鼓了,有一种丧尸般的难受。渴望逃离技术温馨区时会常常经历有这种挫折。在分布式系统领域,Raft就是一道很高的门槛,迈过了这道坎后面技术的自由度就能够再上一个台阶。git
Raft Paper的内容对于一个普通程序员来讲不是太容易理解。选举模块还算比较简单,日志复制表面上也很好理解,快照模块也很形象。可是深刻进去看细节,一头雾水是必然的。特别是对集群节点变动模块的理解更是艰难。程序员
github上找到了一个看起来还不错的开源项目,基于Netty的Raft项目的实现,是百度的工程师开源的。github
https://github.com/wenweihu86/raft-java算法
最近花了一些时间把他的代码通读了一边,发现竟然均可以理解,感受离目标更近了一步。加上以前实现过RPC框架,本身再撸一套Raft应该是能够很快变成现实了。数据库
首先咱们假设有三个RaftNode,每一个RaftNode都会开设一个端口,这个端口的做用就是接受客户端的(Get/Set)请求以及其它RaftNode的RPC请求。这里须要说明的是多数著名开源项目通常会选择两个端口,一个面向客户端,一个面向RPC,好处是能够选择不一样的IP地址,客户端端口能够面向外网,而RPC则是安全的内网通讯。做者选择了一个端口是由于只用于内网,在实现上也会简单很多。安全
客户端能够链接任意一个节点。若是链接的不是Leader,那么发送的请求会在服务端进行转发,从当前链接的RaftNode转发到Leader进行处理。服务器
另一种可选的设计是全部的客户端都链接到Leader,这样就避免了转发的过程,能够提高性能。app
可是服务端转发也有它的好处,那就是当客户端在数据一致性要求很差的状况下,读请求能够不用转发,直接在当前的RaftNode进行处理。因此返回的数据可能不是实时的。这能够挡住大部分客户端请求,提高总体的读性能。框架
RaftNode中包含的重要组件都在这张图上了。
首先Local Server接收到请求后,当即将请求日志附加到SegmentedLog中,这个日志会实时存到文件系统中,同时内存里也会保留一份。由于做者考虑到日志文件过大可能会影响性能和安全性(具体缘由未知,Redis的aof日志咋就不须要分段呢),因此对日志作了分段处理,顺序分红多个文件,因此叫SegmentedLog。
日志有四个重要的索引,分别是firstLogIndex/lastLogIndex和commitIndex/applyIndex,前两个就是当前内存中日志的开始和结束索引位置,后面两个是日志的提交索引和生效索引。之因此是用firstLogIndex而不是直接用零,是由于日志文件若是无限增加会很是庞大,Raft有策略会定时清理久远的日志,因此日志的起始位置并非零。commitIndex指的是过半节点都成功同步的日志的最大位置,这个位置以前的日志都是安全的能够应用到状态机中。Raft会将当前已经commit的日志当即应用到状态机中,这里使用applyIndex来标识当前已经成功应用到状态机的日志索引。
该项目示例提供的状态机是RocksDB。RocksDB提供了高效的键值对存储功能。实际使用时还有不少其它选择,好比使用纯内存的kv或者使用leveldb。纯内存的缺点就是数据库的内容都在内存中,Rocksdb/Leveldb的好处就是能够落盘,减轻内存的压力,性能天然也会有所折损。
若是服务器设置了本地落盘便可返回(isAsyncWrite),那么Local Server将日志塞进SegmentedLog以后就会当即向客户端返回请求成功消息。可是这可能会致使数据安全问题。由于Raft协议要求必须等待过半服务器成功响应后才能够认为数据是安全的,才能够告知客户端请求成功。之因此提供了这样一个配置项,纯粹是为了性能考虑。分布式数据库Kafka一样也有相似的选项。是经过牺牲数据一致性来提升性能的折中方法。
正常状况下,Local Server经过一个Condition变量的await操做悬挂住当前的请求不予返回。
对于每一个RPCClient,它也要维护日志的两个索引,一个是matchIndex表示对方节点已经成功同步的位置,能够理解为局部的commitIndex。而nextIndex就是下一个要同步的日志索引位置。随着Leader和Follower之间的消息同步,matchIndex会努力追平nextIndex。一样随着客户端的请求的连续到来,nextIndex也会持续前进。
Local Server在悬挂住用户的请求后,会当即发出一次异步日志同步操做。它会经过RPC Client向其它节点发送一个AppendEntries消息(也是心跳消息),包含当前还没有同步的全部日志数据(从commitIndex到lastLogIndex)。而后等待对方实时反馈。若是反馈成功,就能够前进当前的日志同步位置matchIndex。
matchIndex是每一个RPCClient局部的位置,当有过半RPCClient的matchIndex都前进了,那么全局的commitIndex也就能够随以前进,取过半节点的matchIndex最小值便可。
commitIndex一旦前进,意味着前面的日志都已经成功提交了,那么悬挂的客户端也能够继续下去了。因此当即经过Condition变量的signalAll操做唤醒全部正在悬挂住的请求,通知它们立刻给客户端响应。
注意日志同步时还得看节点日志是否落后太多,若是落后太多,经过AppendEntries这种方式同步是比较慢的,这时就是要考虑走另外一条路线来同步日志,那就是snapshot快照同步。
RaftNode会定时进行快照,将当前的状态机内容序列化到文件系统中,而后清理久远的SegmentedLog日志,给Raft的请求日志瘦身。
快照同步就是Leader将最新的快照文件发送到Follower节点,Follower安装快照后成功后,Leader继续同步SegmentedLog,力图让Follower追平本身。
RaftNode启动的第一步是加载SegmentedLog,再加载最新的Snapshot造成状态机的初始状态。紧接着使用RPCClient去链接其它节点,开启snapshot定时任务。随之正式进入选举流程。
RaftNode初始是处于Follower状态,启动后当即开启一个startNewElection定时器,在这个定时器到点以前若是没有收到来自Leader的心跳消息或者其它Candidate的请求投票消息,就当即变身成为Candidate,发起新一轮选举过程。
RaftNode变成Candidate后,会向其它节点发送一个请求投票(requestVote)的消息,而后当即开启一个startElection定时器,在这个定时器到点以前RaftNode若是没有变身Follower或者Leader就会当即再次发起一轮新的选举。
RaftNode处于Candidate状态时,若是收到来自Leader的心跳消息,就会当即变身为Follower。若是发出去的投票请求获得了半数节点的成功回应,就会当即变身为Leader,并周期性地向其它节点广播心跳消息,以尽量长期维持本身的统治地位。
并非任意一个节点均可以变成Leader。若是要当Leader,这个节点包含的日志必须最全。Candidate经过RequestVote消息拉票的时候,须要携带当前日志列表的lastLogIndex和相应日志的term值(尾部日志的term和index)。 其它节点须要和这两个值进行匹配,凡是没本身新的拉票请求都拒绝。简单一点说,组里最牛逼的节点才能够当领导。
Leader发生切换的时候,新Leader的日志和Follower的日志可能会存在不一致的情形。这时Follower须要对自身的日志进行截断处理,再从截断的位置从新同步日志。Leader自身的日志是Append-Only的,它永远不会抹掉自身的任何日志。
标准的策略是Leader当选后当即向全部节点广播AppendEntries消息,携带最后一条日志的信息。Follower收到消息后和本身的日志进行比对,若是最后一条日志和本身的不匹配就回绝Leader。
Leader被打击后,就会开始回退一步,携带最后两条日志,从新向拒绝本身的Follower发送AppendEntries消息。若是Follower发现消息中两条日志的第一条仍是和本身的不匹配,那就继续拒绝,而后Leader被打击后继续后退重试。若是匹配的话,那么就把消息中的日志项覆盖掉本地的日志,因而同步就成功了,一致性就实现了。
集群配置变动多是Raft算法里最复杂的一个模块。为了理解这个模块我也是费了九牛二虎之力。看了不少文章后发现这些做者们实际上都没深刻理解这个集群成员变化算法,不过是把论文中说的拷贝了一遍。我相信它们最多只把Raft实现了一半,完整的整个算法若是没有对细节精致地把握那是难以写出来的。
分布式系统的一个很是头疼的问题就是一样的动做发生的时间却不同。好比上图的集群从3个变成5个,集群的配置从OldConfig变成NewConfig,这些节点配置转变的时间并不彻底同样,存在必定的误差,因而就造成了新旧配置的叠加态。
在图中红色剪头的时间点,旧配置的集群下Server[1,2]能够选举Server1为Leader,Server3不一样意不要紧,过半就行。而一样的时间,新配置的集群下Server[3,4,5]则能够选举出Server5为另一个Leader。这时候就存在多Leader并存问题。
为了不这个问题,Raft使用单节点变动算法。一次只容许变更一个节点,而且要按顺序变动,不容许并行交叉,不然会出现混乱。若是你想从3个节点变成5个节点,那就先变成4节点,再变成5节点。变动单节点的好处是集群不会分裂,不会同时存在两个Leader。
如图所示,蓝色圈圈表明旧配置的大多数(majority),红色圈圈代码新配置的带多数。新旧配置下两个集群的大多数必然会重叠(旧配置节点数2k的大多数是k+1,新配置节点数2k+1的大多数是k+1,两个集群的大多数之和是2k+2大于集群的节点数2k+1)。这两个集群的term确定不同,而同一个节点不可能有两个term。因此这个重叠的节点只会属于一个大多数,最终也就只会存在一个集群,也就只有一个Leader。
集群变动操做日志不一样于普通日志。普通日志要等到commit以后才能够apply到状态机,而集群变动日志在leader将日志追加持久化后,就能够当即apply。为何要这么作,能够参考知乎孙建良的这篇文章 https://zhuanlan.zhihu.com/p/29678067,这里面精细描述了commit & reply集群变动日志带来的集群不可用的场景。
文章是写完了,可是感受仍是有点懵,总以为有不少细枝末节尚未搞清楚。另外就是开始以为百度实现的这个Raft应该很完善,深刻了解以后发现原来仍是有不少不完善之处,这个项目应该只是一个demo。后续还得继续研究etcd的raft代码,它的代码要复杂一些,可是应该要完善不少。它具有prevote流程和Leader提交以后的no-op日志同步,这些是raft-java项目欠缺的地方所在。
关注公众号「码洞」,一块儿进阶Raft协议