前言
上一篇文章解析了Raft协议的选举机制,客户端通过和选举出来的Leader通信来读写数据【传送门】。选举只是保证数据一致性的基础,数据读写才是该协议要实现的功能。这篇文章来分析下Raft协议通过哪些约束来保证数据在多个节点上一致性。
基础原理
官方文档上对Raft的描述中说,“Raft本质上是管理日志复制的一致性算法”。这句话包含两层意思,1)Raft规定数据在集群节点中的同步通过复制日志来实现;2)Raft协议的核心是通过一系列约束,保证日志复制的可靠性。
- 为什么采用日志复制
所谓日志复制,就是所有数据操作指令以日志的方式发送到集群内的所有节点上,然后按照相同的顺序被执行,可以参考复制状态机的图来理解。
复制状态机
上图就是复制状态机的工作过程:
第①步,客户端把更新指令发给server(对应Raft中的Leader节点)
第②步,server把指令以log方式持久化(追加到日志队尾),然后发送到其他节点(Raft中的Follower)
第③步,日志被发送到所有节点后(Raft中超过半数节点即可),server将数据应用到状态机(提交)
第④步,回复客户端数据修改成功
因为Log日志队列是有序的,只要保证所有节点的队列数据一致,就可以按照先进先出的顺序执行相同的日志(通常里面存的是指令),状态机的最终状态肯定是一致的。
优点
采用复制日志的方式来同步数据有什么好处呢?
1)对于leader节点来说,只需要记录每个节点最后一条复制成功日志的index。这样对于复制失败的情况,可以从这个index不断重试,而不需要全量数据覆盖。
2)可以防止ABA的问题,比如上图中的y先变成1后变成9,follower节点可以知道整个变化的过程,在某些需要中间状态的场景不会导致错误。
通过复制日志来同步数据在很多系统中都有应用,比如MySQL中通过复制Binlog来做主从数据同步,HDFS中文件冗余存储也是采用同样的原理。 - Raft要达成的目标
Raft通过对复制状态机运行过程添加一定的约束,从而保证了如下的一致性特性:
1)安全性保证(绝对不会返回一个错误的结果):在非拜占庭错误情况下,包括网络延迟、分区、丢包、冗余和乱序等错误都可以保证正确。
2)可用性:集群中只要有大多数的机器可运行并且能够相互通信、以及和客户端通信,就可以保证可用。因此,一个典型的包含 5 个节点的集群可以容忍两个节点的失败(服务器被停止就认为是失败)。他们当有稳定的存储的时候可以从状态中恢复回来并重新加入集群。
3)不依赖时序来保证一致性:物理时钟错误或者极端的消息延迟只有在最坏情况下才会导致可用性问题。
4)通常情况下,一条指令可以尽可能快的在集群中大多数节点响应一轮远程过程调用时完成。小部分比较慢的节点不会影响系统整体的性能。
以上是完整的一致性协议通常的保证的特性,从中可以看出,状态机数据存储部分不是Raft关心的范畴,Raft只保证把数据安全的送到集群中的所有节点。下面我们通过分解Raft协议,来看下它是怎么完成任务的。
日志复制规范
Raft对于复制状态机中描述的流程,在日志存储和复制方面都做了定义
日志数据存储
日志存储属性定义
属性名 | 定义 | 实时持久化 |
---|---|---|
log[] | 日志集合, 每个日志条目包含一个指令和任期号 集群内部通过复制日志来同步数据 |
是 |
commitIndex | 被提交的最后一条日志的索引 | 否 |
lastApplied | 最后被应用到状态机的日志索引 | 否 |
nextIndex[] | 仅Leader节点使用 记录了需要发送给其它节点的最后一条日志的索引号,每个Follower一条记录 |
否 |
matchIndex[] | 仅Leader节点使用 记录了已经发送给其它节点最高索引号 |
否 |
- log属性用来存储日志,必须保证在回复request之前,log已经持久化到磁盘上。
- 当日志被复制到超过半数节点后,Leader节点就会变更commitIndex。所有节点在收到最新的commitIndex后就会尝试把之前未提交的日志应用到状态机,然后修改lastApplied。
- Leader节点额外存储了所有节点的同步进度。matchIndex在收到Follower的Response后更新,这样对于一条日志来说,matchIndex中超过半数都确认了,Leader就知道这条日志可以提交了。
节点日志存储格式
一个典型的log结构如下:
每个log条目有个自增的唯一index,除了存储修改指令外,还会存储收到指令时的任期(term),如上图中term 3已经收到4条日志,日志条目中保存term的作用后面会讲到。
日志数据复制
上一节讲过,Raft采用了强Leader得模式,所有的客户端指令都先发给Leader来执行,所以复制肯定有Leader来发起。
复制步骤
- 客户端把指令发给Leader
- Leader将指令以存成一个新的log条目追加到队尾
- Leader分发新的log条目给Follower,这一步是并发执行的
- Follower处理日志条目,返回结果给Leader
Raft对于复制过程做了一些约束:
- Leader不允许删除或者修改日志条目,只允许追加,更不会复制Follower上的数据更新自己的
- Leader强制要求Follower上的数据和自己相同,如果Follower在指定index上的数据和Leader不同,则先删除index上的log,从Leader重新同步。(数据产生不一致的情况都是因为Leader宕机引发重新选举引起的)
- 当超过半数的Follower收到index为N的log条目后,Leader就会更新commitIndex,把日志应用到状态机。应用到状态机,表示这条数据生效了。
复制RPC
参数 | 定义 |
---|---|
term | 领导人的任期号 |
leaderId | 领导人的 Id,以便于跟随者重定向请求 |
prevLogIndex | 复制给该Follower最后一条日志条目的索引值 |
prevLogTerm | prevLogIndex 条目的任期号 |
entries[] | 准备存储的日志条目(表示心跳时为空;一次性发送多个是为了提高效率) |
leaderCommit | 领导人的CommitIndex |
日志复制RPC由Leader发给Follower,和心跳使用同一个RPC调用,只是其中的entries属性包含日志条目。LeaderCommit即Leader节点CommitIndex的当前值。当Leader的CommitIndex发生变化后,Follower就会在下一次rpc请求中获取到这个值,就可以知道应该把那些日志条目应用到状态机中。
复制RPC的Response
返回值 | 定义 |
---|---|
success | Follower匹配上 prevLogIndex 和 prevLogTerm 的日志时为true,否则为false |
term | Follower当前的任期号,如果Follower发现Leader发来的包任期号比自己小,说明领导人已经过期了,则返回false和自己的任期号 |
在集群运行正常的时候,Leader不断地发送日志复制包给Follower,而Follower接收日志后发现自己prevLogIndex处的日志条目term值等于prevLogTerm,就会追加到本地日志队列中。
如果发生重新选举并且选举前不是所有节点的数据都是最新的,新选上的Leader带过来的prevLogIndex就会和Follower的最大LogIndex对不上,这个时候Follower会返回false,Leader会用更小的prevLogIndex来尝试,直到双方匹配上为止。
一次正常的复制过程
下面用数据模拟一次正常的日志复制过程。
1)当前集群的状态
当前集群经历了两个选举周期,当前现在term为2。Leader已经将最新的日志复制到Follower1,3成功,超过了半数,所以commitIndex为5。Follower2、4最后一条日志的response还未收到,所以Leader中对应的matchIndex=4。此时Leader中状态机中的值x=1,y=1。
2)客户端向Leader发送新的指令,y=2
3)Leader收到指令后,同步向Follower发送日志复制RPC
属性 | 值 |
---|---|
term | 2 |
LeaderId | 1 |
prevLogIndex | 5 |
prevLogTerm | 2 |
entries | term=2,y=2 |
leaderCommit | 5 |
4)Follower收到日志后,将log持久化存储,比较自己的commitIndex和leaderCommit,将log应用到状态机后,更新CommitIndex和lastApplied。当超过半数的Follower接收到日志后的可能状态如下:
此时,Follower1和2收到了index 6的日志并保存,并将自己的commitIndex更新成5,然后将index5的日志应用到状态机,返回成功给Leader。
5)Leader节点收到了超过半数response,则将index 6的log应用到状态机,更新commitIndex和lastApplied为6
6)Leader返回操作成功给客户端
以上就是一次完整的日志复制过程,看起来逻辑比较简单。Leader接收到客户端请求,将日志复制给Follower,当收到超过半数的Follower反馈,则更新commitIndex,返回客户端成功。剩下的Follower大部分情况下也会返回成功,或者极少数情况如果失败的话,Leader会不断重试,直到成功或者确认Follower已宕机,这两种情况都不会对结果有影响。
日志数据差异处理
Raft集群在大部分情况下,都会按照上面正常的逻辑来运行,但是在极少数情况下,由于因为各种原因导致Leader重新选举,可能会出现各节点上的数据差异很大的情况,从下面几个例子可以看下Raft如何处理。
Follower上的数据落后很多
图中的第3个Follower数据落后很多,但是这不影响集群提供服务,因为Leader依靠第2个和第4个Follower的Response就可以超过半数,从而正常提交数据。对于数据落后的Follower,Leader会一直重试。
Leader频繁崩溃
下图展示了一种Leader频繁崩溃可能导致的数据不一致情况
简单模拟一下出现上图的情况的过程。在term 1的时候,所有节点的数据都正常复制,然后Leader崩溃触发选举。这时候(f)被选为新的Leader并开启新的term 2,写入了3条数据,在这些数据复制到其它节点之前(f)就崩溃了,但是因为恢复的快所以在term 3又被选为Leader。在term 3中(f)从客户端接收到5条数据后又崩溃了,请注意这时候term 2和3的数据都还没有复制到其它节点,所以客户端不可能收到提交成功的Response。在第4个任期(e)被选为Leader,在提交了2条数据后(e)又崩溃了。随后是(c)和(d)在第6和第7个任期被选为Leader。(d)在任期7收到2条指令后崩溃就出现了图中的状态,第一个节点被选为Leader。
这时候term 8的Leader开始复制日志,由于是新选举出来的Leader,所以它的nextIndex集合为空。默认情况下,Leader把所有的nextIndex设置为自己的最大logIndex+1,上图中即设置为11。Leader开始发送日志复制请求,请求中prevLogIndex=10,prevLogTerm=6。
- 节点(a),index 10的位置为空,所以它会返回false,Leader收到后会将nextIndex-1重新发送请求,请求中的prevLogIndex=9,prevLogTerm=6,同时entries中携带index 10的log条目。(a)收到后发现和自己index=9处的日志匹配成功,则追加并保存index=10的log,然后返回true。
这里有一点需要注意,(a)节点index=9处的log条目的term和Leader的index=9处term相同,则index<9的日志条目肯定相同,这个使有Raft的安全性保证的,Raft保证了如下两个特性:
1) 如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们存储了相同的指令
2)如果在不同的日志中的两个条目拥有相同的索引和任期号,那么他们之前的所有日志条目也全部相同
这两个特性的证明,可以参考Raft协议原文
- 节点(b),处理逻辑同节点(a),Leader会从nextIndex=11开始尝试,直到等于5的时候日志匹配
- 节点(c), 收到Leader的prevLogIndex=10,prevLogTerm=6的RPC请求后,(c)发现index=11处的log条目是比leader多的,按照Raft的要求会直接删除index=11的日志条目
- 节点(d),处理逻辑同(c),会删除index=11和12处的日志
- 节点(e),发现index=6和7处的日志跟Leader不同,会删除这两个index处的日志,然后重新用Leader上6-10的日志追加到(e)的末尾
- 节点(f) ,同节点(e),会删除index=4及之后的日志
差异处理总结
Raft通过两个约定来保证Leader和Follower的数据最终会达成一致。
- Leader节点只会追加log数据,不会修改、删除已有数据
- 如果Follower在指定的index上的log条目和Leader任期号不一致,则会删除Follower上的数据,重新同步Leader的数据
上面的例子可能没有覆盖所有特殊情况,下面用问题的方式详细解释一下。
复制安全性问题
在相同的Index上,领导人的日志和Follower的日志不一样,怎么办
Raft强制Follower上的日志必须和Leader相同,不同则用Leader上的日志覆盖Follower-
会不会出现Leader发送日志给Follower的时候,Follower同一个Index处的term号更大?
可能出现,因为Follower原来是Leader,日志后还没复制到超过半数节点就崩溃了(如下图)。这种情况下,Follower收到Leader的日志复制包后就会删除自己的,不管term是大还是小
-
如果原来的Leader宕机,新选出的Leader没有最新的数据怎么办
首先定义下什么是最新的数据(up-to-date)。有两个标准,1)对于同一个index上的日志,term大的更新。2)对于term相同的,index大的更新。Raft规定在新的Leader选举时,Follower给候选人投同意票,必须是候选人的日志至少和自己一样新,否则就会投反对票。所以没有最新的日志的Follower,不可能当选Leader(如下图的S4和S5不会当选)
Leader将日志复制给超过半数Follower后,将数据应用到状态机,就返回客户端成功,Follower是什么时候把日志应用到状态机的?
Leader节点在将日志应用到状态机之前,commitIndex必然已更新。在后续leader发送心跳或者复制新的客户端日志给Follower的时候,Follower发现自己的commitIndex比leader的小,就会将更新自己的commitIndex,并将日志应用到状态机。如果领导人提交并反馈客户端以后,Follower还没提交,Leader挂了,对数据有没有影响?
对最终的结果没有影响。Leader提交了指定index上的日志,说明这条日志和它之前的日志已经复制到超过半数的节点上。Leader挂掉后,新的Leader会继续提交该日志。如问题3中的情况,S2或者S3当选新的Leader后,会继续复制term 2的日志至S4和S5。-
如果日志被复制到超过半数节点,但是Leader还没提交就崩溃了,重新选举后数据会不会被提交?
答案是不一定。初看上去,这个问题和问题5有点像。可能的疑问就是,Raft又没有一个专门的RPC请求叫提交请求,都是通过后续的心跳和日志来通知Follower的,那5和6的区别在哪里呢?这其实恰恰是Raft一致性协议的核心,就是Raft保证只要Leader把某个日志应用到状态机(即提交),那最终所有节点都会把该日志应用到状态机。但是,日志只是被复制到超过半数节点,Leader还没将其应用到状态机,Raft不保证该条日志后续一定会提交。这个问题从客户端的角度可能更好理解,在Leader将客户端某条指令提交后,回复客户端提交成功,那客户端可以确认一点,这条指令最终一定会在集群中生效;但是如果Leader收到指令后没提交就崩溃了,那客户端就肯定不会收到肯定的答复,所以也就无法知道指令最终生效没有,这个需要Raft协议的实现方来做处理,不属于Raft协议的范畴,这就是问题5和6的区别。下面通过Raft论文中举的最复杂的一个例子来加深理解:
在上图中,(a)中S1是Leader,term 2中复制新的日志到S2后崩溃了,S5在term 3当选。(b)中S5收到新的日志放在index 2后也崩溃了,S1在term 4重新当选。(c)中S1将term 2的日志复制到S3上,所以term 2的日志已经超过半数,这时候S1会不会提交term 2的日志呢,答案是不会的,Raft禁止Leader提交不属于自己周期的日志。所以在(c)的状态下可能会出现两种结果,一种是客户端在term 4发送了新的指令,S1将该指令的日志复制到了超过半数节点,然后S1就可以将commitIndex改成4,在提交term 4的同时把term 2也提交了,就是上图中的(e)。另外一种就是term 4的日志没被复制到超过半数的节点,S1又挂了,这个时候触发选举,S5会重新当选,因为它的term 3的日志是最新的,S5会把term 3的日志复制到所有节点上,就是上图中的(d)。
通过上图相信已经可以理解,日志复制到超过半数节点,不是日志肯定被提交的充分必要条件。如果在上图中的(c)中S1把term 2的日志提交了,然后又挂了,S5当选后覆盖S2和S3的数据就会造成数据不一致,因为已经提交的日志条目是不能被修改的。所以,日志被提交生效的充分必要条件是,日志已经被复制到超过半数节点,并且日志是在当前Leader的任期(term)内从客户端收到的。
现在再回顾下问题5,客户端发指令给Leader,Leader提交成功并反馈,这里面其实隐含了一个肯定成立的条件就是客户端请求和Leader反馈是在同一个任期内。
总结
Raft通过对日志复制和选举的限制相结合保证了安全性,从而满足了一致性协议的特性。下一篇将解析Raft协议关于集群扩容缩容的规范。