Raft 一致性协议

Raft 一致性协议

1. Raft

Raft协议的发布,对分布式行业是一大福音,虽然在核心协议上基本都是师继Paxos祖师爷(lamport)的精髓,基于多数派的协议。但是Raft一致性协议的贡献在于,定义了可易于实现的一致性协议的事实标准。把一致性协议从“阳春白雪” 变成普通学生、IT码农都可以上手试一试玩一玩的东东,MIT的分布式教学课程6.824都是直接使用Raft来介绍一致性协议。

从《In Search of An Understandable Consensus Algorithm(Extend Version)》论文中,我们可以看到,与其他一致性协议的论文不同的点是,Diego 基本已经算是把一个易于工程实现算法讲得非常明白了,just do it,没有太多争议和发挥的空间,即便如此,要实现一个工业级的靠谱的raft还是要花不少力气。

raft一致性协议相对易于实现主要归结为以下几个原因:

  1. 模块化的拆分:把一致性协议划分为 Leader选举、MemberShip变更、日志复制、SnapShot等相对比较解耦的模块
  2. 设计的简化: 比如不允许类似Paxos算法的乱序提交、使用Randomization 算法设计Leader Election算法以简化系统的状态,只有Leader、Follower、Candidate等等。

本文不打算对Basic Raft一致性协议的具体内容进行说明,而是介绍记录一些关键点,因为绝大部份内容,原文已经说明得很详实,但凡有一定英文基础,直接看raft paper就可以了,如意犹未尽,还可以把raft 作者 Diego Ongaro 200多页的博士论文刷一遍(链接在文末,可自取)。

2. Points

2.1 Old Term LogEntry 处理

旧Term未提交的日志的提交依赖于新一轮的日志的提交

这个在原文 “5.4.2 Committing entries from previews terms” 有说明,但是在看的时候可能会觉得有点绕。

Raft协议约定,Candidate在使用新的Term进行选举的时候,Candidate能够被选举为Leader的条件为:

  1. 得到一半以上(包括自己)节点的投票
  2. 得到投票的前提是:Candidate节点的最后一个LogEntry的Term比投票节点大,或者在Term一样情况下,LogEnry的SN(serial number)必须大于等于投票者。

并且有一个安全截断机制:

  1. Follower 在接收到logEntry的时候,如果发现发送者节点当前的Term大于等于Follower当前的Term;并且发现相同序号的(相同SN)LogEntry在Follower上存在,未Commit,并且LogEntry Term 不一致,那么Follower直接截断从[SN~文件末尾)的所有内容,然后将接收到的LogEntryAppend到截断后的文件末尾。

在以上条件下,Raft论文列举了一个Corner Case ,如图所示



  • (a): S1 成为 Leader,Append Term2的LogEntry(黄色)到S1、S2 成功;
  • (b): S1 Crash, S5使用 Term(3) 成功竞选为 Term(3)的 Leader(通过获得S3、S4、S5的投票),并且将Term为 3的 LogEntry(蓝色) Append到本地;
  • (c): S5 Crash, S1 使用 Term(4) 成功竞选为Leader(通过获得S1、S2、S3的投票),将黄色的LogEntry复制到S3,得到多数派响应(S1、S2、S3)的响应,提交黄色LogEntry为Commit,并将Term为4的LogEntry(红色) Append到本地。
  • (d) S5 使用新的Term(5) 竞选为Leader(得到 S2、S3、S4 的投票),按照协议将所有所有节点上的黄色和红色的LogEntry截断覆盖为自己的Term为3 的LogEntry。

进行到这步的时候我们已经发现,黄色的LogEnry(2) 在被设置为Commit之后重新又被否定了。

所以协议又强化了一个限制;

  1. 只有当前Term的LogEntry提交条件为:满足多数派响应之后(一半以上节点Append LogEntry到日志)设置为commit;
  2. 前一轮Term未Commit的LogEntry的Commit依赖于高轮Term LogEntry的Commit

如图所示 (c) 状态 Term2的LogEntry(黄色) 只有在 (e)状态 Term4 的LogEntry(红色)被commit才能够提交。

提交NO-OP LogEntry 提交系统可用性

在Leader通过竞选刚刚成为Leader的时候,有一些等待提交的LogEntry(即SN > CommitPt的LogEntry),有可能是Commit的,也有可能是未Commit的。(PS: 因为在Raft协议中CommitPt 不用实时刷盘)

