GarrettWale

引言

Hyperledger Fabric是当前比较流行的一种联盟链系统,它隶属于Linux基金会在2015年创建的超级账本项目且是这个项目最重要的一个子项目。目前,与Hyperledger的另外几个子项目Hyperledger Iroha,Hyperledger Indy和Hyperledger Sawtooth一样,Hyperledger Fabric正处于生命周期中的活跃(active)阶段,它的架构设计正在不断地完善并持续为开发者和用户提供更强大,更便捷的区块链服务。

与主流的区块链系统一样,Hyperledger Fabric实际上也是个去中心化的分布式账本,其总账上的数据和交易记录由网络中的多方节点共同维护,而且账本上的记录一旦被写入将永远无法被篡改,同时支持基于时间戳的交易追溯。然而,与当前成熟的比特币,以太坊等公有链系统不同的是,Hyperledger Fabric是一种联盟链系统,它的去中心化程度是受限的,即它只允许被授权的节点加入到区块链网络中。更多地,Fabric还提供了创建通道的功能来进一步满足不同联盟方的实际需求,这进一步提高了区块链系统的安全性和私密性。

区块链系统中的交易处理和共识模块是一个核心功能,它们为实现区块链的主体功能发挥了核心作用。在接下来的内容中,为了让读者更好地了解Fabric中的共识模块,我们首先简要介绍了Hyperledger Fabric的架构设计,接着详细分析了Fabric中的交易流程,最后结合Hyperledger Fabric的源码来深入了解Raft共识算法及其在区块链系统中的具体实现。

Hyperledger Fabric架构简介

在Hyperledger Fabric系统的架构中,它引以为豪的一项设计就是采用了模块化的设计思想,并且支持可插拔的组件开发。

具体地,Fabric的架构主要包括三个重要的组成模块,即成员服务,区块链服务以及合约代码服务。以下将详细介绍每个模块包含的功能以及设计原理。

a) 成员服务模块:成员服务之所以会单独划分为一个模块主要是考虑到联盟链的特殊性,即每个节点在进入区块链系统之前都需要经过身份验证,只有通过验证的节点才能参与到系统中。成员服务提供成员的注册,身份管理以及认证功能,保证了系统的安全性,便利了节点的权限管理。

b) 区块链服务模块:无论是在公有链系统还是在联盟链系统,区块链服务始终作为区块链系统的核心组成部分,为区块链主体功能提供底层的服务支撑。具体地,该模块主要承担了节点间的共识管理,账本的分布式存储,去中心化网络协议的具体实现等任务。

c) 合约代码服务模块:该模块也不是Fabric系统独有的,很多系统如以太坊等都具备部署智能合约的功能。在Hyperledger Fabric系统中,智能合约在Docker容器中运行,所以该模块提供了一个智能合约的执行引擎为合约代码程序提供了一个强大,便捷的部署运行环境。

根据以上的模块划分,Fabric的详细架构如下图1.1所示。

image-20220411201941787

图1.1 Hyperledger Fabric架构图

Hyperledger Fabric中的交易流程介绍

在Hyperledger Fabric系统中,所谓的交易就是一次合约代码的调用过程,包含两种类型的交易,即部署交易和调用交易。

部署交易主要用来在Hyperledger Fabric区块链中安装合约代码。具体地,它使用一个程序作为参数创建新的合约代码,然后执行部署以完成合约的安装。而调用交易简单来说就是执行合约代码,当成功地执行调用交易后,可以相应地修改账本的状态,并且为客户端返回输出结果。不管是部署交易还是调用交易,只要在区块链系统中执行后都会被打包成区块,区块链接在一起就组成了分布式账本的区块链。

Hyperledger Fabric区块链系统中,交易主要可以分为三个阶段,分别是提议阶段,排序打包阶段以及验证提交阶段。这里每个阶段参与的节点的类型略有不同,设计到的技术原理也不同。具体地,下图2.1详细地描述了Hyperledger Fabric中的交易流程。

image-20220411200222040

图2.1 Hyperledger Fabric中的交易流程

2.1 提议阶段

在Fabric的第一阶段中,主要的工作流程是客户端节点提交交易提案、背书节点模拟执行链码、背书节点为交易提案进行背书、背书节点返回背书结果给客户端。

具体地,提议阶段主要可以细分为以下几个步骤:

  1. 客户端首先构建交易的提案,提案的作用是调用通道中的链码来读取或者将数据写入分布式账本。客户端打包交易提案,并使用用户的私钥对提案进行签名。

  2. 应用端打包完交易提案后,便开始把提案提交给通道中的背书节点。通道的背书策略定义了哪些节点背书后交易才能有效,应用端根据背书策略选择相应的背书节点,并向它们提交交易提案。

  3. 背书节点收到交易提案后,首先校验交易的签名是否合法,然后根据签名者的身份,确认其是否具有权限进行相关交易。此外,背书节点还需要检查交易提案的格式是否正确以及是否之前提交过,这样做的目的是防止重放攻击。

  4. 在所有合法性校验通过后,背书节点按照交易提案,模拟调用链码。链码模拟执行时,读取的键值对数据是节点中本地的状态数据库。需要注意的是,链码在背书节点中是模拟执行,即对数据库的写操作并不会对账本作改变。

  5. 在链码模拟执行完成之后,将返回模拟执行的返回值、链码读取过的数据集和链码写入的数据集。读操作集合和写操作集合将在确认节点中用于确定交易是否最终写入账本。

  6. 背书节点把链码模拟执行后得到的读写集等信息使用其私钥进行签名(背书签名)后发回给提案提交方即客户端。

image-20220411200235712

图2.2 交易流程之提议阶段

2.2 排序和打包交易阶段

一般地,等客户端收集到足够多的背书节点返回的响应提案的背书响应后,客户端便会将交易提案、读写集和背书签名等发送给排序节点。排序节点将会对自己接收到的交易信息按照通道分类进行排序,且打包成区块。

排序和打包交易阶段可以细分为以下几个子阶段:

  1. 客户端收到背书响应之后,检查背书节点的签名和比较不同节点背书的结果是否一致。如果提案是查询账本的请求,则客户端无需提交交易给排序节点。如果是更新账本的请求,客户端在收集到满足背书策略的背书响应数量之后,把背书提案中得到的读写集、所有背书节点的签名和通道号发给排序节点。

  2. 排序节点在收到各个节点发来的交易后,并不检查交易的全部内容,而是按照交易中的通道号对交易分类排序,然后把相同通道的一批交易打包成区块。

  3. 排序节点把打包好的区块广播给通道中的所有成员。区块的广播有两种触发条件,一种是当通道的交易数量达到某个预设的阈值,另一种是在交易数量没有超过阈值但距离上次广播的时间超过某个特定阈值,也可触发广播数据块。两种方式相结合,使得排序过的交易可以及时广播出去。

