结合raft算法剖析hashicorp-raft源码

1,039 阅读9分钟

摘要

raft算法是著名的一致性算法,hashicorp-raft则是raft算法的go版本实现。除了 通读raft算法 之外,还必须要熟悉hashicorp-raft的源码实现,达到理论和实践相结合。这样在应用raft算法解决我们实际开发过程中遇到的集群一致性问题时才能做到游刃有余。本篇文章我就结合raft算法论文,详细的剖析一下hashicorp-raft的源码实现

NewRaft:

我们先来整体看一下,直接调用hashicorp-raft的NewRaft函数可以创建一个raft实例,需要传递的参数有Config, FSM, LogStore StableStore, SnapshotStore, Transport。

  • FSM就是raft日志commit之后的回调,必须根据具体业务实现相应的FSM
  • LogStore就是raft日志存取的实现,通常情况下用blotDB存取
  • hashicorp-raft默认的使用blotDB存储日志
  • SnapshotStore就是日志快照的相关实现
  • Transport就是hashicorp-raft封装的raft实例之间rpc调用的相关实现
  • StableStore目前主要用来记录当前节点的term,以及当前节点最近一次投票的候选人以及候选人的term等,防止重复投票,一般也是用blotDB存储 这些都是interface,说明都可以自定义自己的实现。除了FSM必须业务自主实现之外,剩余的LogStore, StableStore, SnapshotStore, Transport hashicorp都提供了标准的实现,建议在构建raft实例的时候这些都用标准实现。SnapshotStore 的标准实现是hashicorp-raft中的FileSnapshotStore,直接调NewFileSnapshotStore函数即可创建一个raft中的FileSnapshotStore对象。而Transport的标准实现则是NetworkTransport,具体实现在tcp_transport.go文件中 1654844069318.png raft实例构建完之后,就会启动run,runFSM,runSnapshots三个协程,run里面进行leader,candidate,follower三种角色之间的切换,其中runFSM,runSnapshots没有放在run里面表明这是 每种raft角色都必须有的操作 ,runFSM把commit的日志同步到本地,runSnapshots定时的生成日志快照进行日志压缩 image.png

结合raft算法剖析hashicorp-raft

整体介绍完一个raft实例之后,我们结合raft算法看看每部分在hashicorp-raft中都是如何去实现的。首先必须知道的是,leader在以下几种情况下会rpc到follower或candidate

  1. AppendEntries 即添加日志
  2. 添加或删除节点(也是通过AppendEntries操作)
  3. installSnapshot 即触发follower安装一次快照
  4. heartbeat(通过不携带日志数据的AppendEntries操作发送心跳) follower和follower之间不会相互通信,candidate只有在request vote的时候会和follower相互通信

领导人选举

raft节点刚被启动的时候都是跟随者的身份,我们进到runFollower函数中看follower具体的逻辑。每个follower会随机一个时间,看一段时间内是否有收到leader的contact,如果距离上次contact时间超过了超时时间就进入candidate的判断。每次follower收到leader的心跳,AppendEntries,InstallSnapshot等操作后都会更新和LastContact时间 image.png 接下来进入runCandidate函数看candidate的逻辑,candidate的核心逻辑在electSelf函数里面,这里candidate会首先增加自己的任期号term,然后并发的向各个节点发送投票请求,最后当得票数大于1/2节点数的时候成为leader image.png 这里就像raft算法中说的样,candidate就监听三个事件,(a) 他自己赢得了这次的选举,(b) 其他的服务器成为领导人,(c) 一段时间之后没有任何一个获胜的人 image.png

日志复制

rpc复制