所以为了防止出现非线性一致性(Non Linearizable Consistency);即之前已经响应客户端的已经Commit的请求回退,并且为了避免出现上图中的Corner Case,往往我们需要通过下一个Term的LogEntry的Commit来实现之前的Term的LogEntry的Commit(隐式commit),才能保障提供线性一致性。

但是有可能接下来的客户端的写请求不能及时到达,那么为了保障Leader快速提供读服务,系统可首先发送一个NO-OP LogEntry 来保障快速进入正常可读状态。

2.2 Current Term、VotedFor 持久化

上图其实隐含了一些需要持久化的重要信息,即 Current Term、VotedFor!!! 为什么(b) 状态 S5 使用的Term Number 为3,而不是2?

因为竞选为Leader就必须是使用新的Term发起选举,并且得到多数派阶段的同意,同意的操作为将Current Term、VotedFor持久化。

比如(a) 状态 S1 为什么能竞选为Leader?首先S1满足成为Leader的条件,S2~S5 都可以接受 S1 成为发起Term 为2 的Leader选举。S2~S5 同意S1成为Leader的操作为:将 Current Term 设置为2、VotedFor 设置为S1 并且持久化,然后返回S1。即S1 成功成为Term 为2的Leader的前提是一个多数派已经记录 Current Term 为2 ,并且VotedFor为S1。那么(b) 状态 S5 如使用Term为2进行Leader选举,必然得不到多数派同意,因为Term 2 已经投给S1,S5只能 将Term++ 使用Term 为3 进行重新发起请求。

Current Term、VotedFor 如何持久化?
type CurrentTermAndVotedFor {
    Term int64 `json:"Term"`
    VotedFor int64 `json:"Votedfor"`
    Crc int32
}

//current state
var currentState  CurrentTermAndVotedFor

.. set value and calculate crc ...

content, err := json.Marshal(currentState)

//flush to disk
f, err := os.Create("/dist/currentState.txt")
f.Write(content)
f.Sync()

简单的方法,只需要保存在一个单独的文件,如上为简单的go语言示例;其他简单的方式比如在设计Log File的时候,Log File Header中包含 Current Term 以及VotedFor 的位置。

如果再深入思考一层,其实这里头有一个疑问?如何保证写了一半(写入一半然后挂了)的问题?写了Term、没写VoteFor?或者只写了Term的高32位?

可以看到磁盘能够保证512 Byte的写入原子性,这个在知乎事务性(Transactional)存储需要硬件参与吗? 这个问答上就能找到答案。所以最简单的方法是直接写入一个tmpfile,写入完成之后,讲tmpfile mv成CurrentTermAndVotedFor文件,基本可保障更新的原子性。其他方式比如采用Append Entry的方式也可以实现。

2.3 Cluser Membership 变更

在Raft的Paper中,简要说明了一种一次变更多个节点的Cluser Membership变更方式。但是没有给出更多的在Securey以及Avaliable上的更多的说明。

其实现在开源的raft实现一般都不会使用这种方式,比如Etcd raft 都是采用了更佳简洁的一次只能变更一个节点的 “single Cluser MemberShip Change” 算法。

当然single cluser MemberShip 并非Etcd 自创,其实raft 协议作者 Diego 在其博士论文中已经详细介绍了Single Cluser MemberShip Change 机制,包括Security、Avaliable方面的详细说明,并且作者也说明了在实际工程实现过程中更加推荐Single方式,首先因为简单,再则所有的集群变更方式都可以通过Single 一次一个节点的方式达到任何想要的Cluster 状态。

原文:“Raft restrict the types of change that allowed: only one server can be added or removed from the cluster at once. More complex changes in membership are implemented as a series of single-server-change”

2.3.1 Safty

回到问题的第一大核心要点:Safety,membership 变更必须保持raft协议的约束:同一时间(同一个Term)只能存在一个有效的Leader。

<一>:为什么不能直接变更多个节点,直接从Old变为New有问题? for example change from 3 Node to 5 Node?



如上图所示,在集群状态变跟过程中,在红色箭头处出现了两个不相交的多数派(Server3、Server4、Server 5 认知到新的5 Node 集群;而1、2 Server的认知还是处在老的3 Node状态)。在网络分区情况下(比如S1、S2 作为一个分区;S3、S4、S5作为一个分区),2个分区分别可以选举产生2个新的Leader(属于configuration< Cold>的Leader 以及 属于 new configuration < Cnew > 的 Leader ) 。

当然这就导致了Safty没法保证;核心原因是对于Cold 和 CNew 不存在交集,不存在一个公共的交集节点 充当仲裁者的角色。