image-20220411200247216

图2.3 交易流程之排序和打包区块阶段

2.3 验证和提交阶段

最后,对于验证和提交阶段来说,担负的职责就是验证其收到的区块,即验证区块中的背书签名以及验证交易的有效性。验证成功后,Peer节点将更新账本和世界状态。

验证和提交阶段的详细工作流如下:

  1. 节点收到排序节点发来的交易数据块后,逐笔检查区块中的交易。先检查交易的合法性以及该交易是否曾经出现过。然后调用 VSCC(Validation System Chaincode)的系统链码检验交易的背书签名是否合法,以及背书的数量是否满足背书策略的要求。

  2. 接下来进行多版本并发控制 MVCC 的检查,即校验交易的读集是否和当前账本中的版本一致。如果没有改变,说明交易写集中对数据的修改有效,把该交易标注为有效,交易的写集更新到状态数据库中。

  3. 如果当前账本的数据和读集版本不一致,则该交易被标注为无效,不更新状态数据库。数据块中的交易数据在标注成"有效"或"无效"后封装成区块写入账本的区块链中。

  4. 最后,节点会通过事件机制通知客户端交易是否已经被加入区块链以及交易是否有效。

image-20220411200256709

图2.4 交易流程之验证和提交阶段

Hyperledger Fabric中的共识算法及其源码分析

对于Hyperledger Fabric系统来说,前一节分析的整个交易流程就是共识,通过这个交易处理流程,所有的Peer节点在由排序节点提供的流程中对交易的排序和根据交易打包成的区块达成了一致。因此,我们可以知道排序服务是共识机制中最重要的一环,所有的交易都需要通过排序服务后才能达成全网节点的共识。

Hyperledger Fabric 的网络节点本质上是互相复制的状态机,节点之间需要保持相同的账本状态。为了实现这个目的,各个节点需要通过共识过程,对账本状态的变化达成一致性的认同。

如何实现所有节点的共识可以说是去中心化的区块链系统所面临的最重要的问题之一,而共识机制又被称为"区块链的灵魂"。所以,针对不同的区块链系统选择合适的共识算法对于分布式系统保持一致性是至关重要的。

在区块链领域,使用的比较多的共识算法有大名鼎鼎的PoW共识算法,PoS和DPoS等权益证明算法,以及PBFT,RAFT等共识算法。对于Hyperledger Fabric这类联盟链系统,PoW和PoS等算法并不适用,因为这类算法实现共识的本质都是挖矿。虽然这类算法具备完全去中心化和节点*进出的优点,但是由于挖矿需要耗费大量的电力和CPU资源以及达成共识的周期很长,并不适用于商业的区块链应用。因此,与现在大部分的联盟链系统一样,Hyperledger Fabric也将目光聚集在PBFT和RAFT等共识算法上。

Fabric的共识服务设计成了可插拔的模块,以此满足了根据不同应用场景切换不同共识选项的需求。在Hyperledger Fabric最新版本中,Fabric系统的共识模块中实现了三种共识算法,其中包括Solo,Kafka以及Raft算法。官方推荐的是使用Raft共识算法,但是为了更好地理解Fabric中的共识模块,我们也简单介绍一下Solo和Kafka这两种共识算法。

  1. solo共识:假设网络环境中只有一个排序节点,从Peer节点发送来的消息由一个排序节点进行排序和产生区块。由于排序服务只有一个排序节点为所有的peer节点服务,虽然可以肯定保证顺序一致性,但是没有高可用性和可扩展性,所以不适合用于生产环境,只能用于开发和测试环境。

  2. Kafka共识:Kafka是一个分布式的流式信息处理平台,目标是为实时数据提供统一的、高吞吐、低延迟的性能。Hyperledger Fabric之前版本的核心共识算法通过Kafka集群实现,简单来说,就是通过Kafka对所有交易信息进行排序(如果系统存在多个通道,则对每个通道分别排序)。

  3. Raft共识:Raft是Hyperledger Fabric在1.4.1版本中引入的,它是一种基于 etcd 的崩溃容错(CFT)排序服务。Raft 遵循 "领导者和追随者" 模型,其中领导者在通道中的排序节点之间动态选出(这个节点集合称为"consenter set"),该领导者将消息复制到跟随者节点。Raft保证即使在小部分(≤ (N-1)/2)节点故障的情况下,系统仍然能正常对外提供服务,所以Raft被称为"崩溃容错"。

其实,Hyperledger Fabric在1.4.1版本以前,它的核心共识算法通过Kafka集群实现,但是在1.4.1版本之后,Fabric推荐使用Raft算法实现节点的共识。其实从提供服务的视角来看,基于Raft和Kafka的排序服务是类似的,他们都是基于CFT(crash fault tolerant)模型的排序服务,并且都使用了主从节点的设置。但是为什么Hyperledger Fabric选择Raft算法呢?我们列举了Raft相较于Kafka所展现出的优势来回答这个问题。

a. 第一点,Raft 更容易设置。虽然 Kafka 有很多崇拜者,但即使是那些崇拜者也(通常)会承认部署 Kafka 集群及其所必须的 ZooKeeper 集群会很棘手,需要在 Kafka 基础设施和设置方面拥有高水平的专业知识。此外,使用 Kafka 管理的组件比使用 Raft 管理的组件多,Kafka 有自己的版本,必须与排序节点协调。而使用 Raft,所有内容都会嵌入到排序节点中。

b. 第二点,Kafka和zookeeper的设计不适用于大型网络。它们的设计是CFT模型,但局限于运行的比较紧密的主机上。也就是说,需要有一个组织专门运行Kafka集群。鉴于此,当有多个组织使用基于Kafka排序服务的时候,其实没有实现去中心化,因为所有的节点连接的都是由一个组织单独控制的Kafka集群。如果使用Raft算法,每个组织可以贡献排序节点,共同组成排序服务,可以更好的去中心化。

c. 第三点,Raft是原生支持的,而Kafka需要经过复杂的步骤部署,并且需要单独学习成本。而且Kafka和Zookeeper的支持相关的issue要通过apache来处理,而不是Hyperledger Fabric。Raft的实现是包含在Fabric社区的,开发支持更加便利。

