Fabric源码解析之共识算法raft

前提介绍:采用 Raft 的系统最著名的当属etcd(一个高可用的分布式键值数据库),一般认为etcd的核心就是 Raft 算法的实现。作为一个分布式kv系统,etcd 使用Raft在多节点间进行数据同步,每个节点都拥有全量的状态机数据。

更重要的是,Fabric在源码中便将Raft模块的实现命名为etcdraft,这进一步体现出Hyperledger Fabric中的Raft只是对etcd中的Raft做了一层封装来实现联盟链中的节点共识。

———————————————————————————————————————————

Hyperledger Fabric对Raft算法的核心实现代码都是放在fabric/orderer/consensus/etcdraft包下.。

一、介绍几个核心的数据结构

Chain接口Chain结构体node结构体
 

Chain接口

位置:fabric/orderer/consensus/etcdraft/consensus/consensus.go; 

作用:它主要定义了排序节点对接收到的客户端发送来的消息的处理操作;

Chain结构体

位置:fabric/orderer/consensus/etcdraft/chain.go
作用:实现了Chain接口,它里面主要定义了一些通道(channel)用于节点间的通信,以便根据通信消息做相应的操作

node结构体

位置:fabric/orderer/consensus/etcdraft/node.go

作用:主要用于将Fabric自己实现的Raft上层应用和etcd的底层Raft实现连接起来,可以说node结构体是它们之间通信的桥梁,正是它的存在屏蔽了Raft实现的细节。

二、Raft机制的启动过程源码分析

Raft的启动入口位于fabric/orderer/consensus/etcdraft/chain.go文件中。

在Chain的Start()方法中会启动etcdraft/node.go中的node.start(),而node.start()方法中进而启动etcd已经封装好的raft.StartNode()方法。

Chain中的Start()方法

作用:Start方法主要完成了启动etcdraft.Node端的循环来初始化Raft集群节点。而Chain里面通过调用c.run()实现了通过循环处理客户端和Raft底层发送的消息。

etcdraft.Node端的Start方法

它作为Chain端和raft/node端的桥梁,会根据Chain中传递的元数据配置信息获取启动Raft节点的ID信息,并且调用底层的Raft.StartNode方法启动节点,并且像Chain端中一样会启动n.run()来循环处理消息。

最后,在etcdraft/node中启动的raft.StartNode()表示进一步启动了Raft底层的Node节点,在这里会进行Raft的初始化,读取配置启动各个节点以及初始化logindex等。与前面的启动流程一样,它同样会开启一个run方法以循环的方法不断监听各通道的信息来实现状态的切换和做出相应的动作。

三、Raft机制的交易处理流程源码分析

接下来Fabric中的排序节点便可以开始接收交易并开始排序和打包成区块;

1.交易提案的提交

客户端将会把已经背书的交易提案以broadcast请求的形式转发给Raft集群的Leader进行处理;Fabric中的交易可以分为两类,一类是普通交易,另一类是部署交易(也叫做配置交易)。这两类请求将分别调用不同的函数,即Order和Configure函数来完成交易提案的提交。

2. 转发交易提案到Leader

我们从上面的源代码中可以注意到,不论是何种交易类型,里面都会调用Submit方法来提交交易提案。在Submit方法中,主要做的事就是将请求消息封装为结构体并且写入指定的一个通道中(submitC)以便传递给Chain进行处理。此外,它还会判断当前节点是否是Leader,如果不是,还会将消息重定向给Leader节点。

3. 对交易排序

前面也提到了,提案将被转发给Leader,并且消息被封装为消息结构体后写入了submitC通道中传递到了Chain端。Chain端将不断接收交易并将它们进行排序处理。

在ordered方法中,将根据不同类型的消息执行不同的排序操作。对于接收到是通道配置消息,比如通道创建、通道配置更新等。先调用ConsensusSupport对配置消息进行检查和应用,然后直接调用 BlockCutter.Cut() 对报文进行切块,这是因为配置信息都是单独成块;而对于普通交易消息,则直接校验之后,调用 BlockCutter.Ordered() 进入缓存排序,并根据出块规则决定是否出块。

4. 打包区块

交易消息经c.ordered处理之后,会得到由BlockCutter返回的数据包bathches(可打包成块的数据)和缓存是否还有数据的信息。如果缓存还有余留数据未出块,则启动计时器,否则重置计时器,这里的计时器由case timer.C处理。