但是如果每次只允许出现一个节点变更(增加 or 减小),那么Cold 和 CNew 总会相交。 如下图所示



<二>: 如何实现Single membership change

论文中以下提几个关键点:

  1. 由于Single方式无论如何 Cold 和 CNew 都会相交,所以raft采用了直接提交一个特殊的replicated LogEntry的方式来进行 single 集群关系变更。
  2. 跟普通的 LogEntry提交的不同点,configuration LogEntry 只需要commit就生效,只需要append 到Log中即可。(PS: 原文 "The New configuration takes effect on each server as soon as it is added to the server's log")
  3. 后一轮 MemberShip Change 的开始必须在前一轮 MemberShip Change Commit之后进行,以避免出现多个Leader的问题



  • 关注点1

如图所示,如在前一轮membership configure Change 未完成之前,又进行下一次membership change会导致问题,所以外部系统需要确保不会在第一次Configuration为成功情况下,发起另外一个不同的Configuration请求。( PS:由于增加副本、节点宕机丢失节点进行数据恢复的情况都是由外部触发进行的,只要外部节点能够确保在前一轮未完成之前发起新一轮请求,即可保障。)

  • 关注点2

跟其他客户端的请求不一样的,Single MemberShip Change LogEntry只需要Append持久化到Log(而不需要commit)就可以应用。

一方面是可用性方面的考虑,如下所示:Leader S1 接收到集群变更请求将集群状态从(S1、S2、S3、S4)变更为 (S2、S3、S4);提交到所有节点之后commit之后,返回客户端集群状态变更完成(如下状态a),S1退出(如下状态b);由于Basic Raft 并不需要commit消息实施传递到其他S1、S2、S3节点,S1退出之后,S1、S2、S3 由于没有接收到Leader S1 的心跳,导致进行选举,但是不幸的是S4故障退出。假设这个时候S2、S3由于 Single MemberShip Change LogEntry 没有Commit 还是以(S1、S2、S3、S4)作为集群状态,那么集群没法继续工作。但是实质上在(b)状态 S1 返回客户端 集群状态变更请求完成之后,实质上是认为可独立进入正常状态。



另一方面,即使没有提交到一个多数派,也可以截断,没什么问题。(这里不多做展开)

另一方面可靠性&正确性

raft协议 Configuration 请求和普通的用户写请求是可以并行的,所以在并发进行的时候,用户写请求提交的备份数是无法确保是在Configuration Change之前的备份数还是备份之后的备份数。但是这个没有办法,因为在并发情况下本来就没法保证,这是保证Configuration截断系统持续可用带来的代价。(只要确保在多数派存活情况下不丢失即可(PS:一次变更一个节点情况下,返回客户端成功,其中必然存在一个提交了客户端节点的 Server 被选举为Leader))

  • 关注点3

single membership change 其他方面的safty保障是跟原始的Basic Raft是一样的(在各个协议处理细节上对此类请求未有任何特殊待遇),即只要一个多数派(不管是新的还是老的)将 single membership change 提交并返回给客户端成功之后,接下来无论节点怎么重启,都会保障确保新的Leader将会在已经知晓(应用)新的,前一轮变更成功的基础上处理接下来的请求:可以是读写请求、当然也可以是新的一轮Configuration 请求。

2.3.2 初始状态如何进入最小备份状态

比如如何进入3副本的集群状态。可以使用系统元素的Single MemberShip 变更算法实现。

刚开始节点的副本状态最简单为一个节点1(自己同意自己非常简单),得到返回之后,再选择添加一个副本,达到2个副本的状态。然后再添加一个副本,变成三副本状态,满足对系统可用性和可靠性的要求,此事该raft实例科对外提供服务。

2.4 其他需要关注的事项

  • servers process incoming RPC requests without consulting their current configurations. server处理在AppendEntries & Voting Request 的时候不用考虑本地的configuration信息
  • catchup:为了保障系统的可靠性和可用性,加入 no-voting membership状态,进行catchup,需要加入的节点将历史LogEntry基本全部Get到之后再发送 Configuration。
  • Disrptive serves:为了防止移除的节点由于没有接收到新的Leader的心跳,而发起Leader选举而扰绕当前正在进行的集群状态。集群中节点在Leader心跳租约期间内收到Leader选举请求可以直接Deny。(PS:当然对于一些确定性的事情,比如发现Leader listen port reset,那么可以发起强制Leader选举的请求)

3 参考文献

  1. Raft Paper
  2. Raft 博士论文
  3. 事务性(Transactional)存储需要硬件参与吗?
编辑于 2018-02-26

文章被以下专栏收录