一致性协议(一)、Raft算法
分布式技术:
分布式事务(一)、CAP,BASE理论
分布式事务(二)、刚性事务之 2PC、3PC
分布式事务(三)、柔性事务之 TCC、Saga、本地消息表、事务消息、最大努力通知
前言
目前的数据中心和服务运行在非常复杂的环境内,经常会遇到各种情况如机器宕机、网络延迟、磁盘坏道等问题,随着数据爆炸和移动互联网的发展,如何在出现这些问题的时候不影响使用是进入21世纪后愈发需要解决的问题。因此出现了分布式一致性算法,它可以将多台机器组成集群运行并允许其中一部分节点故障而不影响服务,保障系统高可用。
很多人都听说过鼎鼎大名的Paxos算法,在过去的二十年里,Paxos算法在分布式一致性领域处于主导地位,大部分的实现要么基于Paxos,要么会受它的影响,而且Paxos也是教授学生一致性的主要算法。然而Paxos太难理解且难以实现。因此斯坦福大学的两位教授Diego Ongaro和John Ousterhout决定设计一种更容易理解的一致性算法,最终在论文"In search of an Understandable Consensus Algorithm"中提出了Raft算法。
Raft算法的目标是易于理解(UnderStandable),相比于Paxos算法,Raft算法也更易于在工程上实现,在实际部署中的表现良好,它解决了部署完整系统所需要的一切,包括管理客户端的交互、管理集群成员关系、日志压缩等。为了可以改变集群成员,Raft允许增加和删除集群中的节点,并且保证集群在这个过程中可以不中断服务。
相关链接:
Raft算法:https://raft.github.io/
Raft算法论文:https://raft.github.io/raft.pdf
Raft算法动画:http://thesecretlivesofdata.com/raft/
复制状态机
一致性算法通常基于复制状态机(Replicated State Machine),即多个机器拥有状态的多份副本,并能在一些机器故障时不中断的提供服务,从而解决分布式系统中的各种容错问题。比如分布式存储系统中GFS的Chubby 和HDFS的Zookeeper 都做为复制状态机存储集群配置信息和管理Leader选举。
在复制状态机的架构中,每个server中包含:
一致性模块(Consensus Module):复制状态机的核心,接收客户端的请求并记录到日志内,并决定何时由状态机执行该命令;
日志(Log):以相同的顺序保存相同的命令;
状态机(State Machine):顺序执行日志中的命令;
从图上也可以看出,由于每台server中的日志记录着相同顺序的相同命令,且状态机会顺序执行日志内的,因此只要保证各server中的日志一致就能达到最终一致性,这就是一致性算法要做的事情。server中的一致性模块接收来自客户端的命令,将其添加到自己的日志中,并和其他server中的一致性模块通讯确保它们的日志中也记录了相同的内容(即使有部分server宕机)。当命令被正确的复制,每个server中的状态机会按照日志中的顺序执行命令并将结果返回给客户端。
在实际应用中一致性算法通常满足如下属性:
在任何情况下能够保证安全性(不返回错误的结果),包括网络延迟、分区、丢包、重复、失序;
只要大多数节点可以正常工作和通讯就能够保证完整的可用性。因此,一个典型的拥有5台机器的集群可以容忍两台机器的故障。当server恢复后可以读取持久化的状态并重新加入集群中。
错误的时钟可能会引起消息延迟导致不可用,因此不依赖于时间来确保日志的一致。而是以异步的方式来保证安全性,消息以任意的速度来执行。
通常情况下,只要大多数节点对RPC调用进行了响应就可以认为命令执行完成,一小部分速度慢的server也不会影响到整个系统的性能。
复制状态机的应用
通常会使用三或五台机器来部署一个复制状态机的集群,其余server可以通过于该集群通讯来协调它们的活动,如图a,这些系统通常使用复制状态机来提供集群成员管理、配置管理、锁等。另一个场景如图b,一个server作为Leader,管理其他的server,Leader将一些重要的数据存储在一致性系统中,如果Leader故障,会从其他standby server中竞争选出新的Leader,一旦成功就可以继续使用一致性系统中的数据继续操作,如HDFS的namenode就是这样做的。
还有一种场景如上图,在超大数据存储系统中,为了可扩展性会将数据分散到多个复制状态机上,利用二阶段提交(2PC)来管理多个复制状态机的一致性。
Raft算法
Raft是一个管理日志一致性的算法,Raft算法的目标是易于理解,为了提高可理解性,Raft算法主要做了两件事情:
问题分解:将集群中节点一致性这个问题分解为几个独立的子问题:Leader选举、日志复制、安全性;
状态简化:对算法做一些限制,减少状态空间中的状态数目,使得算法更加清晰;
【角色】
Raft算法将系统中的节点处于下面三种角色之一:
领导者(Leader):接收客户端请求,并向Follower同步请求日志,当日志同步到多数节点上后告诉Follower提交日志。
跟随者(Follower):接收并持久化Leader同步来的日志,当Leader通知日志可以提交之后,再提交日志。
候选者(Candidate):选举过程中的临时角色,或被选举为新的Leader或恢复为Follower。
上图显示了系统中的节点角色的切换:在初始化启动的时候所有的节点都是跟随者,在一段时间内如果没有收到来自领导者的心跳,跟随者会切换到候选者然后发起选举并给自己投一票。如果收到多数的选票(含自己的一票)则切换到领导者角色,如果发现其他候选者节点比自己的选票更高,则主动切换到跟随者。
Raft 算法保证在给定的一个任期最多只有一个领导者,如果在一段时间里发现没有领导者,则大家通过选举机制选出领导者。领导者会不停的给跟随者发送心跳消息,表明自己的存活状态。如果领导者发生故障,跟随者会转换成候选者,重新发起选举并选出新的领导者。
【任期(Term)】
Raft 算法将时间划分成为任意不同长度的任期(term)。任期用连续的整数进行表示。每一个任期的开始都是一次选举(election),一个或多个候选者会试图成为领导者。如果一个候选者赢得了选举,它就会在该任期担任领导者。在某些情况下,选票会被分散,可能没有选出领导者,然后将会开始一个新的任期,并且立刻开始下一次选举。
任期(term)在Raft中起到了逻辑时钟的作用,它可以帮助server检测过期信息比如过期的领导者。每一个server都存储有当前任期(Current Term)字段,会自动随时间增加。当server间进行通讯时,会交换current term信息,如果一个节点的current term比另一个小,则任期号小的节点会自动将term更新为较大者。如果候选者或者领导者发现了自己的term过期了,它会立刻转为跟随者角色。如果一个节点收到了一个含有过期的term的请求,它会拒绝该请求。
【远程过程调用(RPC)】
Raft 算法中节点之间使用远程过程调用(RPC:Remote Procedure Call)进行通讯,基本的一致性算法只需要两种 RPC,为了在服务器之间传输快照,Raft增加了第三种 RPC:
RequestVote RPC:由候选人在选举期间发出。
AppendEntries RPC:由领导者发出,用于复制日志和提供心跳。
InstallSnapshot RPC:领导者使用该RPC来发送快照给term太落后的跟随者或新加入的跟随者。
每一个请求类型都有对应的response,Raft假定request和response都可能会丢失,因此要求请求者有超时重试的能力。为了性能,RPC请求会并行发出,而且不保证RPC的到达顺序。
Leader选举(Leader Election)
Raft使用心跳机制来触发Leader选举,如果跟随者在election timeout 内没有收到来自领导者的心跳,则它会假定领导者发生故障并会主动发起选举,该跟随者会进行以下步骤:
增加该跟随者本地的 current term;
转为候选者身份;
给自己投一票;
向集群中其他节点发出RequestVote RPC;
等待其他节点的回复;
在等待其他节点的回复时,该候选者会一直保持这个状态直到出现下面三种情况之一:
赢得选举:当候选者收到了集群中多数选票(含自己的一票)时就会选举成功并成为领导者,然后该领导者会发送心跳消息来避免其余节点触发新的选举。
其他候选者赢得选举:在等待投票的过程中,候选者可能收到来自其他节点的AppendEntries RPC,声明它才是领导者。如果RPC中的term大于等于该候选者的current term,该候选者就会认为这个领导者是合法的并转为跟随者角色。如果RPC中的term比自己的current term小,将会拒绝这个请求并保持候选者角色。
没有获胜者产生:等待选举超时。如果许多跟随者同时变成候选者,选票就会被瓜分(split vote),可能会出现平票的情况,形不成多数派。这种情况发生时,候选者将会超时并触发新一轮的选举,提高term并发送新的RequestVote RPC。
当出现平票情况就等于延长了系统不可用的时间(没有leader是不能处理客户端请求的),因此Raft使用随机选举超时来确保平票的情况很少出现而且即使出现了也可以被很快地解决。同时,在leader-based 共识算法中,节点的数目都是奇数个,尽量保证majority的出现。
随机选举超时(randomized election timeout):每一个Follower都有一个时钟,是一个随机的值(如:150–300ms),表示该Follower等待成为Candidate的时间,在没有收到Leader的心跳的跟随者中谁的时钟先跑完,则该节点成为Candidate发起Leader选举。同时,Candidate在发起选举前也会重置自己的随机election timeout,帮助减少新的选举轮次内选票瓜分的情况。
在选举期间,参与投票的节点在进行投票时候有以下约束:
在同一任期内,每个节点最多只能投一票;
候选人记录的日志不能比自己的少;
先来先得(first-come-first-served),哪个候选者的RequestVote RPC先发来就投给谁;
随机选举超时动图:
如下图,在Term0时,A,B,C都是Follower,由于节点B的election timeout先走完然后它进入term1并给自己投一票然后发起选举,之后它获得了多数选票成为Leader。
平票动图:
如下图,在Term4时,节点D和节点C成为候选者重置自己的election timeout并给自己投一票,然后分别得到了A和B的投票,此时D和C都有2票,出现了平票的情况,然后重新等待节点的election timeout触发新一轮的选举,此时节点B成为候选者进入Term5并给A,C,D节点发送RequestVote RPC等待投票,之后B获得了三票成为Leader。
日志复制(Log Replication)
当Leader被选举出来后,它开始接收客户端的请求。每一个客户端的请求都包含着一个由状态机执行的命令,Leader会将这个命令作为新的一条日志追加到自己的日志中,然后并行的向其他节点发出AppendEntries RPC来复制日志。当日志被安全的复制(committed)之后,Leader可以将日志apply到自己的状态机,并将执行结果返回给客户端。如果Follower宕机或运行很慢,甚至丢包,Leader会无限的重试RPC(即使已经将结果报告给了客户端),直到所有的Follower最终都存储了相同的日志,从而保证最终一致性。
日志按下图的方式进行组织,每一条日志储存了一条命令和Leader接收到该指令时的term序号。日志中的term序号可以用来检测不一致的情况,每一条日志也拥有一个整数索引用于定位。
committed:日志被复制到大多数节点后日志的状态;
apply:节点将日志应用到状态机,真正影响到节点状态;
Leader会追踪已经committed的最高的日志索引,并将这个索引放入之后的AppendEntries RPC,以便于其他Follower可以最终发现,当Follower意识到一条日志被committed了,它就会将其apply到自己的状态机。
Raft日志机制可以保证不同server上的日志具有很高的一致性,Raft通过下面两个特性保证日志复制的一致性:
如果不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。
如果不同日志中的两个条目有着相同的索引和任期号,则它们之前的所有条目都是完全一样的。
Leader在给定的index和term处至多只会创建一条记录,并且新的记录不会改变之前的位置,因此可以确保上面的第一条。第二条是通过AppendEntries的一致性检查实现的,当发送AppendEntries RPC的时候,Leader会将之前最新日志的term和index包含在其中,如果Follower没有在自己的日志中找到相同的index和term,它就会拒绝这条请求。因此只要AppendEntries RPC返回成功,Leader就会知道Follower的日志直到最新的这条都和自己一样。
日志的不一致情况
一般情况下,Leader和Followers的日志保持一致,因此 AppendEntries 一致性检查通常不会失败。然而,Leader崩溃可能会导致日志不一致:旧的Leader可能没有完全复制完日志中的所有条目。
下图阐述了一些Followers可能和新的Leader日志不同的情况。一个Follower可能会丢失掉Leader上的一些条目(a,b),也有可能包含一些Leader没有的条目(c,d),也有可能两者都会发生(e,f)。丢失的或者多出来的条目可能会持续多个任期。
当出现上面的日志不一致的情况时,Leader会强制让Follower复制它的日志来处理日志的不一致,Follower上的不一致的日志会被Leader的日志覆盖。Leader为了使Follower的日志同自己的一致,Leader需要找到Follower同它的日志一致的地方,然后覆盖Follower在该位置之后的条目。
详细操作为:Leader会从后往前试,每次AppendEntries失败后尝试前一个日志条目,直到成功找到每个Follower的日志一致位置点(基于上述的两条保证),然后向后逐条覆盖Follower在该位置之后的条目,以达到主从日志的一致性。
安全性(Safety)
前面介绍了Raft如何进行Leader选举和复制日志,但目前为止所描述的这些机制还不能充分确保每一个状态机都以相同的顺序执行相同的指令。例如,在leader提交了几条日志的过程中,某个Follower始终不可用,之后该Follower被选举为Leader并重写了原来Leader的这些日志,结果导致不同的状态机可能执行了不同的命令。
因此在选举过程增加一些限制来完成Raft算法,这个限制可以确保Leader在给定的term都含有全部已提交的日志。通过选举限制使得日志提交的规则更加精确。:
1. 选举约束:拥有最新的已提交的日志的Follower才有资格成为Leader。
该限制由RequestVote RPC实现:RPC请求包含着candidate的日志信息,其他节点如果发现自己的日志比candidate的日志更新,就回拒绝该请求。Raft通过比较最后一条日志的index和term来决定谁更新一些,如果term不一致则拥有更大的term日志更新,如果term一样,则index更大的日志更新。
2. Leader只能提交当前term的已经复制到大多数服务器上的日志,一旦当前term的日志committed,那么之前的term也会被间接提交。
跟随者和候选者的宕机处理
跟随者和候选者的宕机很好处理,当Follower或Candidate发生故障,之后发给他们的RPC请求会失败。Raft会进行无限重试,如果Follower和Candidate重启了,RPC请求将会执行成功。如果server完成了RPC请求,但是响应丢失了,Leader也会重新发送RPC请求,节点收到重复请求是没有影响的,比如收到了重复的log entry,它会忽略这些日志。
时间和可用性
Leader选举对时间非常敏感,当满足下面的式子时,Raft可以选出并保持一个稳定的Leader:
broadcastTime << electionTimeout << MTBF
broadcastTime:节点并行向其他所有节点并行发送RPC请求并获得响应的平均时间;
electionTimeout:选举超时;
MTBF:单个节点两次宕机间隔的平均时间。
broadcastTime应当远小于electionTimeout,从而Leader可以依靠心跳来阻止Follower发起选举;
给electionTimeout引入随机性可以避免选票瓜分;
electionTimeout应远小于MTBF,从而使得系统可以平稳工作;
当Leader宕机时,系统将仅会在electionTimeout的时间内不可用。
broadcastTime和MTBF都受到底层系统的影响,但是electionTimeout是我们必须谨慎选择的。Raft的RPC请求一般会需要持久化存储,因此broadcastTime一般需要0.5-20ms,依赖于持久化技术。因此electionTimeout最好选择在10-500ms之间。MTBF值一般是几个月。
集群成员变更
Raft允许集群在配置更新的时候可以继续工作,并且仅仅给基本的Raft算法增加一些扩展就可以实现。
挑战:保证配置更新期间的安全性是面临的第一个挑战,必须确保在同一个term中不会有两个leader被选举出来。如果直接更新配置,增加或者删除集群中的servers,直接将集群从旧的配置更新到新的配置是不安全的,可能会出现两个leader,形成脑裂的情况。
解决方式:当向集群添加或移除一个节点时,老的集群节点形成的多数派和新集群节点形成的多数派,必然会发生重叠,也就是说必然只能形成一个多数派,这个重叠避免了脑裂。因此只添加或删除一个节点时,可以直接更新配置。Raft利用此属性,每次成员变更只允许增加或删除一个成员(如果要变更多个成员,连续变更多次),几乎不需要其他机制即可安全的更改集群成员的身份。
日志压缩
Raft日志会随着不断处理客户端的请求而变大,会占用越来越多的空间而且重放日志也需要更多的时间。如果不采取一些压缩日志的方式,最终会造成一些可用性的问题比如:机器可能会耗尽空间,或者花费太多的时间来重启。因此对于实际系统来说日志压缩是必须的。
每一个server都独自压缩自己的已committed的日志,而不是将日志压缩任务集中在Leader,这避免了通过网络传输大量的冗余数据给Follower。 大部分的日志压缩操作集中于状态机而不是Raft本身,这可以使得整个系统的复杂度最小。
状态机和Raft之间的交互都需要将日志从raft传输到状态机。在apply这些日志entry后,状态机会将这些日志存到磁盘,便于以后恢复系统状态。一旦状态机完成,就会通知Raft把这部分日志丢弃掉,在丢弃之前,它必须保留一些描述丢弃掉的日志的状态信息。通常Raft会保留丢弃掉的最后一条日志的index和term,从而可以使得AppendEntries的一致性检查继续工作,Raft也需要保留丢弃的日志中的最新的配置信息来支持配置更新。
一旦Raft丢弃了之前的日志,状态机就会担负起另外两种新的责任。如果server重启,在状态机可以apply Raft日志之前需要从磁盘加载这些数据,另外状态机也需要产生一个满足一致性的数据快照,以便于可以将其发送给较慢的Follower。当Raft监测到AppendEntries中需要发送的next entry已经被丢弃掉了,此时状态机必须提供快照以便发送给Follower。
每一个server单独拍摄快照,仅包括自己日志中committed部分。一旦状态机完成了snapshot,日志就可以被截断。Raft首先会持久化重启所需要的状态,最后一条日志的index、term和所属的config,然后丢弃在此index之前的日志。任何之前的快照也会被丢弃 。
当follower收到snapshot,它必须决定如何处理它当前的日志。通常快照会包含follower当前没有的新日志,这种情况下follower只需要全部丢弃。当然如果收到的snapshot数据不如follower自己的新,它会保留在这之后的日志,而把之前的用snapshot覆盖掉。
问:什么时候进行快照?
答:如果快照进行的太频繁,会浪费磁盘带宽和其他的资源,如果快照太少,将会使得存储空间耗尽的风险增大,也增加了重启后日志重放的耗时。当前日志的大小超过了上次快照体积的一定倍数,就需要进行快照了,这个倍数称为expansion factor,是可配置的。
expansion factor需要在磁盘带宽和空间使用上寻求平衡,例如:expansion factor为4的时候,快照会占用20%的磁盘带宽,以及6倍的磁盘空间,因为需要数据的一份拷贝(原来的快照+新的快照+4倍大小的日志)。
做一次snapshot可能耗时过长,会影响正常日志同步,可以利用copy-on-write技术在不影响快照写入的情况下apply新的更新。
客户端交互
【发现集群节点】
客户端可以利用网络广播找到所有的集群节点。
客户端可以通过额外的目录服务来发现集群节点,如DNS。
【请求Leader节点 】
同样,由于客户端的请求都是由Raft集群中的Leader负责接收的,因此客户端需要从集群节点内找出Leader节点。当服务首次启动时,客户端会随机选择集群的一个节点进行连接,如果该节点不是Leader,则它可以以下面两种方式进行操作:
节点将客户端的请求代理到Leader。这在某些场景下更简单如:当一个客户端只需要读请求的时候,代理请求可以帮助客户端节省管理连接的成本。
在实现客户端连接时需要注意的点:
Leader:当出现网络分区(脑裂)的情况下,集群中会出现2个Leader,但其中有一个Leader无法提交日志,连接该Leader的请求会被无限延迟,因此当在一个election timeout之内没有成功完成一轮心跳到大部分节点的话,它就应当下线了,令客户端可以再向其他节点重试请求。
Follower:Follower中保存当前Leader的信息,当发生新的选举或term更新时,Follower必须丢弃当前Leader的信息,避免请求在两个节点之间来回发送造成死循环。
【线性化语义】
为避免由于网络等原因导致的请求已经被执行但客户端没有接收到响应而发出重复的请求的情况。如下图展示了一种死锁现象:状态机用于提供锁机制,当客户端的请求没有收到相应的响应时它会认为没有获得锁,当它重新发送请求时会被告知锁已经被拿了,但是事实上可能就是它获得了该锁。
Raft利用线性化语义令每个请求只能执行一次,对于客户端来说这是一种强一致性。为了实现线性化,客户端必须能够过滤出重复请求。基本的思路是server保存客户端操作的执行结果,以便于收到相同请求时可以直接返回避免再次执行。具体方式如下:
每一个客户端会分配一个UID,并且客户端给每一个命令分配一个序号。
每一个server对每一个client维护一个session,session可以追溯到最新处理的客户端命令序号,以及对应的响应,如果收到的序号已经被执行过了,可以将响应直接返回。
这种方式通常也适合于单客户端的并发访问,session内包含着一系列的(请求序号,响应)对,对于每一个请求,客户端保存着尚未收到响应的最小的序号,并且状态机也会丢弃比它更小的所有响应。然而,由于空间是有限的,session不可能一直保留,server最后必须关闭session,但servers必须就何时关闭session达成一致,否则状态机就会出现状态不一致。
【更加有效地处理只读请求】
对于只读的请求只会查询复制状态机,不会改变状态,因此是否可以绕过日志呢?如果可以绕过追加日志条目的磁盘写操作自然会对性能有很大的提升。然而如果没有其他措施,绕过日志的只读查询可能会导致返回过期的结果,比如当出现网络分区(脑裂)的情况,被分区的Leader不咨询其他节点就对只读请求返回响应,它就会返回过期的结果。
线性化要求只读请求返回的结果能够反映的是只读请求触发之后的系统的状态,每一个读请求至少应该返回最新committed的写操作后的状态。为了实现对只读请求绕过日志并且实现线性化,Leader需要采取下面的步骤:
Raft让每个Leader在其任期刚开始的时候提交一个空的日志条目,只要这个日志被提交了,Leader的Commit Index就可以保证是当前term内最大的index。
Leader保存当前的Commit Index为一个readIndex变量,用来作为查询操作的版本下限。
Leader需要确保它没有被不知道的新Leader所取代,它会发起新一轮的心跳并等待来自集群大多数节点的响应。一旦这些响应到达,Leader就会知道在它发出心跳的时刻不会存在另一个term更大的Leader。因此,readIndex就是那时集群中最大的Commit Index。
Leader等待状态机至少前进到readIndex处,这是满足线性化要求的。
最后Leader向状态机发起查询并将结果返回客户端。
这种方式相比于将只读操作提交到日志要更加高效,因为它不需要同步磁盘写操作。为了进一步提高效率,Leader可以将"确认自己是Leader"的开销进行分摊,比如对累计的任意数量的只读操作使用单次心跳,Follower也可以分担只读请求的压力,这样既可以提高系统的读吞吐量,也会减少Leader的负载压力,使得Leader可以处理更多的写操作。
然而这样也面临着由于网络分区(脑裂)导致的读取过期数据的风险,为了可以安全的读取,Follower可以向刚才请求当前readIndex的Leader发起请求(Leader会执行步骤1~3),然后Follower可以在自己的状态机执行步骤4和5。
希望本文对你有帮助,请点个赞鼓励一下作者吧~ 谢谢!