d. 第四点,Raft 是向开发拜占庭容错(BFT)排序服务迈出的第一步。正如我们将看到的,Fabric 开发中的一些决策是由这个驱动的。Fabric使用Raft共识算法是向BFT类算法过渡的步骤。

鉴于以上对Kafka和Raft的分析比较,再考虑到目前最新版的Fabric中推荐采用的共识算法,我们以下将详细分析Raft共识算法,而不是之前版本使用的Kafka。具体地,在3.1节中我将从理论角度阐述Raft共识算法及共识流程,而在3.2节我则会从Fabric源码角度来进一步深入分析Raft算法的实现及其与Fabric交易流程的整合。

3.1 Raft算法理论

在分布式系统中,为了消除单点故障提高系统可用性,通常会使用副本来进行容错,但这会带来另一个问题,即如何保证多个副本之间的一致性?而所谓的一致性并不是指集群中所有节点在任一时刻的状态必须完全一致,而是指一个目标,即让一个分布式系统看起来只有一个数据副本,并且读写操作都是原子的,这样应用层就可以忽略系统底层多个数据副本间的同步问题。也就是说,我们可以将一个强一致性分布式系统当成一个整体,一旦某个客户端成功的执行了写操作,那么所有客户端都一定能读出刚刚写入的值。即使发生网络分区故障,或者少部分节点发生异常,整个集群依然能够像单机一样提供服务。

为了实现一致性的目的,共识算法基于状态复制机模型来建模,所有的节点从一个相同的状态(state)出发,经过相同的操作日志,最终达到一致的状态。在众多的共识算法中,Paxos算法可以说是一个最经典的共识算法,也是公认的可以实现有效共识的算法。然而,由于Paxos却很少在实际架构中应用,因为它难以理解,更加难以实现,作者为了让读者更好地理解Paxos算法,甚至为此专门发表了论文进行进一步解释。其次,Paxos没有提供一个足够好的用来构建一个现实系统的基础,而且它也并不是十分易于构建实践性的系统。因此,现在的分布式系统通常使用Paxos的一种变种共识算法,即Raft算法,它的优点是容易理解,容易实现,这使得Raft得到更广泛的普及和应用。

3.1.1 基本概念

Raft 使用 Quorum 机制(一种集群一致性和可用性之间的权衡机制)来实现共识和容错,我们将对 Raft 集群的操作称为提案(提案可以简单理解为对集群的读写操作),每当发起一个提案,必须得到大多数(> N/2)节点的同意才能提交。

接下来,我们详细介绍下Raft中涉及到的一些关键概念以及术语。

  1. Leader:Leader负责提取新的日志条目,将它们复制到跟随者订购节点,以及管理何时认为条目已提交。在Hyperledger Fabric中,其中一个排序节点将担任Leader。

  2. Follower:Follower从Leader那里接收日志并确定性地复制它们,确保日志保持一致。Follower也会收到来自Leader的"心跳"信息。如果Leader停止在可配置的时间内发送这些消息,Follower将转换为候选状态。

  3. 候选状态(candidate):处于候选状态的节点会发起选举,如果它收到集群中大多数成员的投票认可,就转换为Leader。

  4. 日志条目:Raft排序服务中的主要工作单元是"日志条目",这些条目的完整序列称为"日志"。如果成员的多数(法定人数,换言之)成员到条目及其顺序达成一致,我们认为日志是一致的。

  5. 有限状态机(FSM):Raft中的每个排序节点都有一个FSM,它们共同用于确保各个排序节点中的日志序列是确定性的(以相同的顺序编写)。

  6. Consenter设置:排序节点主动参与给定信道的共识机制并接收信道的复制日志。这可以是所有可用节点(在单个群集中或在对系统通道有贡献的多个群集中),或者是这些节点的子集。

  7. 法定人数:描述需要确认提案的最少数量的同意者,以便可以提交交易。对于每个consenter集,这是大多数节点。在具有五个节点的群集中,必须有三个节点才能存在仲裁。如果由于任何原因导致法定数量的节点不可用,则排序节点将无法用于通道上的读取和写入操作,并且不能提交新日志。

  8. 任期:每开始一次新的选举,称为一个任期(term),每个 term 都有一个严格递增的整数与之关联。每当 candidate 触发*选举时都会增加 term,如果一个 candidate 赢得选举,他将在本任期中担任 Leader 的角色。但并不是每个任期都一定对应一个Leader,有时候某个任期内会由于选举超时导致选不出 Leader,这时candidate会递增任期号并开始新一轮选举。

在了解了Raft中的基本概念后,我们再来简单了解一下Raft算法的运行过程。

首先,Raft 集群必须存在一个主节点(Leader),没有主节点集群就无法工作,我们作为客户端向集群发起的所有操作都必须经由主节点处理。所以 Raft 核心算法中的第一部分就是*选举,先票选出一个主节点,再考虑其它事情。其次,主节点负责接收客户端发过来的操作请求,将操作包装为日志同步给其它节点,在保证大部分节点都同步了本次操作后,就可以安全地给客户端回应响应了。这在 Raft中被叫做日志复制。然后,因为主节点的责任是如此之大,所以节点们在*选举的时候一定要谨慎,只有符合条件的节点才可以当选主节点。此外主节点在处理操作日志的时候也一定要谨慎,为了保证集群对外展现的一致性,不可以覆盖或删除前任主节点已经处理成功的操作日志。所谓的"谨慎处理",其实就是在选主和提交日志的时候进行一些限制,这一部分在 Raft 共识算法中叫安全性保证。

Raft 核心算法其实就是由这三个子问题组成的:*选举、日志复制、安全性。这三部分共同实现了 Raft 核心的共识和容错机制。

3.1.2 *选举

Raft集群中每个节点都处于Leader,Follower和Candidate三种角色之一。在*选举的过程中,节点的这些状态将随着选举场景的不同而发生切换。接下来,我们将详细剖析*选举的流程。

Raft 的*选举基于一种心跳机制,集群中每个节点刚启动时都是 Follower 身份,Leader 会周期性的向所有节点发送心跳包来维持自己的权威,那么首个 Leader 是如何被选举出来的呢?方法是如果一个 Follower 在一段时间内没有收到任何心跳,也就是选举超时,那么它就会主观认为系统中没有可用的 Leader,并发起新的选举。