日志复制是leader主动发起的,当一个节点成为leader之后会进入runLeader函数中,然后调用startStopReplication函数,为除leader之外的每个节点启动一个replicate goroutine,并且通过followerReplication对象,来保存每个节点AppendEntries操作的状态 image.png 当我们往leader节点写入数据时,调用的是Raft.Apply函数,把数据包装成logFuture,放入raft实例的applyCh中,如下所示 image.png leader监测到applyCh channel有数据后,调用dispatchLogs函数(可以看到为提高日志的复制效率,这里增加了一个批量提交的逻辑,通过MaxAppendEntries进行控制),在dispatchLogs里面leader首先把日志保存在了本节点的LogStore并且把这些日志放在了leaderState.inflight中,方便后面日志提交的时候快速的进行获取。然后通过triggerCh channel通知每个follower进行复制 image.png image.png 然后我们再进入replicate函数中,看具体是如何进行复制的。在replicate goroutine监测到triggerCh有消息后就开始调用replicateTo函数,在replicateTo函数中可以看到首先是构建AppendEntriesRequest请求参数,然后通过Transport发送rpc调用,对响应成功的请求,会把状态更新到commitment对象中 image.png 在replicateTo我们也可以看到当,AppendEntries操作不成功时,leader会不断的递减重试,直到AppendEntries操作成功为止 image.png 在commitment中,我们可以看到leader是如何判断大多数节点是否复制成功的,这里实现的其实就是raft算法中说的:假设存在 N 满足N > commitIndex,使得大多数的 matchIndex[i] ≥ N以及log[N].term == currentTerm 成立,则令 commitIndex = N ,当大多数节点复制成功后就发消息到commitCh channel中 image.png 当leader监测到commitCh中的消息后,就更新自身的commitIndex,并且从leaderState.inflight队列中(注意并不是从LogStore中捞取,raft为了提高效率直接把已复制了但并未提交的日志保存到inflight队列中)获取当前正在复制中的日志,构建然后在processLogs中把确定需要提交的日志批量发送到了fsmMutateCh channel中,如下所示: image.png 前面在调用NewRaft函数构建raft实例的时候我们知道,每个raft实例都会启动一个叫runFSM的协程,这个协程其实就是监测raft的提交日志的(即fsmMutateCh channel),把fsmMutateCh中的日志读取出来,然后调用我们业务实现FSM 的Apply或ApplyBatch函数,把数据保存到我们具体业务对象中。 image.png 通过以上的操作leader就完成了一次日志commit并更新commitIndex,在下次日志复制的时候把最新的commitIndex带给各个follower节点,follower节点在处理AppendEntries RPC请求的最后会判断commitIndex是否比自己的新,如果是则提交部分日志(具体逻辑如下图)。最终各节点便达到了一致 image.png

流水线复制

在replicate goroutine中我们可以看到这样一层逻辑,当一次rpc AppendEntries操作成功之后,就会开启流水线复制。通过源码可以发现pipeline AppendEntries相比于rpc AppendEntries, 可以复用一个连接不断的发送rpc请求,对于返回的rpc响应,再异步的交给其他线程做校验看是否append成功。而rpc AppendEntries每次都要重新去连接池获取连接或者建立新的连接,发送了AppendEntries操作后还要等follower的响应,校验拿回来的响应是否append成功,这些所有操作都在一个goroutine里面完成,所以相比于pipeline肯定效率比较差。 image.png 流水线复制具体的源码实现就是在pipelineReplicate函数中,通过AppendEntriesPipeline会创建一个pipeline对象(里面有带缓存的channel),并且启动一个run goroutine,当监测到triggerCh中有事件时就会把消息,发送到pipeline的channel中,然后由run消费channel里面的数据,并把响应放到另外的channel中,然后由pipelineDecode不断的消费channel里面的响应,并判断是否成功,成功的话更新到commitment对象中。实现上非常值得学习 image.png

安全性

raft通过在选举和日志复制的过程中加一些额外的限制和措施,保证了这个安全性。下面我们结合论文看看具体实现的代码实现。首先是选举限制

选举限制

在raft算法里面要论证的选举安全性就是,对于被选举出来的leader必定拥有之前任期所有已被提交的日志条目,候选人要想成leader必须获得集群中一半以上节点的投票,而每一个已被提交的日志条目必然存在一半以上的节点上,这两个条件便产生了交集。所以如果候选人成为了leader那么它必定拥有之前任期所有已被提交的日志条目。具体的代码实现就是节点在处理requestVote RPC请求的时候会先做如下校验,校验通过便会投出选票,候选人获得一半以上的投票之后即可安全的成为leader image.png

时间和可用性

这块比较容易理解,直接看代码即可 image.png

集群成员变化

为了保证raft的高可用性,在新增或删除集群成员的时候,raft集群也是安全的。具体raft集群成员的变化也是通过AppendEntries实现各节点之间的同步的,集群成员都保存在configuration里面,具体代码从以下函数入口一步步看即可 image.png

日志压缩

最后再来讲讲保证raft集群高可用的必不可少的实现,即日志压缩。raft通过快照的方式实现日志压缩,在快照之前的日志全部删除,然后把快照最终存储在持久化的存储中。快照中具体保存的就是如下信息: image.png 在raft集群中各节点有两种触发快照的方式,一是每个节点定时的触发(即我们上面提到的runSnapshots协程的逻辑),二是leader通知。对于第二种情况leader在什么情况下会触发呢,其实就是当follower节点太落后于leader节点的时候,就会发送一个快照过去,更新follower节点,具体更新逻辑就看installSnapshot函数吧 image.png

总结

关于hashicorp-raft的源码就结合论文讲到这了。其实对于hashicorp-raft源码的实现,还有非常非常多值得学习的地方,比如多goroutine的管理,future模块的设计,以及transport模块的封装,channel的使用等等,希望读者朋友看完本篇文章之后也不要就止于此,继续把hashicorp-raft源码分析透彻最好