1、简介
容错、协调、服务发现、配置变更等是系统中的难题,分布式共识可以解决这些问题,最近几年,最出名的分布式共识算法是Paxos,但是Paxos难懂,且在工程实现上缺乏实现细节,所以提供了Raft算法,有以下优点:
1、Raft的设计、实现简单易懂
2、Raft的Leader选举机制,提供算法性能
3、Raft的成员变更机制,允许一次添加或者删除一个节点,并且允许成员变更时不停服
4、对于client交互、日志压缩等细节有着完整详细的描述
5、对于算法的安全性、规范等方面做证明
2、背景
2.1 复制状态机,用来在分布式系统中解决容错问题
复制状态机用一组复制log实现,只要包含命令的log顺序相同,就能得到确定的输出。
保证log相同时共识算法的责任,共识模块接受client的命令,加到log中,并与系统中其他节点的共识模块交互,保证所有节点的log完全一致。这样,整个系统就像单台状态机一样。
在这种场景下,共识算法需要满足以下要求:
1、在非拜占庭网络下保证安全性,包括网络延时、分区、丢包、重复和乱序
2、只要大多数节点存活,就能正常工作
3、不依赖于时序就能保证log一致性
4、少数慢节点不会影响系统整体性能
2.2 复制状态机的使用场景
1、系统中一组机器通过状态机上的状态来做成员角色发现、成员变更或者锁服务
2、用状态机存储数据
2.3 Paxos的缺点
1、难懂
2、没有一套完成的工程实现
3、基础Raft算法
3.1 为易懂而设计
raft设计目标:
1、完整的工程实现
2、有效
3、易懂
3.2 Raft简介
Raft算法总体步骤:
Raft算法特性:
首先,选一个节点作为Leader,Leader负责接受client请求,组织log,并把log复制到其他节点上,告诉其他节点何时可以把log应用到状态机中。
通过Leader机制,raft把共识算法简化为下面三个问题:
1、Leader选举:在集群启动或者Leader fail时,必须有新的Leader被选举出来
2、Log复制:Leader必须接受client请求,组织log,并把log复制到其他节点,同时使得其他节点同意接受log
3、安全性:任何节点应用一条log在状态机中,其他节点不可能在相同log index位置应用其他log
3.3 raft算法
raft算法将节点分为三种角色:
1、Leader: 处理client请求(如果client将请求发给Follower,Follower会把请求转发给leader)
2、Follower: 不处理client请求,只接受来自于Leader和Candidate的请求
3、Candidate: 用来选举Leader
三者关系如下:
raft将时间划分为term,递增整数,开始于一次Leader选举,当节点被选为Leader时,这个节点任期内的时间都为该term,如果一次选举中没有选出来Leader,term也会递增。
term作为逻辑时钟,可以用来检测过时的Leader;过时的server会更新自己term,Candidat和Leader如果发现自己的term过时了,设置为自己Follower;如果收到过时term的request,节点会拒绝该request
节点间交互通过rpc,有两种:
1、AppendEntries RPC:Leader用户复制log和维持心跳
2、RequestVote RPC:用来选举
Leader transfer时还有一种rpc
3.4 Leader选举
通过心跳机制来触发选举:
1、系统启动时,所有的节点为Follower
2、只要收到Leader、Candidate有效的rpc,节点就维持为Follower
3、Leader周期性的发送心跳给Follower来维持
4、Follower一定时间内没收到心跳rpc,则转变为Candidate,触发选举
选举开始时,Follower增加term,变为Candidate,选举自己的同时,发送RequestVote rpc给其他节点,直到:1、赢得选举;2、其他节点选为Leader;3、此次选举没有选出Leader
Candidate赢得选举的条件:获取集群中大多数节点的认可,每个节点最多投出一次选票,这样保证一个term之多只有一个Leader。在Candidate等待选举过程中,如果其他其他节点的rpc,并且term比自己大,则设置状态为Follower,如果term比自己小,则拒绝该rpc。
如果选举被拆分,则本次选举可能无法选出Leader,raft通过一个随机的election time来尽可能的避免这种情况发生。
3.5 log复制
Leader将client的请求追加到log的一个新entry中,并通过AppendEntries rpc发送给其他节点,当entry被安全复制后,Leader将entry应用到状态机中,并回复client。如果网络延迟或者丢包,Leader会不停重试发送AppendEntries rpc,直到该Follower接受entry
log entry包含term和index,term是当前Leader的任期,index标明entry在log中的位置。
Leader决定何时可以将entry应用到状态机中,这个过程叫commited,raft保证已经被commited的entry被持久化,并且被所有可用节点commited,如果log entry被commited,那么这个entry之前的entry都可以被commited,包括被之前Leader创建的entry。Leader维护最高的可以被commited的index,并在之后的AppendEntries rpc中带上这个信息,这样Follower就可以得知哪些 log entry可以被commited。
log复制机制有以下两点安全性:
1、如果两个entry有相同的term和index,那么必然存储相同的client命令(不同节点上)
2、如果两个entry有相同的term和index,那么在这个entry之前的entry也必然相同(不同节点上)
第一点保证给定一个index,只有一条entry,并且entry不会改变位置;第二点用来在log复制时,保证各个节点上log的一致性,在发送AppendEntries rpc时,Leader会带上上一条entry的term和index,Follower查找该term和index,如果没找大,则拒绝该rpc。这样,只要Follower回复成功,Leader就能得知Follower的log与Leader一致。
由于Leader、Follower的crash,会导致新Leader与Follower上的log不一致:
后续由Leader负责将Follower上的log修复与Leader一致,这点也是通过AppendEntries rpc来实现:Leader维护每个Follower下一个要发送的log的nextindex,当新lLeader当选时,nextindex初始化为Leader的最新entry的下一个entry(比如上图中的11 index),如果Follower的log与Leader不一致,会拒绝rpc,Leader会尝试减少nextindex,直到找到第一个与Follower一致的index,并且Follower删除这个index之后的entry,重新接受来自于Leader的entry。上述过程可以优化,当Follower拒绝rpc时,回复中带上Follower当前term中的第一个entry的index,这样Leader对比之后,可以大幅减少nextindex。
这样做的好处:Leader不需要感知一致性,只需要以自己为准,修复Follower不一致的log即可。
3.6 安全性
上述Leader选举和log复制机制存在安全性问题,需要对Leader选举过程增加一些限制,来保证新的Leader包含之前所有已经被commited的log entry。
3.6.1 Leader选举限制
在所有的以Leader为主的共识算法中,都要确保新当选的Leader包含之前所有被commited的log entry,即使一开始新Leader没有这些entry,也必须有想过机制,把被commited的log entry传递给Leader。
raft不需要这个传递机制,在选举过程中就保证选出来的Leader包含所有commited的log entry。Candidate在RequestVote rpc中带上自己的log,如果节点发现Candidate的log比自己“旧”,那么就拒绝投票。
raft对于log entry的新旧比较法则如下:先比较term,term大的新,如果term相同,就比较index,index大的新。
3.6.2 为之前的entry commit
新当选的Leader不确定自己log中的entry是否被commited,如果此时Leader去复制修复与自己log不一致的Follower,并且在log被大多数节点复制完后,立即去commited这些log entry,就可能会导致被大多数节点commited的log entry在之后被删除:
为了解决上述问题,raft增加一个限制:新当选的Leader不会立即去commit之前的log entry,那何时去commit这些entry呢?在新Leader第一次接受client请求,并把这个新entry成功复制到大多数节点之后,那些之前的 log entry就可以被安全的commit了。
3.6.3 安全性证明
3.7 Candidate和Follower crash
如果Follower收到相同的AppendEntries rpc,则忽略
3.8 持久化状态和server重启
需要持久化的状态:
1、log entry
2、term
3、投票信息:防止一个节点投两次票,造成脑裂
其他状态都可以在重启后丢失,然后重新初始化,特别说明的是commit index,在节点重启后被初始化为0,然后当节点被选为Leader并commit第一个entry之后,commit index就可以被设置为正确的值,同时通知其他Follower,目前正确的commit index。
状态机的状态可以持久化也可以不持久化,如果不持久化,则重启后需要回放所有log;如果持久化,需要同时持久化最后一次apply的log index,重启后从这个位置之后就绪回放log
3.9 时序和可用性
raft算法需要满足:broadcastTime ≪ electionTimeout ≪ MTBF
broadcastTime:表示rpc的时间,electionTimeout表示选举超时时间和心跳时间,MTBF表示单节点宕机间隔,这很容易满足
3.10 Leader切换
某种情况下,需要主动切换Leader,切换步骤如下:
1、旧Leader停止处理client请求
2、通过log复制机制,旧Leader将自己的log全部复制给target server
3、旧Leader发送TimeoutNow rpc给target server,target server开始一轮选举
4、新Leader选举出来之后,旧Leader收到新Leader的rpc,判断出term大于自己,旧Leader即可下线
如果上述过程超时electiontimeout,那么取消本次Leader切换,旧Leader开始重新处理client请求
3.10 结论
4、成员变更
成员变更步骤如下:
4.1 安全性
确保在成员变更过程中不会出现两个Leader,不会出现两个”大多数“
直接成员变更可能会出现两个大多数:
raft限制:每次只能添加或者删除一个节点,这样旧的配置中的大多数必然与新的配置中的大多数相交,这个相交的节点只能投一票,由此防止出现两个Leader被选举出来的情况:
过程如下:
1、收到成员变更请求,将该请求封装为一条特殊的log entry,并通过log复制机制传递给其他节点(包括新配置中的节点)
2、节点收到新配置entry,不用等该entry被commited,立即生效
3、当新配置entry被commited之后,配置变更完成
此后,新配置的entry被复制到大多数节点,哪些没有新配置entry的节点就不可能被选为Leader,如果是删除节点,被删除的节点就可以下线,同时再一次的配置变更就可以进行了。
4.2 可用性
4.2.1 新节点追赶log
在成员变更过程中,集群的可用性是脆弱的,比如:
为了解决这个问题,raft在成员变更之前加入了一个log追赶的过程,在成员变更之前,Leader先将log复制给待加入节点,此时该节点不能投票,也不能作为集群中的大多数,当log追到到某个阈值之后,就可以进行成员变更,阈值越小,可用性越高。这个阈值的确认方法:该轮log追赶的时间不超过election timeout。
4.2.2 移除当前Leader
可以先Leader切换,在移除旧Leader。
4.2.3 引起混乱的节点
被移除的节点如果不知道自己已经不在集群中,会发起选举,增加term,导致现有Leader设置自己为Follower,触发选举,这个过程持续,影响可用性。
为了解决这个问题,raft采用两个方法:
1、在选举之前,增加一个pre-check过程,Candidate通过rpc确认自己能否能获取大多数节点的选票,如果可以,才增加term,并发起选举,否则不发起选举
2、给收到RequestVote rpc的节点增加一个限制:如果该节点与当前Leader没有失联(没有超过心跳时间),则拒绝本次投票
但是这与主动Leader切换冲突,所以在主动Leader切换过程中,Candidate会在RequestVote rpc中添加一个特殊标志,说明是可以进行本次选举。
4.2.4 可用性证明
4.3 直接成员变更
推荐多次单个节点的成员变更形式,也有一次多个节点的成员变更,过程如下:
4.4 系统集成
4.5 总结
5、log压缩
随着client的请求处理,log会越来越多,空间占用、回放时间也会增加,所以需要log压缩。
不同类型的系统有不同类型的log压缩方法:
有两个核心问题要讨论:
1、统一由Leader做压缩,还是各个节点分开压缩
2、raft和状态机在删除前面log时的交互方式,一旦状态机可以从磁盘上恢复状态时,就可以删除前面log,同时raft要记录这些被删除log的信息,即term和index,这些信息在AppendEntries rpc中用来检测log的一致性,同时如果删除的log中有配置log,也要记录下来,用来做后续的成员变更
3、raft删除前面log之后,状态机需要保证:a、重启后,能够恢复到raft log中被删除log点的状态,然后再从raft log继续回放entry;b、能够提供一个一致性的状态给慢的Follower或者新加入的节点。
5.1 内存状态机
各个节点单独做snapshot,做完后,就可以删除前面log和之前的snapshot,raft需要记录最后一个被删除的entry的term和index。
有新节点加入时,通过InstallSnapshot rpc来发送snapshot给新节点,再追赶日志。
对于慢节点,收到InstallSnapshot rpc时,需要判断当前log中,哪些entry需要删除,rpc中包含该snapshot中最后一个entry的term和index,Follower删除比这个entry旧的所有log,也会删除自己的snapshot。
5.1.1 并行snapshot
内存状态机做snapshot很耗时,可以通过copy-on-write方式来并行做snapshot:
1、内存状态机通过一些不可修改的数据结构来构造
2、利用系统重copy-on-write机制,比如fork
copy-on-write时需要注意内存损耗问题
5.1.2 何时做snapshot
做的太频繁,浪费磁盘带宽;做的太不频繁,log大小过大,增加 回放log时间。
可以按照log大小阈值决定是否做snapshot。
也可以根据snapshot文件和log文件的大小比较来决定是否重新做snapshot,但是没做snapshot之前,难以确定snapshot文件大小,可以用之前的snapshot文件作为借鉴,比如 log_size >= pre_snapshot_size * factor时,就可以重新做sanpshot。
需要考虑在做snapshot时,额外的内存、磁盘带宽、CPU占用对正常用户请求处理的影响。
可能得解决方案:当Leader想做snapshot时,先切Leader,做snapshot,做完后,再切回来
5.1.3 实现时的注意点
1、生产和加载snapshot: 可以压缩sanpshot文件,并增加checksum
2、传递snapshot
3、减少不安全的log获取
4、并行做snapshot
5、决定何时做snapshot
5.2 磁盘状态机
磁盘状态机每次apply entry之后,状态就会立即生效,对应的log entry就可以被删除。这类状态机要考虑性能问题,每次apply entry需要落盘,影响性能,同时做snaopshot时可能会很耗时,同时占用较多磁盘空间。
5.3 log增量清理
LSM tree结构好处:
1、每次apply只修改小部分磁盘数据
2、写磁盘效率高,顺序写
3、生产和传输snapshot效率较高
6、与client的交互
6.1 发现集群
通过DNS服务,在成员变更时,先更新DNS,再更新成员配置,成员变更完成后,再在DNS中移除被删除的节点
6.2 路由请求到Leader
client启动时,随机选择集群一个节点发送请求,如果节点是Follower,
1、拒绝请求,并在回复中告知client的Leader地址,client路由请求到Leader,并缓存Leader地址
2、Follower自己将请求路由到Leader
在Leader转换期间,需要注意:
1、Leader:如果发生网络分区,当前Leader不在大多数中,此时Leader无法commit client的请求,也就无法处理client的请求,大多数的分区选举出一个新的Leader,但是这个Leader却无法被路由到,导致从client看来,集群不可用。所以需要增加限制:如果Leader在一个election timeout内,没有收到大多数Follower的心跳,则主动设置为Follower。
2、Follower在选取前,或者term改变时,都要放弃自己当前的Leader信息
3、Client如果与当前Leader失联,或者节点返回除了重定向以为的错误,需要从集群中随机选择一个几点继续重试
6.3 线性语义
client的请求可能被执行多次(超时重试),可以为每个client维护一个session,然后给这个client的request分配一个地址的id,状态机根据这个id来过滤该client重复的请求。但需要注意两个问题:
1、集群中所有的节点对于client的session过期要达成一致
2、当client的session过期时,但是需要发送请求,通过RegisterClient rpc,可以让client重新注册session,然后接着发送请求
6.4 提高读请求效率
读可以绕过raft log,但是直接绕过raft log会有问题,比如:网络分区,在新的分区中选出Leader,处理了一个写请求,旧Leader此时不知道,处理读请求,并返回一个过时的数据。读请求被处理时,要求节点一定要有最新的commited, write commited。
为了解决上述问题,支持支持线性语义,需要:
1、之前说过,如果新Leader当选后,不会主动去commit之前Leader的entry,需要等待自己的第一个entry被commited之后,之前Leader的entry才会被commited,为了减少这个等待,通过提交一个空的no-op entry来实现
2、当前Leader维护一个readindex,该值和最新的commit index相等
3、Leader发起一轮心跳rpc,一旦大多数节点响应,就可以保证,当前集群中没有新leader,并且此时的readindex是集群中所有节点能够看到的最大的commit index。
4、Leader等待自己的状态机应用到最新的readindex
5、最后,Leader处理读请求
上述做法比把读请求当做raft log entry然后走raft流程更高效。
Follower节点也可以承担部分读流量,以此来提升系统的整体吞吐,Follower节点的状态机可能是过时的,为了解决这个问题,Follower接受到读请求时,先通过rpc查询Leader当前的readindex,Leader执行上述1-3步,然后4、5步由Follower执行
6.4.1 用clocks在读期间减少信息交互
只有Leader在一次心跳重获取到大多数节点的成功回复,那么久可以认为,在未来的election timeout内,不会有新的Leader被选举出来,同时这个Leader的信息就是最新的,那么在这段时间内处理读请求时,就可以省略上述第三步,避免多次网络交互,提高读请求的性能。
所以只要心跳rpc一直是正常的,那么在读请求时就不需要交互,但是一旦超过election timeout之后,Leader仍然没有获取大多数的节点成功回复,那么久不能再处理读请求。
server在回复client请求时,会带上当前已经应用到状态机中的最新index,client会维护这个最新的index,在后续请求中带上这个index,如果有节点收到client请求时,发现client中的index大于自己最新应用到状态机中的index,旧拒绝该请求。这样,在Follower上处理读请求时,大多数情况下可以省去上述中的向Leader查询这一步。