这里有一个问题,即这个"选举超时时间"该如何制定?如果所有节点在同一时刻启动,经过同样的超时时间后同时发起选举,整个集群会变得低效不堪,极端情况下甚至会一直选不出一个主节点。Raft 巧妙的使用了一个随机化的定时器,让每个节点的"超时时间"在一定范围内随机生成,这样就大大的降低了多个节点同时发起选举的可能性。

若Follower想发起一次选举,Follower需要先增加自己的当前任期,并将身份切换为candidate。然后它会向集群其它节点发送"请给自己投票"的消息。在此之后,系统中会出现三种可能的结果。

第一种,当前candidate节点选举成功。当candidate从整个集群的大多数(N/2+1)节点获得了针对同一任期的选票时,它就赢得了这次选举,立刻将自己的身份转变为Leader 并开始向其它节点发送心跳来维持自己的权威。每个节点针对每个任期只能投出一张票,并且按照先到先得的原则。这个规则确保只有一个 candidate会成为Leader。

第二种,当前candidate节点选举失败。candidate 在等待投票回复的时候,可能会突然收到其它自称是Leader 的节点发送的心跳包,如果这个心跳包里携带的任期号不小于 candidate 当前的任期号,那么candidate 会承认这个Leader,并将身份切回 Follower。这说明其它节点已经成功赢得了选举,我们只需立刻跟随即可。但如果心跳包中的任期号比自己小,candidate会拒绝这次请求并保持选举状态。

第三种,选举超时。如果有多个Follower 同时成为 candidate,选票是可能被瓜分的,如果没有任何一个candidate 能得到大多数节点的支持,那么每一个 candidate都会超时。此时candidate 需要增加自己的任期号,然后发起新一轮选举。如果这里不做一些特殊处理,选票可能会一直被瓜分,导致选不出Leader来。这里的"特殊处理"指的就是前文所述的随机化选举超时时间。

3.1.3 日志复制

前面我们也提到了,Raft共识算法是基于状态复制机(RPM)模型实现的,也就是说Raft需要保证集群中所有节点的日志log一致。在Raft模型中,Leader节点承担了领导集群的任务,所有的日志都需要先交给Leader节点处理,并由Leader节点复制给其他节点(Follower),这个处理过程被称为日志复制。

一旦Leader被集群中的节点选择出来,它就开始接收客户端请求,并将操作包装成日志,并复制到其它节点上去。日志复制的整体流程如下:

  1. Leader为客户端提供服务,客户端的每个请求都包含一条即将被RPM执行的指令。

  2. Leader把该指令作为一条新的日志附加到自身的日志集合,然后向其它节点发起附加条目请求,来要求它们将这条日志附加到各自本地的日志集合。

  3. 当这条日志已经确保被安全的复制,即大多数(N/2+1)节点都已经复制后,Leader 会将该日志追加到它本地的状态机中,然后把操作成功的结果返回给客户端。

各节点的每条日志除了存储状态机的操作指令外,还会拥有一个index值被用来表明它在日志集合中的位置。此外,每条日志还会存储一个任期号,该任期号表示Leader收到这条指令时的当前任期,任期号相同的日志条目是由同一个Leader在其任期内发送的。当一条日志被Leader节点认为可以安全的应用到状态机时,称这条日志是committed。那么什么样的日志可以被 commit 呢?只有当Leader 得知这条日志被集群过半的节点复制成功时,这条日志才可以被commit。Raft 保证所有 committed 日志都已经被持久化,且"最终"一定会被状态机apply。

当集群中各节点都正常工作的时候,Raft算法的这种日志复制机制可以保证一致性,那么当节点可能出现宕机等特殊情况下,Raft又是如何保持集群日志一致的呢?

Raft对于当节点出现意外情况宕机后出现的不一致问题也是有解决方法,但是这需要遵循一些规则。其中,最重要的一条就是,Raft 强制要求Follower必须复制Leader的日志集合来解决不一致问题。换句话说,Follower节点上任何与Leader不一致的日志,都会被Leader节点上的日志所强制覆盖。这并不会产生什么问题,因为某些选举上的限制,如果Follower上的日志与Leader不一致,那么该日志在Follower上一定是未提交的,而未提交的日志并不会应用到状态机,当然也不会被外部的客户端感知到。

要使得Follower的日志集合跟自己保持完全一致,Leader 必须先找到二者间最后一次达成一致的地方。因为一旦这条日志达成一致,在这之前的日志一定也都一致。这个确认操作是在一致性检查步骤完成的。Leader 针对每个Follower 都维护一个next index,表示下一条需要发送给该Follower的日志索引。当一个 Leader 刚刚上任时,它初始化所有next index值为自己最后一条日志的index+1。但凡某个Follower的日志跟Leader不一致,那么下次日志复制时的一致性检查就会失败。在被Follower 拒绝这次日志复制请求后,Leader会减少next index的值并进行重试。最终一定会存在一个next index使得Leader和Follower在这之前的日志都保持一致。

针对每个Follower,一旦确定了next index的值,Leader便开始从该 index 同步日志,follower会删除掉现存的不一致的日志,保留Leader 最新同步过来的。整个集群的日志会在这个简单的机制下自动趋于一致。此外要注意,Leader 从来不会覆盖或者删除自己的日志,而是强制Follower与它保持一致。

3.1.4 安全性保证

前一节也提到了,为了保证集群的日志一致性,Raft 强制要求Follower必须复制Leader的日志集合来解决不一致问题。这样做的前提是需要保证每一轮选举出来的Leader具备"日志的正确性",这也就是前面着重强调的"选举上的限制"。

我们假设有以下的场景:

  • Leader 将一些日志复制到了大多数节点上,进行commit后发生了宕机。

  • 某个Follower 并没有被复制到这些日志,但它参与选举并当选了下一任 leader。

  • 新的Leader又同步并commit了一些日志,这些日志覆盖掉了其它节点上的上一任committed日志。

  • 各个节点的状态机可能apply了不同的日志序列,出现了不一致的情况。

仅仅依靠前面两个小节提到的*选举和日志复制机制并不能保证在这种情况下的节点一致性。为了解决这类问题,Raft加上了一些额外的限制来保证状态机的安全性和共识算法的准确性。

Raft首先采取的一个措施就是增加了对选举的限制。我们再来分析下前文所述的场景,根本问题其实发生在第2步。candidate 必须有足够的资格才能当选Leader,否则它就会给集群带来不可预料的错误。这需要增加一个判断,即每个 candidate 必须在竞选投票请求中携带自己本地日志的最新 (term, index),如果 Follower发现这个candidate 的日志还没有自己的新,则拒绝投票给该candidate。candidate想要赢得选举成为Leader,必须得到集群大多数节点的投票,那么它的日志就一定至少不落后于大多数节点。又因为一条日志只有复制到了大多数节点才能被commit,因此能赢得选举的candidate一定拥有所有committed日志。而比较两个 (term, index) 的逻辑非常简单:如果任期号不同则任期号更大的日志更新,否则index大的日志更新。