接下来,将会调用propose方法来打包交易为区块。propose会根据batches数据包调用createNextBlock打包出block ,并将block传递给c.ch通道(只有Leader具有propose的权限)。而如果当前交易是配置信息,还需要标记处当前正在进行配置更新的状态。

5. Raft对区块的共识

Leader将会前面说的区块通过调用c.Node.Propose将数据传递给底层Raft状态机。这里的Propose就是提议将数据写入到各节点的日志中,这里也是实现节点间共识的入口方法。

Propose就是将日志广播出去,要所有节点都尽量保存起来,但还没有提交,等到Leader收到半数以上的节点都响应说已经保存完了,Leader这时就可以提交了,下一次Ready的时候就会带上committedindex。

这里用少量篇幅介绍一下领导者选举的代码剖析【etcd的raft源码】:

可以直接下载ercd包进行剖析  go install go.etcd.io/etcd@latest

当Follower节点发现Leader的心跳超时,会触发etcd/raft/node.go文件中的run函数中的tickc信道。通过调用tickElection函数实现了超时选举的功能。

超时选举函数中调用Step函数,发送MsgHup消息,并调用campaign函数发布竞选消息。在campaign函数中,节点会将自己的Follower状态设置为candidate状态,与此同时递增任期号,最后candidate节点将会向其他节点发送竞选消息。 位置:etcd/raft/raft.go

其他节点通过Step函数实现对竞选消息的判断,并依据相应的判断决定是否给candidate节点投票。其中投票的判断逻辑主要分两步。第一步,如果投票信息中的任期号小于自身的任期号,则直接返回nil,不予投票响应。第二步,通过和本地已存在的最新日志做比较来判断,首先看消息中的任期号是否大于本地最大任期号,如果是则投票,否则如果任期号相同则要求竞选消息中有最大的日志索引。  位置:etcd/raft/raft.go

candidate节点收到其他节点的回复后,判断获取的票数是否超过半数,如果是则设置自身为Leader,否则还是设置为follower,说明本轮竞选领导者失败。

更加详细地描述了Raft的领导者选举流程,如下:

日志复制

上面我们也分析了,对于Leader中生成的块,Leader会调用etcd的Node接口中的Propose方法来提交写日志请求。Propose 内部具体调用stepWithWaitOption实现日志消息的传递,并阻塞/非阻塞地等待结果的返回。

Leader节点调用appendEntry将消息追到Leader的日志之中,但不进行数据的commit。之后调用bcastAppend 将消息广播至其他follower节点。

Follower节点接收到请求后,会调用handleAppendEntries函数来判断是否接受Leader提交的日志。判断逻辑如下:如果Leader提交的日志index小于本地已经提交的日志index则将本地的index回复给Leader。查找追加的日志和本地log的冲突,如果有冲突,则先找到冲突的位置,用Leader的日志从冲突位置开始进行覆盖,日志追加成功后,返回最新的日志index至Leader。如何任期信息不一致,则直接拒绝Leader的追加请求。

当Leader接收到Follower的响应后,针对拒绝和接收的两个场景有不同的处理逻辑,这也是保证follower一致性的关键环节

  1. 当Leader 确认Follower已经接收了日志的append请求后,则调用maybeCommit进行提交,在提交过程中确认各个节点返回的matchindex,排序后取中间值比较,如果中间值比本地的commitindex大,就认为超过半数已经认可此次提交,可以进行commit,之后调用sendAppend向所有节点广播消息,follower接收到请求后调用maybeAppend进行日志的提交。

  2. 如果Follower拒绝Leader的日志append请求。Leader接收到拒绝请求后会进入探测状态,探测follower最新匹配的位置。

结合源码,总结一下Raft的日志复制流程(下次再补);

6. 保存区块

经过Raft共识后,节点需要将区块写入到本地,这里Raft底层会通过通道的方式传递保存区块到本地的消息(即CommittedEntries不为空的消息)。在这里,Fabric通过实现apply方法完成了保存区块的功能。

在apply方法中,如果是普通entry,则会调用writeblock写入区块到本地,如果这个 block 是配置块,则将配置块写入到 orderer 的账本中,同时需要解析出其中的配置信息,看看是否存在 raft 配置项和 raft 节点变动,如果存在变动,则调用 raft 状态机的 ProposeConfChange 应用此变更,应用层也进行相关的信息更新;如果是配置entry,解析出其中的配置更新信息,先调用底层raft 状态机的ApplyConfChange 应用此配置更新。

猜你喜欢

转载自blog.csdn.net/weixin_45270330/article/details/133847153