其次,Raft规定了Leader只允许commit包含当前任期号的日志。所谓 commit 其实就是对日志简单进行一个标记,表明其可以被 apply 到状态机,并针对相应的客户端请求进行响应。之所以有这个限制,Raft主要考虑到以下的场景:

image-20220411200321469

图3.1 Leader对不含当前任期号的日志进行commit引发的异常情况

a) S1是Leader,收到请求后将 (term2, index2) 只复制给了S2,尚未复制给S3 ~ S5。

b) S1宕机,S5当选term3的Leader(S3、S4、S5 三票),收到请求后保存了 (term3, index2),尚未复制给任何节点。

c) S5 宕机,S1恢复,S1重新当选term4的Leader,继续将 (term2, index2) 复制给了 S3,已经满足大多数节点,我们将其 commit。

d) S1又宕机,S5 恢复,S5 重新当选Leader(S2、S3、S4 三票),将 (term3, inde2) 复制给了所有节点并 commit。注意,此时发生了致命错误,已经 committed 的 (term2, index2) 被 (term3, index2) 覆盖了。

在上述场景中,问题的根源发生在阶段c,即使作为term4 leader 的 S1 将 (term2, index2) 复制给了大多数节点,它也不能直接将其commit,而是必须等待term4的日志到来并成功复制后,一并进行commit。

为了解决这个问题,Raft规定了Leader只允许commit包含当前任期号的日志。在增加了这条限制后,我们再来看阶段e。

e) 在添加了这个限制后,要么 (term2, index2) 始终没有被 commit,这样S5 在阶段d将其覆盖就是安全的;要么 (term2, index2) 同 (term4, index3) 一起被 commit,这样 S5 根本就无法当选 leader,因为大多数节点的日志都比它新,也就不存在上图中出现的问题了。

在对Raft共识算法增加了这两个限制后,状态机的安全性得到了极大的保证,更有效地实现了集群日志的一致性。

3.2 Fabric中Raft算法的源码分析

其实,采用 Raft 的系统最著名的当属etcd(一个高可用的分布式键值数据库),一般认为etcd的核心就是 Raft 算法的实现。作为一个分布式kv系统,etcd 使用Raft在多节点间进行数据同步,每个节点都拥有全量的状态机数据。值得说明的是,Hyperledger Fabric对于Raft共识算法的实现也是参考或者说基于etcd中已经实现的Raft算法,这一点在Fabric的源码中也可以得到充分的体现。更重要的是,Fabric在源码中便将Raft模块的实现命名为etcdraft,这进一步体现出Hyperledger Fabric中的Raft只是对etcd中的Raft做了一层封装来实现联盟链中的节点共识。

接下来,我将详细介绍Hyperledger Fabric中对Raft共识算法的实现与封装细节,从这里我们也可以进一步了解到Raft算法的细节。

3.2.1 Fabric中Raft源码的核心数据结构

从Fabric中的源码可以窥见,其底层调用了etcd已经实现的成熟的Raft算法作为Fabric*识算法的核心。etcd中的Raft实现了领导者选举,日志复制等核心操作,而把应用层相关的操作如节点间的通信以及存储等交给上层应用层也就是这里的Hyperledger Fabric。

我们首先来看etcd/raft中涉及到的核心数据结构:Node接口和node结构体。

Node接口主要定义了一些Raft算法实现所必须的方法,这也是根据Raft的理论模型来定义的,主要包括时钟,选举等操作。

// Node represents a node in a raft cluster.
type Node interface {
	Tick() //时钟的实现,选举超时和心跳超时基于此实现
	Campaign(ctx context.Context) error //参与Leader竞争
	Propose(ctx context.Context, data []byte) error //在日志中追加数据,需要实现方保证数据追加的成功
	ProposeConfChange(ctx context.Context, cc pb.ConfChange) error // 集群配置变更
	Step(ctx context.Context, msg pb.Message) error //根据消息变更状态机的状态
	//标志某一状态的完成,收到状态变化的节点必须提交变更,Raft底层的任何变动都会通知到这里
	Ready() <-chan Ready
	//进行状态的提交,收到完成标志后,必须提交过后节点才会实际进行状态机的更新。在包含快照的场景,为了避免快照落地带来的长时间阻塞,允许继续接受和提交其他状态,即使之前的快照状态变更并没有完成。
	Advance()
	//进行集群配置变更
	ApplyConfChange(cc pb.ConfChange) *pb.ConfState
	//变更leader
	TransferLeadership(ctx context.Context, lead, transferee uint64)
	//保证线性一致性读,
	ReadIndex(ctx context.Context, rctx []byte) error
	//状态机当前的配置
	Status() Status
	// ReportUnreachable reports the given node is not reachable for the last send.
	//上报节点的不可达
	ReportUnreachable(id uint64)
	//上报快照状态
	ReportSnapshot(id uint64, status SnapshotStatus)
	//停止节点
	Stop()
}

此外,node结构体也是etcd/raft中的一个核心数据结构,其主要定义了一系列的通道(go语言中的一种数据类型)来实现Raft中的信息传递,而且节点的不同状态之间的切换也是通过这些通道实现的。

// node is the canonical implementation of the Node interface
type node struct {
   propc      chan msgWithResult
   recvc      chan pb.Message
   confc      chan pb.ConfChange
   confstatec chan pb.ConfState
   readyc     chan Ready
   advancec   chan struct{}
   tickc      chan struct{}
   done       chan struct{}
   stop       chan struct{}
   status     chan chan Status
   logger Logger
}

以上的两个数据结构是etcd/raft实现的,并不是Fabric自己实现的,Fabric只是单纯地调用了它们来完成节点间的共识。但是要怎么在Fabric中实现与etcd/raft的有机整合就是Fabric需要考虑的事了。

Hyperledger Fabric对Raft算法的核心实现代码都是放在fabric/orderer/consensus/etcdraft包下的,这里主要包含几个核心的数据结构,即Chain接口,Chain结构体和node结构体。

首先,Chain接口的定义在fabric/orderer/consensus/etcdraft/consensus.go文件下,它主要定义了排序节点对接收到的客户端发送来的消息的处理操作,它的详细定义如下:

// Chain defines a way to inject messages for ordering.
type Chain interface {
    // 负责对普通交易消息进行处理排序
	Order(env *cb.Envelope, configSeq uint64) error
    // 负责对配置交易消息进行处理和排序。当排序服务在 BroadCast 接口收到消息进行校验和过滤之后,就交由对应 Chain 实例进行处理。
	Configure(config *cb.Envelope, configSeq uint64) error
	WaitReady() error
	Errored() <-chan struct{}
   // Start()负责启动此 Chain 服务。
	Start()
	Halt()
}

其次,Chain结构体实现了Chain接口,它里面主要定义了一些通道(channel)用于节点间的通信,以便根据通信消息做相应的操作。

// Chain implements consensus.Chain interface.
type Chain struct {
   configurator Configurator
   rpc RPC // 节点与外部节点进行通信的对象,RPC 是一个接口,包含两个方法SendConsensus 和 SendSubmit。前面这种用于节点间 raft 信息的通讯,后者用于转发交易请求给 leader 节点。
   raftID    uint64
   channelID string
   lastKnownLeader uint64
   ActiveNodes     atomic.Value
   submitC  chan *submit // 接收 Orderer 客户端提交的共识请求消息的通道
   applyC   chan apply // 接收 raft 节点间应用消息的通道
   observeC chan<- raft.SoftState
   haltC    chan struct{}         
   doneC    chan struct{} 
   startC   chan struct{} 
   snapC    chan *raftpb.Snapshot //接收 raft 节点快照数据的通道
   gcC      chan *gc 
   …
   Node *node // 封装了底层 raft 库的节点实例
   …
}

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

type node struct {
   chainID string
   logger  *flogging.FabricLogger
   metrics *Metrics
   unreachableLock sync.RWMutex
   unreachable     map[uint64]struct{}
   tracker *Tracker
   storage *RaftStorage
   config  *raft.Config
   rpc RPC
   chain *Chain // 前面定义的Fabric自己实现的Chain结构体
   tickInterval time.Duration
   clock        clock.Clock
   metadata *etcdraft.BlockMetadata
   subscriberC chan chan uint64
   raft.Node // etcd底层的Raft中的节点接口
}

3.2.2 Fabric Raft机制的启动过程源码分析

Raft的启动入口位于fabric/orderer/consensus/etcdraft/chain.go文件中,在Chain的Start()方法中会启动etcdraft/node.go中的node.start(),而node.start()方法中进而启动etcd已经封装好的raft.StartNode()方法。

Chain中的Start()方法定义如下:

// Start instructs the orderer to begin serving the chain and keep it current.
func (c *Chain) Start() {
   …
// 这里又启动了etcdraft/node中的start方法
c.Node.start(c.fresh, isJoin)
   close(c.startC)
   close(c.errorC)
   go c.gc()
   go c.run()
   es := c.newEvictionSuspector()
   interval := DefaultLeaderlessCheckInterval
   if c.opts.LeaderCheckInterval != 0 {
      interval = c.opts.LeaderCheckInterval
   }
   c.periodicChecker = &PeriodicCheck{
      Logger:        c.logger,
      Report:        es.confirmSuspicion,
      ReportCleared: es.clearSuspicion,
      CheckInterval: interval,
      Condition:     c.suspectEviction,
   }
   c.periodicChecker.Run()
}

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

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

func (n *node) start(fresh, join bool) {
   …
   var campaign bool
   if fresh {// 是否是新节点标记位
      if join {// 是否是新加入节点标记位
         raftPeers = nil
         n.logger.Info("Starting raft node to join an existing channel")
      } else {
         n.logger.Info("Starting raft node as part of a new channel")
         sha := sha256.Sum256([]byte(n.chainID))
         number, _ := proto.DecodeVarint(sha[24:])
         if n.config.ID == number%uint64(len(raftPeers))+1 {
            campaign = true
         }
      }
      // 调用raft/node中的启动节点函数,初始化raft
      n.Node = raft.StartNode(n.config, raftPeers)
   } else {
      n.logger.Info("Restarting raft node")
      n.Node = raft.RestartNode(n.config)
   }
   n.subscriberC = make(chan chan uint64)
// run方法中会启动一个循环用来接收raft节点发来的消息,在这里经过进一步处理后,转发给Chain层进行处理,消息的转发机制都是通过通道来完成的。
   go n.run(campaign)
}

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

// StartNode returns a new Node given configuration and a list of raft peers.
// It appends a ConfChangeAddNode entry for each given peer to the initial log.
func StartNode(c *Config, peers []Peer) Node {
   r := newRaft(c)
   r.becomeFollower(1, None)
   for _, peer := range peers {
      // 将配置中给定的所有节点加入集群
…
   }
//初始化logindex
r.raftLog.committed = r.raftLog.lastIndex()
   for _, peer := range peers {
      r.addNode(peer.ID)
   }
   n := newNode()
   n.logger = c.Logger
   go n.run(r)
   return &n
}

结合上述的源码分析,图3.2更加详细地描述了Raft的启动流程。

image-20220411201122918

图3.2 Raft启动流程图

3.2.3 Fabric Raft机制的交易处理流程源码分析

我们在上一节已经根据源码仔细分析了Raft的启动流程,接下来Fabric中的排序节点便可以开始接收交易并开始排序和打包成区块了。这个交易处理流程可以说是Fabric中交易的核心。下面我们也跟着源码来详细分析这部分的实现细节。

1. 提案的提交

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

// Order submits normal type transactions for ordering.
func (c *Chain) Order(env *common.Envelope, configSeq uint64) error {
   c.Metrics.NormalProposalsReceived.Add(1)
   return c.Submit(&orderer.SubmitRequest{LastValidationSeq: configSeq, Payload: env, Channel: c.channelID}, 0)
}
// Configure submits config type transactions for ordering.
func (c *Chain) Configure(env *common.Envelope, configSeq uint64) error {
   c.Metrics.ConfigProposalsReceived.Add(1)
   return c.Submit(&orderer.SubmitRequest{LastValidationSeq: configSeq, Payload: env, Channel: c.channelID}, 0)
}

2. 转发交易提案到Leader

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

func (c *Chain) Submit(req *orderer.SubmitRequest, sender uint64) error {
…
   leadC := make(chan uint64, 1)
   select {
   case c.submitC <- &submit{req, leadC}: // 将消息封装并且写入submitC通道
      lead := <-leadC
      if lead == raft.None {
         c.Metrics.ProposalFailures.Add(1)
         return errors.Errorf("no Raft leader")
      }
      if lead != c.raftID { // 当前节点不是Leader,则转发消息给Leader
         if err := c.forwardToLeader(lead, req); err != nil {
            return err
         }
      }
   …
   return nil
}

3. 对交易排序

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

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

func (c *Chain) ordered(msg *orderer.SubmitRequest) (batches [][]* common.Envelope, pending bool, err error) {
  if c.isConfig(msg.Payload) {
      // 配置消息
      …
      batch := c.support.BlockCutter().Cut()
      batches = [][]*common.Envelope{}
      if len(batch) != 0 {
         batches = append(batches, batch)
      }
      batches = append(batches, []*common.Envelope{msg.Payload})
      return batches, false, nil
   }
   // 普通交易信息
   if msg.LastValidationSeq < seq {
     …
   }
   batches, pending = c.support.BlockCutter().Ordered(msg.Payload)
   return batches, pending, nil
}

4. 打包区块

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

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

func (c *Chain) propose(ch chan<- *common.Block, bc *blockCreator, batches ...[]*common.Envelope) {
   for _, batch := range batches {
      b := bc.createNextBlock(batch) // 根据当前批次创建一个区块
      c.logger.Infof("Created block [%d], there are %d blocks in flight", b.Header.Number, c.blockInflight)
      select {
      case ch <- b: // 将block传递给c.ch通道,Leader可以通过这个通道收到这个区块
      default:
         c.logger.Panic("Programming error: limit of in-flight blocks does not properly take effect or block is proposed by follower")
      }
      // if it is config block, then we should wait for the commit of the block
      if protoutil.IsConfigBlock(b) {
         c.configInflight = true
      }
      c.blockInflight++
   }
}

5. Raft对区块的共识

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

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

func (n *node) Propose(ctx context.Context, data []byte) error {
   return n.stepWait(ctx, pb.Message{Type: pb.MsgProp, Entries: []pb.Entry{{Data: data}}})
}

这里涉及到了Raft算法的具体共识步骤,这里就不详细深入了,这部分的内容将在3.2.4节深入剖析。

6. 保存区块

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

func (c *Chain) apply(ents []raftpb.Entry) {
   …
   for i := range ents {
      switch ents[i].Type {
      case raftpb.EntryNormal:// 如果是普通entry消息
         …
         block := protoutil.UnmarshalBlockOrPanic(ents[i].Data)
         c.writeBlock(block, ents[i].Index) // 写入区块到本地
 c.Metrics.CommittedBlockNumber.Set(float64(block.Header.Number))
      case raftpb.EntryConfChange:// 如果是配置entry消息
         var cc raftpb.ConfChange
         if err := cc.Unmarshal(ents[i].Data); err != nil {
            c.logger.Warnf("Failed to unmarshal ConfChange data: %s", err)
            continue
         }
         c.confState = *c.Node.ApplyConfChange(cc)
         switch cc.Type {
         case raftpb.ConfChangeAddNode:
            c.logger.Infof("Applied config change to add node %d, current nodes in channel: %+v", cc.NodeID, c.confState.Nodes)
         case raftpb.ConfChangeRemoveNode:
            c.logger.Infof("Applied config change to remove node %d, current nodes in channel: %+v", cc.NodeID, c.confState.Nodes)
         default:
            c.logger.Panic("Programming error, encountered unsupported raft config change")
         }
…
      if ents[i].Index > c.appliedIndex {
         c.appliedIndex = ents[i].Index
      }
   }
}

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

结合上述的源码分析,图3.3以流程图的形式更加详细地描述了Raft的交易流程。

image-20220411201321029

图3.3 Raft交易源码分析流程图

至此,Fabric中关于Raft机制的交易处理流程已经大致分析完成了,限于篇幅,我们仅着重分析了从交易提案提交到保存区块到各节点的过程,而忽略了背书和验证等流程的细节,这部分的内容与Fabric中Raft共识算法的实现关系较小,就不在本文中详细介绍相关的源码实现了。

3.2.4 Fabric Raft底层核心算法实现细节源码分析

在前一节即3.2.3节中我已经从源码的角度详细描述了Fabric中交易提案的提交,交易的打包和区块的保存等核心内容。然而,前一节中对于Raft实现共识的细节并没有涉及太多,这部分的内容Fabric本来就没有自己去实现,而是调用的第三方(etcd)中已经实现好了的Raft算法。Fabric做的只是实现了将发送提案到Leader以及保存共识后的区块这些应用层的功能以及实现了与Raft集群底层的消息交互。为了更好地理解Raft的精髓,我们还是不得不进入到etcd的Raft源码中一探究竟。

3.2.4.1 领导者选举

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

func (r *raft) tickElection() {
   r.electionElapsed++
   if r.promotable() && r.pastElectionTimeout() {
      r.electionElapsed = 0
      r.Step(pb.Message{From: r.id, Type: pb.MsgHup})
   }
}

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

func (r *raft) campaign(t CampaignType) {
   var term uint64
   var voteMsg pb.MessageType
   if t == campaignPreElection {
      r.becomePreCandidate()
      voteMsg = pb.MsgPreVote
      term = r.Term + 1 // 当前任期号自增一
   } else {
      r.becomeCandidate()
      voteMsg = pb.MsgVote
      term = r.Term
   }
   if r.quorum() == r.poll(r.id, voteRespMsgType(voteMsg), true) {
      // 如果集群是单节点,那节点将投票给自己,则获取的票数一定超过了一半,当选为Leader
      if t == campaignPreElection {
         r.campaign(campaignElection)
      } else {
         r.becomeLeader()
      }
      return
   }
// 向其他节点发送竞选领导者的消息
   for id := range r.prs {
      if id == r.id {
         continue
      }
      …
      r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm(), Context: ctx})
   }
}

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

func (r *raft) Step(m pb.Message) error {
   switch { 
   case m.Term > r.Term:// 节点只会投票给任期号大于自己任期号的candidate
      if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote {
         force := bytes.Equal(m.Context, []byte(campaignTransfer))
         inLease := r.checkQuorum && r.lead != None && r.electionElapsed < r.electionTimeout
         if !force && inLease {
            ….
            return nil
         }
      }
   case m.Term < r.Term: 
      return nil
   }
   switch m.Type {
   case pb.MsgVote, pb.MsgPreVote: // 如果candidate拥有最新的日志则发送投票给该candidate
      if canVote && r.raftLog.isUpToDate(m.Index, m.LogTerm) {
         …
         r.send(pb.Message{To: m.From, Term: m.Term, Type: voteRespMsgType(m.Type)})
         if m.Type == pb.MsgVote {
            // Only record real votes.
            r.electionElapsed = 0
            r.Vote = m.From
         }
      } else {// 否则当前节点会拒绝给此次参与领导者选举的candidate投票
         r.send(pb.Message{To: m.From, Term: r.Term, Type: voteRespMsgType(m.Type), Reject: true})
      }
   }
   return nil
}

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

func stepCandidate(r *raft, m pb.Message) error {
   switch m.Type {
   case pb.MsgProp:
      …
   case myVoteRespType:// 统计投票结果
      gr := r.poll(m.From, m.Type, !m.Reject) 
      switch r.quorum() {
      case gr:// 判断票数是否超过半数
         if r.state == StatePreCandidate {
            r.campaign(campaignElection)
         } else {
            r.becomeLeader()// 如果票数超过一般则当选为Leader
            r.bcastAppend()
         }
      case len(r.votes) - gr:
         r.becomeFollower(r.Term, None)
      }
   case pb.MsgTimeoutNow:
      …
   }
   return nil
}

最后,结合上述源码分析,图3.4更加详细地描述了Raft的领导者选举流程。

image-20220411201421105

图3.4 Raft领导者选举流程图

3.2.4.2 日志复制

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

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

func stepLeader(r *raft, m pb.Message) error {
   switch m.Type {
   case pb.MsgBeat:
      r.bcastHeartbeat()
      return nil
   case pb.MsgCheckQuorum:
      …
   case pb.MsgProp:
      for i, e := range m.Entries {
         if e.Type == pb.EntryConfChange {
            if r.pendingConfIndex > r.raftLog.applied {
               r.logger.Infof("propose conf %s ignored since pending unapplied configuration [index %d, applied %d]",
                  e.String(), r.pendingConfIndex, r.raftLog.applied)
               m.Entries[i] = pb.Entry{Type: pb.EntryNormal}
            } else {
               r.pendingConfIndex = r.raftLog.lastIndex() + uint64(i) + 1
            }
         }
      }
      if !r.appendEntry(m.Entries...) {// appendEntry将消息追到Leader的日志之中
         return ErrProposalDropped
      }
      r.bcastAppend()/ /bcastAppend 将消息广播至其他Follower节点。
      return nil
   }
}

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

func (r *raft) handleAppendEntries(m pb.Message) {
   if m.Index < r.raftLog.committed {
      r.send(pb.Message{To: m.From, Type: pb.MsgAppResp, Index: r.raftLog.committed})
      return
   }
   if mlastIndex, ok := r.raftLog.maybeAppend(m.Index, m.LogTerm, m.Commit, m.Entries...); ok {// 当前Follower追加日志,可能存在冲突的情况,需要找到冲突的位置用Leader的日志进行覆盖
      r.send(pb.Message{To: m.From, Type: pb.MsgAppResp, Index: mlastIndex})
   } else {// 如果两者的任期信息不一致,当亲节点拒绝此次追加日志请求,并把最新的日志index回复给Leader,便于进行追加
      r.send(pb.Message{To: m.From, Type: pb.MsgAppResp, Index: m.Index, Reject: true, RejectHint: r.raftLog.lastIndex()})
   }
}

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

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

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

1)	func stepLeader(r *raft, m pb.Message) error {
case pb.MsgAppResp:
   pr.RecentActive = true
   if m.Reject {// Follower发送的是拒绝append的响应
      if pr.maybeDecrTo(m.Index, m.RejectHint) {
         if pr.State == ProgressStateReplicate {
            pr.becomeProbe() // 进入试探append阶段, 继续探测follower最新匹配的位置。
         }
         r.sendAppend(m.From)
      }
   } else {
      oldPaused := pr.IsPaused()
      if pr.maybeUpdate(m.Index) {
         switch {
         case pr.State == ProgressStateProbe:
            pr.becomeReplicate()// 日志追加成功,状态转换为复制状态
         case pr.State == ProgressStateSnapshot && pr.needSnapshotAbort():
            pr.becomeProbe()
            pr.becomeReplicate()
         case pr.State == ProgressStateReplicate:
            pr.ins.freeTo(m.Index)
         }
         if r.maybeCommit() {// 如果超过半数已经认可此次提交,Leader可以进行commit
            r.bcastAppend()// 广播通知所有Follower进行日志的提交
         } else if oldPaused {
            r.sendAppend(m.From)
         }
       }
   }
}

最后,结合上述源码分析,图3.5以流程图的形式更加详细地描述了Raft的日志复制流程。

image-20220411201503063

图3.5 Raft日志复制源码分析流程图

至此,我们已经基本通过源码来进一步对Raft共识算法进行分析和理解,特别是该算法的一些实现细节。需要说明的是,在Raft源码分析这一节,我并没有将如何保证安全性这一特性单独拿出来分析,这是与我们前面的理论部分不同的一个地方。因为Raft共识的安全性主要是通过给算法添加一些限制条件来保证的,这些特性最终都能在领导者选举和日志复制这两部分的源码内容中得到体现,所以在源码分析阶段没有必要单独成节。

总结

本次源码与结构分析我选择的目标区块链系统是Hyperledger Fabric,而我选择Fabric的原因主要是因为在当前已有的较为成熟的联盟链中,Fabric可以说是最受欢迎的也是应用最广泛的一个区块链系统,而且它还是现有其他联盟链实现的基础,很多其他联盟链中都能看到Fabric的设计原理。

本文首先介绍了Fabric中的交易的基本流程,其主要分成提议、排序打包以及验证提交这三个阶段。交易可以说是区块链系统的核心功能,而与其他区块链系统的交易有很大的不同的是,Fabric更加注重交易的隐私性和安全性,通过引入背书机制来加强这些特性。其次,说到交易流程就不得不涉及到共识,因为Fabric也是一个去中心化的分布式账本,需要完成去中心化系统中各节点对交易的共识才能保证系统的一致性。Fabric提供了可插拔的共识组件,允许用户选择不同的适用于不同场景的共识算法。Fabric官方推荐的是Raft共识算法,该算法目前也是比较成熟的一个共识算法,它是Paxos算法的一种延伸实现,可以容忍少部分的节点崩溃。本文基于Fabric开源的源代码对它的交易流程和共识流程都做了详细的理论分析,特别是针对Fabric中Raft共识模块,本文花费了大量的篇幅从源代码出发来详细剖析它的实现原理。

相关文章:

猜你喜欢