Raft协议

Raft协议


原文链接: Raft原文

1. 什么是Raft

  • 相比于Paxos,Raft最大的特性就是易于理解(Understandable)。为了达到这个目标,

  • Raft主要做了两方面的事情:

    1. 问题分解:把共识算法分为三个子问题,分别是领导者选举(leader election)日志复制(log replication)安全性(safety)

    2. 状态简化:对算法做出一些限制,减少状态数量和可能产生的变动。

• 论文中对43个大学生做了个实验,让他们同时学习Paxos和Raft,结果显示,其中有33个人学习Raft的成绩好于学习Paxos的成绩

2. 复制状态机和状态简化

2.1 复制状态机

在具体介绍Raft之前,我们要先了解一下复制状态机(Replicated state machine)的概念。

相同的初始状态 + 相同的输入 = 相同的结束状态

  • 多个节点上,从相同的初始状态开始,执行相同的一串命令,产生相同的最终状态

• 在Raft中,leader将客户端请求(command)封装到一个个log entry中,将这些log entries复制到所

有follower节点,然后大家按相同顺序应用log entries中的command,根据复制状态机的理论,大家的结束状态肯定是一致的。

image-20240520180432075

这是原论文中的案例:client给leader发送了一个命令(Consensus Module),leader 生成若干各log entry,并且发送给其他follower,所有的follower将这些log应用到自己的状态机中(State Machine),生成一致的状态。

这样无论client查询哪个节点的状态机,只要这个节点正常应用了日志,其查询到的结果都是相同的

  • 可以说,我们使用共识算法,就是为了实现复制状态机。一个分布式场景下的各节点间,就是通过共识算法来保证命令序列的一致,从而始终保持它们的状态一致,从而实现高可用的。(投票选主是一种特殊的命令)

    复制状态机作为一种抽象的概念,其复制状态机的功能可以更加强大。比如两个副本一个采用行存储的数据结构存储数据,另一个采用列存储,只要它们初始数据相同,并持续发给他们相同的命令,那么同一时刻从两个副本中读取到的结果也是一样的

    行存储更实用于OLTP时的交易,而列存储更适应于OLAP的交易,这种行列混合的存储形式,就是一种HTAP的实现方法(比如TiDB)

2.2 状态简化

三大状态
  • 在任何时刻,每一个服务器节点都处于leaderfollowercandidate这三个状态之一

  • 相比于Paxos,这一点就极大简化了算法的实现,因为Raft只需考虑状态的切换,而不用像Paxos那样考虑状态之间的共存和互相影响

    image-20240520181742640

    如上图所示,任何一个节点启动的时候都是follower状态,如果这个节点察觉到集群中没有leader,会将自己从follower状态切换成candidate状态,在candidate中经历一次或者多次的选举,最终会根据选举结果决定自己切换到leader状态,或者是切换到follower状态。

    如果选举成功,则切换到leader状态,并且为客户端提供服务。如果他在leader状态的任期结束,或者自身宕机或者发生其他问题,那么他会切换回follower状态, 并且进行下一个循环。

任期
  • Raft把时间分割成任意长度的任期(term),任期用连续的整数标记(通常为int类型)。
  • 每一段任期从一次选举开始。在某些情况下,一次选举无法选出leader(比如两个节点收到了相同的票数,如t3任期),在这种情况下,这一任期会以没有leader结束;一个新的任期(包含一次新的选举)会很快重新开始。Raft保证在任意一个任期内,最多只有一个leader。

image-20240520183457381

任期机制可以非常明确地标识集群的状态,并且通过任期的比较,可以帮助我们确认一台服务器历史的状态。比如说我们可以通过查看一台服务器是否具有t2任期内的日志,来判断它在t2时间段内是否出现过宕机。

2.3 RPC通信

Raft算法中服务器节点之间使用RPC进行通信,并且Raft中只有两种主要的RPC:

  • RequestVote RPC(请求投票):由candidate在选举期间发起。
  • AppendEntries RPC(追加条目):由leader发起,用来复制日志和提供一种心跳机制。
  • 服务器之间通信的时候会交换当前任期号;如果一个服务器上的当前任期号比其他的小,

该服务器会将自己的任期号更新为较大的那个值。

  • 如果一个candidate或者leader发现自己的任期号过期了(也就是收到的rpc的任期好比自己当前的任期号要大),它会立即回到follower状态。
  • 如果一个节点接收到一个包含过期的任期号的请求,它会直接拒绝这个请求。

3.领导者选举

3.1 概念
  • Raft内部有一种心跳机制,如果存在leader,那么它就会周期性地向所有follower发送心跳,来维持自己的地位。如果follower一段时间没有收

到心跳(根据心跳超时计数器),那么他就会认为系统中没有可用的leader了,然后开始进行选举。

  • 开始一个选举过程后,follower先增加自己的当前任期号,并转换到candidate状态。然后投票给自己,并且并行地向集群中的其他服务器节点

发送投票请求(RequestVote RPC)。

image-20240520192456870

例如s5是leader,会向其他的server发送心跳消息。如果一个server在他的心跳超时计时器结束时,仍没有收到s5的消息,则他会认为系统中没有可用的leader,然后开始进行选举。

  • 对于一个Candidate来说,其投票之后最终会出现三种结果:

    ①它获得超过半数选票赢得了选举 -> 成为主并开始发送心跳

    ②其他节点赢得了选举 -> 收到新leader的心跳后,如果新leader的任期号不小于自己****当前的任期号,那么就从candidate回到follower状态。

    ③一段时间之后没有任何获胜者 -> 每个candidate都在一个自己的随机选举超时时间

    增加任期号开始新一轮投票。

    • 为什么会没有获胜者?比如有多个follower同时成为candidate,得票太过分散,没有任何一个candidate得票超过半数。

      注意,当前选举阶段没有产生任何leader,不需要集群中的所有节点对此产生共识,而是通过每个candidate都在等待一个随机选举超时时间之后,默认去进入下一个选举阶段。

    论文中给出的随机选举超时时间为 150~300ms。这意味着如果candidate没有收到超过半数的选票,也没有收到新leader的心跳,那么它就会在150-300ms之间随机选择一个时间再次发起选举(即增加任期号,投票)

    如果要在现在服务器运用raft算法,对于这个选举超时时间还需要根据自身的网络和服务器性能进行一些调整。

3.2 请求投票RPC

image-20240520194257096

  • Request由candidate发器,reponse由follower回复candidate
  • RPC中需要通过任期号来确定自身的状态,以及判读接不接受这个RPC。
  • candidateId ,follower需要知道自己投票给谁
  • follower的投票逻辑:
    • 收到一个requestVoteRequest之后先校验这个candidate是否符合条件:1.term是否比自己大;2.是自己最后一个日志号和自己的最后一个任期,确认无误后就可以投票了,并且每个follower只有一张选票,按照“先来先得”的原则投出。
    • 为什么RequestVote RPC中要有candidate最后一个日志的信息呢,安全性子问题中会给出进一步的说明。
3.3 Raft的选举过程解析

其选举过程主要包含两个计时器:

  • 选举计时器:控制一个节点从跟随者变成候选者的时间,随机被初始化从150ms-300ms之间
  • 心跳计时器:控制任期内leader向follower发送的心跳信息
    • 当一个跟随者的选举计时器超时,其会变成候选者,并且发起一个新的选举周期
image-20240520195500907

例如:这里的图C节点选举计时器超时,成为候选者,并且发起一个新的选举周期,并且周期号加1,并给自己投上一票:Term:1,Cnt:1。C节点会发送请求投票消息给其他节点,消息中带有这个投票周期的周期号,如果接收到消息的节点,在这个投票周期内还没投过票,它就会给这个候选者投票,并且重启自己的选举计时器。若C收到多数节点的投票,如图中C收到了A、B投票,它就会成为领导者。然后C就成为了这个领导者,会周期性的发送Append Entries(心跳信息)给他的followers,followers收到Append Entries(心跳信息)后会回复确认给领导者,这个选举周期会一直持续不变,知道某个跟随者在心跳计时器超时内都没有收到leader的心跳信息,并成为了跟随者,重新发起领导选举。

  • 如果C崩溃,两个节点A、B同时超时,平票

    image-20240520200450468

若A、B同时超时,则平票,等到选举计时器超时,进入下一轮选举;因为选举计时器时随机的,下一轮选举可能A先超时,并且获得B的选票,因此可以成为leader。

4. 日志复制

4.1 含义
  • Leader被选举出来后,开始为客户端请求提供服务。

  • 客户端怎么知道新leader是哪个节点呢?

    • 若刚好为leader节点,则直接提供服务
    • 若为follower,那么可以通过心跳得知leader的ID,然后可以告知client该找哪个节点。
    • 假设client找到的节点宕机,那么只能再去寻找下个节点,重复过程。
    • 只要集群中有超过半数的节点可用,rafts就能够正常提供服务。
  • Leader接收到客户端的指令后,会把指令作为一个新的条目追加到日志中去。

  • 一条日志中需要具有三个信息:

    • 状态机指令:通常是对某个值做的操作,如x<-3
    • leader的任期号:日志中包含任期号,对于检测多个日志副本之间的不一致的情况,和判定节点状态,有重要作用,下图每个方块的数字都是一个任期号,不同的任期号用不同的颜色表示。
    • 日志号(日志索引):日志索引值标志日志的位置,下图的最上方可以看到,每个日志号都是唯一的。
image-20240520201728722

只有日志号和任期号两个因素才能唯一确定一个日志。

4.2 日志复制的核心
  • 生成日志之后,Leader就会将日志放到AppendEntries中,并行发送AppendEntries RPC给follower,让它们复制该条目。当该条目被超过半数的follower复制后,leader就可以在本地执行该指令把结果返回客户端
    • 在所有服务器不出错,且网络状态正常的情况下,这个机制可以保证所有节点具备完整且正确的日志。
  • 我们把本地执行指令,也就是leader应用日志与状态机这一步,称作提交。
  • 日志复制到超过半数的节点之后,一定会提交呢?
    • 不是的,follower复制完成,到follower通知leader,再到leader完成提交,是需要时间的。这个时间内如果leader如果宕机了,则这条日志虽然复制超过了半数的节点,但是却无法提交。
image-20240520201728722

上述这张图,第一行是leader,后面这几个是follower,可以看到每个follower的进度是不一样的;在这里只要包括leader在哪,有超过半数的节点复制到了日志,leader可以提交了。图中可以提交日志的位置是log index=7的位置,因为有三个日志节点复制到了7,就构成了大多数。

  • 可以看到有很多的follower的日志进度落后leader,如何保证follower能够追上leader并且保证日志进度一致呢?(在此过程中,leader或follower随时都有崩溃或缓慢的可能性,Raft必须要在有宕机的情况下继续支持日志复制,并且保证每个副本日志顺序的一致)分如下情况讨论:

    具体有三种可能:

    • follower缓慢:

      如果有follower因为某些原因没有给leader响应,那么leader会不断地重发追加条目请求(AppendEntries RPC),哪怕leader已经回复了客户端(le ader已经提交日志,leader仍然不会抛弃follower,其仍需要不断的重发)。

    • follower宕机:如果有follower崩溃后恢复,这时Raft追加条目的一致性检查生效,保证follower能按顺序恢复崩溃后的缺失的日志。

      • 其中可能经过多种状态:有可能经过了多次选举,更换多个leader;并且follower恢复后的状态也是未知的,当前leader并不知道它宕机前的日志复制到了哪里。这时候需要通过Raft通过一致性检查实现这个功能。
      • Raft的一致性检查:leader在每一个发往follower的追加条目RPC中,会放入前一个日志条目的索引位置和任期号,如果follower在它的日志中找不到前一个日志,那么它就会拒绝此日志,leader收到follower的拒绝后,会发送前一个日志条目,从而逐渐向前定位到follower第一个缺失的日志
        • 如果想要的话,该协议可以被优化来减少被拒绝的AppendEntries RPC的个数。
          1. 例如,当拒绝一个AppendEntries RPC的请求的时候,follower可以包含冲突条目的任期号和自己存储的那个任期的第一个index。
          2. 借助这些信息,leader可以跳过那个任期内所有冲突的日志条目来减小nextIndex;这样就变成每个有冲突日志条目的任期需要一个AppendEntries RPC而不是每个条目一次。
          3. 在实践中,认为这种优化是没有必要的,因为失败不经常发生并且也不可能有很多不一致的日志条目
    • leader宕机:

      • 如果leader崩溃,那么崩溃的leader可能已经复制了日志到部分follower但还没有提交,而被选出的新leader又可能不具备这些日志,

      这样就有部分follower中的日志和新leader的日志不相同

      • Raft在这种情况下,leader通过强制follower复制它的日志来解决不一致的问题,这意味着follower中跟leader冲突的日志条目会被新

      leader的日志条目覆盖(因为没有提交,所以不违背外部一致性)

      image-20240520205606804

      如上面这张图所示,c和d比leader多出两个日志,并且c和d多出的日志没有提交,也就是不构成大多数;

      在这七个节点的集群中,leader可以依靠abef和自己的选票当选leader。此时他们多出的日志和leader产生冲突!

      并且f节点中具有的23任期的日志,别的节点都不具有,表示它在那两个任期内担任leader,但是他在2,3任期内的日志都没有正常复制到大多数节点,也就没有提交。因此如果f恢复了,他在2,3任期内的日志都和leader中的不同,也就产生了冲突。

      Raft通过强制follower复制它的日志来解决不一致的问题:leader通过一致性检查找到follower中最后和自己一致的日志之后,就会把这之后follower和自己冲突的所有日志全部覆盖掉。

      注意:是因为这些日志都没有提交,所以抛弃这些日志是不违反一致性的。

      所以,图中的c d e f,节点中与leader不同的日志,最终会被覆盖掉。

      当然,也有可能当前leader宕机,这时候a,c,d有机会当上leader(任期号>=当前leader),若是c或者d当上leader,则会把自己多出的日志复制给follower,来使的自己多出的日志提交。

4.3 日志复制要点
  • 通过这种机制,leader在当权之后就不需要任何特殊的操作来使日志恢复到一致状态。
  • Leader只需要进行正常的操作,然后日志就能在回复AppendEntries一致性检查失败的时候自动趋于一致。
  • Leader从来不会覆盖或者删除自己的日志条目。(Append-Only)
  • 这样的日志复制机制,就可以保证一致性特性:
    • 只要过半的服务器能正常运行,Raft就能够接受、复制并应用新的日志条目;
    • 在正常情况下,新的日志条目可以在一个RPC来回中被复制给集群中的过半机器;
    • 单个运行慢的follower不会影响整体的性能
4.4 追加条目RPC的具体内容
image-20240520211107979
  • preLogIndex和preLogTerm是用来做一致性检查的,只有这两个都和follower中的相同,follower才会认为日志是一致的。

  • leaderCommit:对于follower而言,虽然接收到leader的日志,并不能立即提交,因为这时候还没有确认这个日志是否被复制到了大多数节点。只有leader确认被提交到大多数节点后,leader才会提交这个日志,也就是应用到自己的状态机中。然后leader会在AppendEntries RPC中把这个提交信息告知follower,也就是以这个leaderCommit的状态。然后follower就可以把自己复制但未提交的日志设置为已提交状态,并应用到自己的状态机里。

  • 如果leaderCommit > commitIndex,那么把commitIndex设为min(leaderCommit, index of last new entry)

    image-20240520212408418
  • Response:只有在request的term大于等于自己的term,且request通过一致性检查,才返回true

5. 安全性

领导者选举和日志复制两个子问题实际上已经涵盖了共识算法的全程,但这两点还不能完全保证每一个状态机会按照相同的顺序执行相同的命令

  • 所以Raft通过几个补充规则完善整个算法,使算法可以在各类宕机问题下都不出错。
  • 这些规则包括(不讨论安全性条件的证明过程):
      1. Leader宕机处理:选举限制
      1. Leader宕机处理:新leader是否提交之前任期内的日志条目
      1. Follower和Candidate宕机处理
      1. 时间与可用性限制
5.1 leader宕机处理:选举限制
  • 如果一个follower落后了leader若干条日志(但没有漏一整个任期),那么下次选举中,按照领导者选举里的规则,它依旧有可能当选leader。它在当选新leader后就永远也无法补上之前缺失的那部分日志,从而造成状态机之间的不一致。

  • 所以需要对领导者选举增加一个限制,保证被选出来的leader一定包含了之前各任期的所有被提交的日志条目

  • RequestVote RPC执行了这样的限制:RPC中包含了candidate的日志信息,如果投票者自己的日志比candidate的还,它会拒绝掉该投票请求。

  • Raft通过比较两份日志中最后一条日志条目的索引值和任期号来定义谁的日志比较新。

    • 如果两份日志最后条目的任期号不同,那么任期号大的日志更“新”

    • 如果两份日志最后条目的任期号相同,那么日志较长的那个更“新”

      image-20240520213539815

      如果投票者自己日志比candidate还新,它会拒绝该投票请求。

      image-20240520213719070

      (a)中s1是leader;(b)中s1崩溃,s5通过3,s4的选票当选leader;

      ©中s5崩溃,这是s1重启,并且选举成功。此时日志2被复制到大多数的节点上,但还没提交。

      (d)中s1再次崩溃,s5通过s2s3s4再次选举成功,因为s2,s3的日志号与s5相同,但是s5的任期号更大,所以会投票给s5。

      这时候日志2已经被复制到了大多数节点,达到了在leader上提交的条件,却仍然被覆盖了。

5.2 Leader宕机处理:新leader是否提交之前任期内的日志条目
  • 一旦当前任期内的某个日志条目已经存储到过半的服务器节点上,leader就知道该日志条目可以被提交了。

    • leader收到了超过半数节点的复制成功反馈之后,就可以应用日志到自己的状态机中了,这一步是leader中的提交。但这时候follower节点虽然复制到日志,但还没有应用到自己的状态机上,也就是没有提交。对整个集群来说,提交这个状态并没有构成大多数。

    • follower如何知道自己能提交?

      • follower的提交触发:如下图,AppendEntries中有一个leaderCommit参数,通过这个参数,follower可以知道leader提交到哪个日志,从而自己也可以应用到这个日志。

      • 下一个AppendEntries RPC:心跳 or 新日志

        • raft的心跳是一个特殊的AppendEntries RPC,相比普通的AppendEntries RPC,心跳中没有日志体,但是仍然能够传递leaderCommit参数,从而告知follower是否可以提交。

          image-20240520211107979
  • 单点提交 <-> 集群提交

    • leader提交和follower提交之间必然间隔一段时间,如果leader提交之后直接返回客户端,在通知follower提交之前(就是一个心跳时间内),leader宕机,是否会出现返回提交成功,但是事务提交状态没有在集群中保留下来呢?

      raft是一个底层的共识算法,本身是应用实现高可用的算法。而与客户端交互应该属于应用端的事情,理论上不是raft该考虑的。

      通常为了避免这个问题,应用中会设置集群提交的概念,只有集群中超过半数节点都完成提交,才认为集群提交完成。

      因为raft的leader可以通过AppendEntries RPC返回success与否,判定一个follower是否完成提交。

      所以leader可以很容易判断一个日志是否符合集群提交的条件,有点类似分布式事务的两阶段提交

  • 如果某个leader在提交某个日志条目之前崩溃了,以后的leader会试图完成该日志条目的复制

    • 复制,而非提交, 不能通过心跳提交老日志。

      • 一般情况下,新leader是具有老leader的日志的,这些老的日志可能在新的leader中还没有提交,这是,新leader会尝试将这个日志复制给所有其他follower,但他不会提交。

      • 为何不会提交?

        • 如下图所示例子:(c)到(d)的情况下,(c)中s1当选leader,并且将s1的日志2复制到了大多数节点,但是最终却被日志3覆盖了。也就是没有在集群中提交日志2,如果s1在©的时候提交了日志2,就会出现不一致了,因为日志2的任期号是老的,是2,假设©中s1重新当选leader,在s1,s2,s3中都把日志2提交了,这时候集群中的大多数节点都提交了,可以认为集群中的日志提交了,也可以返回客户端提交成功了。但是如果这时候s1宕机,集群重新选举,s5依靠他最高的任期号3,依旧拿到s2,s3,s4的选票,s5依旧当选leader。依旧会把日志2覆盖掉,即出现d中的情况。这个时候就出现了比较危险的情况:已经提交的日志被覆盖掉了。

          image-20240520215706568
  • Raft永远不会通过计算副本数目的方式来提交之前任期内的日志条目(如上面的日志2中有三个机器提交了,但是其是之前的leader复制的,如果按照计算副本数来提交,会被覆盖)。

    • 只有leader当前任期内的日志条目才通过计算副本数目的方式来提交;

      • 因为可以确认自己当前的任期号最大的,新leader的提交是危险的,但是复制是安全的,依旧会把老日志复制到所有节点。

      • 老日志如何提交呢?等这个新leader在他的任期内新产生一个日志 ,如(c)-(e)的情况,老leader任期内的日志也就可以提交了。注意:一定要是新任期内的日志提交,而不是复制,因为只有在提交的时候,新leader才会将自己的leaderCommit设置为新任期内日志的日志号。这种方式相当于用一个新leader的一个新日志,把老任期内的日志保护了起来。这样,老任期的日志就不会再被覆盖。

        • 在某些情况下,比如新leader可以确认所有的节点都已经复制到了老任期内的日志,理论上可以安心用心跳提交日志了。而raft为了简化问题,使用了这种更保守的方式。因为宕机不机场发生,raft这种简略的方法,即不会影响性能,又简化了实现。
    • 一旦当前任期的某个日志条目以这种方式被提交,那么由于日志匹配特性,之前的所有日志条目也都会被间接地提交。

      image-20240520221818849

5.3 Follower和Candidate宕机处理
  • Follower和Candidate崩溃后的处理方式比leader崩溃要简单的多,并且两者的处理方式是相同的。
  • 如果follower或candidate崩溃了,那么后续发送给他们的RequestVote和AppendEntries RPCs都会失败。
  • Raft通过无限的重试来处理这种失败。如果崩溃的机器重启了,那么这些RPC就会成功地完成。
  • 如果一个服务器在完成了一个RPC,但是还没有响应的时候崩溃了,那么它重启之后就会再次收到同样的请求。(Raft的RPC都是幂等的
5.4 时间与可用性限制
  • Raft算法整体不依赖客观时间,也就是说,哪怕因为网络或其他因素,造成后发的RPC先到,也不会影响raft的正确性。
  • 只要整个系统满足下面的时间要求,Raft就可以选举出并维持一个稳定的leader:
    • 广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障时间(MTBF)
      • 如果一次网络来回的时间大于选举超时时间,永远选不出leader。假如网络来回时间为300ms,大于选举超时时间的上限,那么在candidate等到follower的选票之前,就会因为超时开启下一个任期了,永远也得不到选票。广播时间指的就是一次RPC来回的时间,保证性能的话,这两个的差距得大于一个数量级。
      • 如果系统宕机特别频繁,每次宕机的间隔短于选举超时时间,这时也会出现永远无法完成选举的情况。
      • 广播时间和平均故障时间是系统硬件决定,但选举超时时间是我们自己选择的。Raft的RPC需要接受并将信息落盘,所以广播时间大约是0.5ms到20ms,取决于存储的技术。因此,选举超时时间可能需要在10ms到500ms之间。大多数服务器的平均故障间隔时间都在几个月甚至更长。

6. 集群成员变更

周刊(第13期):重读Raft论文中的集群成员变更算法(一):理论篇 - codedump的网络日志

周刊(第14期):重读Raft论文中的集群成员变更算法(二):实践篇 - codedump的网络日志

  • 在需要改变集群配置的时候(如增减节点、替换宕机的机器或者改变复制的程度),Raft可以进行配置变更自动化

  • 自动化配置变更机制最大的难点是保证转换过程中不会出现同一任期的两个leader,因为转换期间整个集群可能划分为两个独立的大多数

  • 右图为三节点(s123)集群扩容到五节点(s12345)

    • s1S2为老配置集群,s3s4s5为新配置集群
    • 在下图中黑色方框表示的位置,s1s2仍然是老配置,s3s4s5已经切换到了新配置
    • 老配置为三节点,s1s2可以选出一个leader(2/3)
    • 新配置五无节点,s3s4s5可以选出一个leader(3/5)
    • 此时就出现了分布式中最经典的脑裂问题
image-20240521110029530
  • 需要说明的是,在上面这个错误的示例中,是由于有两类行为同时出现才导致的错误:

    • 一次性变更多个节点。在例子中,就是一次性把4、5两个节点加入到集群中。

    • 直接(directly)变更。直接变更由于集群中不同节点的步子不一样,而不一样的节点如果出现了两个不同的集群,那么就可能导致选出两个不同的leader。

6.1 一次变更单个节点

如果限制每次只变更一个节点,那么就能保证“新、旧集合的quorum集合是有重合的”,由于有重合,这样就能保证新旧两个集群的集合不会选出不同的leader,就能间接保证安全性。

论文中以下面几个例子来说明这样操作的正确性:

image-20240521110158976

这几个图,是在两个维度上做示范的:

  • 增、删操作。
  • 原集群节点数量是奇数还是偶数。

两个维度的组合一共就是上面的4中情况,但是无论哪一种情况,由于都保证了“新、旧集合的quorum集合是有重合的”这个条件,于是不会选出不一样的leader来。

6.1.1 单节点变更的具体步骤
image-20240521143928174
  • 如上图所示,集群要从3节点切换到4节点,首先步骤2要完成增加节点的日志同步。那么如何判断增加的节点完成日志同步呢?
    • 因为增加的节点在追赶日志的同时,leader还在不断接收新的日志,所以看起来新增的节点和其他follower一样,永远会落后leader当前的若干日志。可以通过分多轮同步的方法来完成同步。例如最右侧图中,每一轮开始,leader记录下当前日志号,然后同步新增节点的日志到此位置。如此重复,在一定的轮次后(论文给出的参考值是十轮),就可以认为新增的节点的日志已经足够新了,可以开始集群成员变更了。
    • 然后开始步骤3,leader s3开始产生发送Cnew,和联合一致的方法一样,当s3有Cnew的日志后,他就按照新配置来执行了。也就是说Cnew只有复制到了三个节点以上才能完成提交。
    • 最后到步骤4,Cnew复制到了s1和s4中,并且s3完成了Cnew的提交,单节点集群变更完成。
6.1.2 leader宕机情况讨论
  • 在Cnew没有复制到大多数节点时leader宕机
    • 选出的新leader可能是S4,(3/4),具有Cnew,那么它会继续进行集群成员变更。
    • 也可能是S1或S2,(2/3),没有Cnew,这时集群成员变更失败。

• 因为S4复制了Cnew,所以它需要三个节点的选票才能当选,也就是S1S2S3至少有两者给它投票了。这也是老配置的大多数,所以这里不会产生脑裂现象

image-20240521144859562
6.1.3 单节点集群变更可能存在的缺陷:
  • 联合一致支持一步完成机器的替换,比如我们可以通过联合一致的方法把原来集群的(a,b,c)三台机器替换为(d,b,c)三台机器。

    • 但使用单节点变更就只能由(a,b,c)替换为(a,b,c,d)再替换为(d,b,c),需要两步
  • ②单节点变更过程必然经历偶数节点的状态,这会降低集群的高可用性

    • 偶数节点可能存在隐患,如图中增加机器d到集群中,这四台机器两两一组,分布在两个机房中。机器两两分布时,如果发生网络分区,无法选出leader。
  • 优化单节点变更的过程中偶数节点集群的大多数概念。

    • 让新老配置集群有交集就可以了。

    • 所以老配置的任意两个节点(a,b)(a,c)(b,c)也可以算作变更过程中四节点的大多数,让Cnew提交

    • 因为(a,b)(a,c)(b,c)是新老配置的最小交集,只要他们都复制了Cnew,就可以保证选出的新leader一定是新配置的,所以不会脑裂。

    在这种过渡阶段,决策(如选举leader或提交新的日志条目)需要在新旧配置的“最小交集”中获得多数派的支持。这里的“最小交集”不仅是直接的节点交集(b, c),而更多的是指在新旧配置中能够形成稳定多数派的节点组合。

    • 任意两节点组合:在旧配置中,任意两个节点的组合(a,b)、(a,c)、(b,c)都可以在过渡期间形成决策多数。这些组合虽然不全在新配置中,但在过渡期间仍然被认为是关键的,因为它们帮助维持集群操作的连续性和一致性。
  • 这点是TiDB对raft集群变更的一个优化。

    image-20240521145407011
  • ③连续的两次变更,第一步变更的过程中如果出现了切主,那么紧跟着的下一次变更可能,出现错误。

    image-20240521151612162
    • 在上图中,如果集群准备从四节点增加两个节点到6节点,分为两次单节点成员变更进行。

    • 图1中,leader s3把Cnew1复制到了s5, s5就是Cnew1要增加的节点了。到图2中s3宕机了,重新选举,s1依靠s2s4的两票当选leader,这时候认为Cnew1变更失败,到了图3中,新leader s1开始Cnew2的复制,Cnew2向集群中增加s6。注意,这时候的leader s1是不认为集群中有s5的,因此Cnew2还是将集群从四节点变更为五节点。所以s1把Cnew2复制到s1s2s6之后,就达到了五节点的大多数,就可以提交Cnew2了。认为单节点集群变更完成。

      image-20240521154538462

    • 到了图4中,s1宕机,s3依靠s3s4s5的选票当选leader,注意这是对于s3而言,还是Cnew1的配置,其认为集群是s1到s5五个节点,所以他只需要三票就可以当选。s1当选leader后,就会将Cnew1复制到所有节点,造成已提交的Cnew2被覆盖。当然他根本不知道s6的存在,所以s6上的Cnew2不会被覆盖。

    • 这里已经提交的Cnew被覆盖,是个很严重的bug,应该如何解决?

      新leader必须提交一条自己任期内的no-op日志,才能开始单节点集群成员变更

      这样,图③中,S1在当选新leader后,就可以通过no-op把未提交的Cnew1覆盖掉,再开始Cnew2的复制,就不会出问题

6.2 一次变更多个节点(两阶段方法)
  • 所以配置采用了一种两阶段的方法。

  • 集群先切换到一个过渡的配置,称之为联合一致(joint consensus)

  • 因此只需要关注怎样避免在联合一致问题发生脑裂问题就可以了,而配置信息作为一个日志体包装为一个普通的AppendEntries RPC,发送给所有的follower。

  • 第一阶段,leader发起Cold,new,使整个集群进入**联合一致状态。这时,**所有RPC都要在新旧两个配置中都达到大多数才算成功。

  • 第二阶段,leader发起Cnew,使整个集群进入新配置状态。这时,所有RPC只要在新配置下能达到大多数就算成功。

    image-20240521110724557
  • 一旦某个服务器将该新配置日志条目增加到自己的日志中,他就会用该配置来做出未来所有的决策(服务器总是使用它日志中最新的配置,无论该配置日志是否已经被提交)。

  • 这意味着Leader不用等待Cold,newCnew返回,就会直接使用其中的新规则来作出决策。

  • 我们假设leader可以在集群成员变更任何时候宕机,大概有以下几种可能:

    • ①leader在Cold,new未提交时宕机

    • ②leader在Cold,new已提交但Cnew未发起时宕机

    • ③leader在Cnew已发起时宕机

      image-20240521111406403
6.2.1 增加机器时集群成员变更的两个阶段
image-20240521111943053
  • 首先有s1s2s3三个节点,其中s3是现在任期的leader;这时增加s4,s5节点,raft会先将他们设置为只读,等到他们追上日志进度后,才会开始集群成员变更。现任leader发器 C o l d , n e w C_{old,new} Cold,new,并复制给了s4s5,这就是刚刚说的有可能出现脑裂的情况,注意,这是的s3s4s5已经进入了联合一致状态,他们的决策需要在新旧两个配置中都达到大多数才算成功。
6.2.2 leader在Cold,new未提交时宕机
  • 假设原来leader节点s3宕机

  • s1s2超时,开始进行选举,并且可以产生一个老配置的leader。

  • 但是,在联合一致状态下,s3s4s5必须要在老配置(s1s2s3)和新配置(s1s2s3s4s5)下都拿到超过半数选票才能当选。

  • 由于s1和s2已经投票给了他们之中的一个节点(s1或者s2),所以s3s4s5中就无法再选出一个leader,集群中只能选出s1s2中的一个leader。

  • 这样集群成员变更就失败了,但不会出现两个leader的脑裂情况,仍然保证了raft的正确性。

    image-20240521112938142
  • 这里还有一种可能,选出的新leader具有Cold,new

  • 比如此时s1,s3,s4,s5都复制了Cold,new,但还没有提交,这时选出的新leader一定具有Cold,new,但按照安全性限制(在当前任期复制日志超过半数以上),这个新leader无法提交Cold,new

  • 可以让它继续发送Cnew,继续进行集群成员变更。

    image-20240521113034190
  • 选出的新leader具有Cold,new但这个新leader无法提交Cold,new(安全性限制), 可以让它继续发送Cnew,继续进行集群成员变更。

    image-20240521115145866
  • 如上图中,s3复制了Cold,new到新老配置的大多数节点,满足联合一致。但是s3未提交Cold,new就宕机了,这时s1当选leader,根据安全性规则,我们知道s1是不可以提交Cold,new的,所以s1只能继续复制Cnew,这时候s1将Cnew复制到了s1,s4,s5节点,构成了新配置集群的大多数,但这时候它能提交吗?

    • 不行,因为它没有s3的反馈,Cold,new的提交规则并没有满足,这样提交的Cnew会把Cold,new一并提交,这是不安全的。

    • 论文中没有给出这种情况的具体解决方法,在某些设计中,这里可以强制让Cnew按照联合一致规则提交,如果leader在一段时间内仍满足不了提交条件,自动退位

6.2.2 leader在Cold,new已提交但Cnew未发起时宕机
  • 假设s3没有宕机,在这种情况下,leader(S3)的Cold,new日志在新旧两种配置的集群中都超过半数了,Cold,new就可以被提交了。

    • S2S3/S1S2S3 = 2/3

    • S2S3S4/S1S2S3S4S5 =3/5

      image-20240521113627850
  • 这时候选举限制安全性规则决定了选出的新leader 一定具有Cold,new,也就是符合在两种配置集群中都超过半数,已经不存在脑裂的可能了。

  • 联合一致状态下,也是可以正常执行命令的,但也需要在两个配置集群中都达到大多数才能提交,如下图中,Cold,new没有提交,仍能够进行AppendEntries RPC的日志操作

    image-20240521113709219
6.2.3 leader在Cnew已发起时宕机
  • Cold,new提交后,leader就会发起Cnew,这时leader只要满足新配置中的条件,就可以提交日志。比如下图中的s3s4s5复制了Cnew,Cnew就可以提交了,就不需要在s1s2s4中达到大多数了。

    S3S4S5/S1S2S3S4S5 =3/5

  • 要Cnew发起,意味着Cold,new已经被复制到了大多数节点,就不需要再去管老配置。

  • 若此时leader在Cnew已发起时宕机

    • 已经复制了Cnew的节点会只按新配置选举,没有复制Cnew的节点会按新老配置选举。

    • 没有复制Cnew的节点选举成功也会发Cnew

      image-20240521114218376
  • 讨论特殊情况,如果是下图中缩减节点的情况

    • 由s1s2s3s4s5缩减为s1s2s3,Cold,new仍需要复制到两个集群中的大多数才能提交,但是Cnew只需要复制到s1s2s3中的两个就可以提交

    • 这时如果leader S3宕机了,Cnew会不会被覆盖呢?

      • 不会的,因为处于联合一致状态的节点,也就是只复制了Cold,new没有复制Cnew的节点,必须要在两个集群都得到大多数选票才能选举成功。

      • 而s2s3不会投票给s1s4s5中的一个【是由于安全性规则,s2s3的比s1s4s5新,其不会投票给s1s4s5】,所以s3宕了,只有s2才能当选,已提交的Cnew不会被覆盖。

        image-20240521114934241
6.3 总结
  • Cold,new发起但未提交时,raft集群还未进入联合一致状态。这时leader宕机,可以仅靠老配置选出来的新leader。
  • 一旦Cold,new提交,raft集群就进入了联合一致状态,这时leader宕机,选出的新leader也要符合联合一致的选票规则了。
  • Cold,new提交后,leader就可以发起Cnew**,从发起**Cnew开始,集群就可以仅靠新配置进行选举和日志复制了。
  • 红色箭头说明,如果是缩减集群的情况下,leader可能自身就是缩减的对象,那么它会在Cnew复制完成后自动退位。

image-20240521120001411

  • 集群成员变更还有三个补充规则需要说明一下:

    1. 新增节点时,需要等新增的节点完成日志同步再开始集群成员变更,这点是防止集群在新增节点还未同步日志时就进入联合一致状态或新配置状态,影响正常命令日志提交。

      让新增节点完成同步日志住需要让新节点在同步完成日志前不具有投票权,也不参与日志计数,也就是处于一个只读的状态。

    2. 缩减节点时,leader本身可能就是要缩减的节点,这时它会在完成Cnew的提交后自动退位。在发起Cnew后,要退出集群的leader就会处在操纵一个不包含它本身的raft集群的状态下。这时它可以发送Cnew日志,但是日志计数时不计自身

    3. 为了避免下线的节点超时选举而影响集群运行,服务器会在它确信集群中有leader存在时拒绝Request Vote RPC。

      因为Cnew的新leader不会再发送心跳给要退出的节点,如果这些节点没有及时下线,它们会超时增加任期号后发送Request Vote RPC。虽然它们不可能当选leader,但会导致raft集群进入投票选举阶段,影响集群的正常运行。

      为了解决这个问题,Raft在Request Vote RPC上补充了一个规则:一个节点如果在最小超时时间之内收到了Request Vote RPC,那么它会拒绝此RPC。

      这样,只要follower连续收到leader的心跳,那么退出集群节点的Request Vote RPC就不会影响到raft集群的正常运行了。

7. 总结和性能测试

7.1 深入理解复制状态机

共识算法的本质是实现复制状态机。

  • 我们构建分布式存储系统,是为了获取更大的存储容量(Scalability)

  • 为了获取更大的存储容量,我们把数据进行分片(Sharding)

  • 而更多的机器带来了更高的出错频率(Fault)

  • 为了容错(Fault Tolerance),我们要对每个分片建立副本(Replication)

  • 而为了维持副本之间的一致,就要引入共识算法(Consensus)

  • 而共识算法会需要额外的资源与性能(Low Performance),这里又会反过来影响系统的容量和分片数设计

    image-20240521121313778
  • 把复制状态机需要同步的数据量按大小进行分类,它们分别适合不同类型的共识算法。

  • ①数据量非常小,如集群成员信息、配置文件、分布式锁、小容量分布式任务队列。

    这些场景下,外部命令比较稀疏,集群内部的信息可以用过比日志粒度更小的一个个命令来传递,所以也无需设计一个leader来支持快速的写入同步。

    ->无leader的共识算法(如Basic Paxos),实现有Chubby等。如下图中的上半张图。

  • ②数据量比较大但可以拆分为不相干的各部分,写入较连续的场景,如大规模存储系统。就需要选出一个比较稳定有较长生命周期的leader,来统一对外服务,简化客户端的并发写场景,并可以通过leader迅速在集群中达到同步,如下图中的下半张图,这时从leader到follower复制的就是日志,每个日志可以包括一串相同顺序的命令。

    ->有leader的共识算法(如Multi Paxos,Raft),实现有GFS,HDFS等。

    image-20240521121632302
  • ③不仅数据量大,数据之间还存在关联,这时一个共识算法集群容纳不了所有的数据。这种情况下,就要把数据分片(partition)到多个状态机中,状态机之间通过两阶段提交来保证一致性。

    这类场景就主要是一些如Spanner、OceanBase、TiDB等支持分布式事务的分布式数据库。它们通常会对Paxos或Raft等共识算法进行一定的改造,来足事务级的要求。

    image-20240521122249048

7.2 Raft基本概念总结

  • 共识算法的三个主要特性:

  • ①共识算法可以保证在任何非拜占庭情况下的正确性。我们可以把拜占庭情况理解为有节点发送错误的命令,在拜占庭情况下,节点不但会错误,还有可能使坏,如存储不可靠,消息错误等。这种情况下,raft是解决不了的。

    但非拜占庭情况如:网络延迟、网络分区、丢包、重复发送、乱序问题。共识算法可以解决。

  • ②共识算法可以保证在大多数机器正常的情况下集群的高可用性,而少部分的机器缓慢不影响整个集群的性能。(如果是4个节点,只能容忍一个宕机,因为3个节点才能构成大多数)

  • 不依赖外部时间来保证日志的一致性。

    这一点既是共识算法的优势,因为共识算法不受硬件影响,不会因外部因素造成错误。但也造成了一些限制,让共识算法受网络影响很大,在异地容灾场景下,共识算法的支持性比较差。

  • raft区分于其他共识算法的三个特征:

    • **Strong leader:**在Raft中,日志只能从leader流向其他服务器。这简化了复制日志的管理,使得raft更容易理解。

    • **Leader election:**Raft使用随机计时器进行leader选举。这只需在任何共识算法都需要的心跳(heartbeats)上增加少量机制,同时能够简单快速地解决冲突。

    • **Membership changes:Raft使用一种共同一致(joint consensus)**的方法来处理集群成员变更的问题,变更时,两种不同的配置的大多数机器会重叠。这允许整个集群在配置变更期间可以持续正常运行。

      image-20240521142349738

      长生命周期的强leader,是raft实现起来简单,并区别于其他共识算法的最重要的特点。这一点也使得raft在性能上存在很大的隐患。因为raft日志流的单向性,raft选举出的leader必须具有完整的日志。为了保证时时刻刻都具备完整日志的节点可以成为leader,raft又必须使用顺序日志复制的方法来避免日志空洞。这一套就是raft三个子问题,领导者选举、日志复制、安全性的闭环逻辑。

      raft的主要创新点是强leader,为了支持强leader,raft单独分解出了领导者选举这个子问题,并用安全性子问题来保证选举的leader具有完整日志,以处理leader宕机的情况。

      强leader使得共识算法中最重要的日志复制模块实现起来很简单,极大的降低了raft理解和实现难度。

7.2.1 Raft常用补丁:no-op补丁

如下图所示,(c)中的日志2已经复制到了大多数节点,但还没有提交,因此s5仍能够当上leader,从而把日志2覆盖掉(d1)场景,但是我们大部份时候不希望出现这种情况。因为这个日志2已经复制到了大多数节点,那么顺势把它提交肯定是最高效的。所以可以附加以下的no-op规则。

  • 一个节点当选leader后,立刻发送一个自己当前任期的空日志体的AppendEntries RPC。这样,就可以把之前任期内满足提交条件的日志都提交了。

  • 一旦no-op完成复制,就可以把之前任期内符合提交条件的日志保护起来了,从而就可以使它们安全提交。

  • 因为没有日志体,这个过程应该是很快的。

  • 目前大部分应用于生产系统的raft算法,都是启用no- op的。

    image-20240521143248628

7.3 日志压缩机制(log compaction)

  • 为什么要进行日志压缩呢,因为随着raft集群的不断运行,各状态机上的log也在不断地累积,总会有一个时间会把状态机的内存打爆,所以我们需要一个机制来安全地清理状态机上的log。
  • Raft采用的是一种快照技术,每个节点在达到一定条件之后,可以把当前日志中的命令都写入自己的快照,然后就可以把已经并入快照的日志都删除了。
    • 注意,这里的条件是很灵活的,应用可以根据节点的资源使用情况,或者压力情况自行调整。比如可以设置为达到一定的日志数或者内存使用率,又或者每隔一段时间自动进行快照。
  • 快照中一个key只会留有最新的一份value,占用空间比日志小得多。如下图,把日志5以及之前的所有日志都并入了快照,快照中只保留x和y的最新值就可以了,这样就避免了数据无限膨胀的问题。理论上,并入快照的日志,如右图中日志1-5,就可以被清理掉了。
  • 但是在一些场景下,还需要用到老的日志,如果一个follower落后leader很多,如果老的日志被清理了,leader怎么同步给follower呢?
    • Raft的策略是直接向follower发送自己的快照

image-20240521155511228

对于大型存储系统,状态机的快照通常是很大的,在节点落后不多的情况下,同步日志的代价通常比同步快照小,但留有更多的日志会占用leader的空间。何时进行快照,何时清理日志,是使用raft的系统,一个重要的调参方向。

7.4 只读操作处理

  • 直观上讲,raft的读只要直接读取leader上的结果就行了。

  • 直接从leader的状态机取值,实际上并不是线性一致性读(一般也称作强一致性读)。

  • 我们对线性一致性读的定义:读到的结果要是读请求发起时已经完成提交的结果(快照)【不能比这个时间点新,也不能比这个时间点老】。

  • 为什么说直接取leader的状态机不能满足这一点呢?

    如下图中的例子,在leader和其他节点发生了网络分区情况下,其他节点可能已经重新选出了一个leader,而如果老leader在没有访

问其他节点的情况下直接拿自身的值返回客户端,这个读取的结果就有可能不是最新的,因为新leader可能有最新提取的日志。

image-20240521160019082
  • 要追求强一致性读的话,就需要让这个读的过程或结果,也在大多数节点上达到共识

  • 稳妥的方法:把读也当做一个log,由leader发到所有的所有节点上寻求共识,这个读的log提交后,得到的结果是一定符合线性一致性的。但是这种方法的代价显然太大。

  • 优化后的方法,要符合以下规则:

    ①线性一致性读一定要发往leader

    ②如果一个leader在它的任期内还没有提交一个日志,那么它要在提交了一个日志后才能反馈client读请求。(可以通过no-op补丁来优化这一点)

  • 因为只有在自己任期内提交了一个日志,leader才能确认之前任期的哪些日志已被提交,才不会出现已提交的数据读取不到的情况。

  • 安全性规则能保证被选出的leader一定具有所有已被提交的日志,但它可能有的日志还没有提交,它并不能确定哪些日志是已提交的,哪些日志没提交,而在它任期内提交一个日志,就能确定这一点。

    如下图中的例子,图中虚线框的日志没有提交,但是实线框的日志已经提交了,可以看到s3当leader时,后两个日志已提交,但是还未来的及通知s1,它就不再是leader了,因为安全性规则选出的leader一定具有所有的日志,它可以当选任期3的leader。但是他不能确定他的后两个日志是否提交,而等到他在任期内提交了一个日志后,就能确认这一点了。因为日志3一但提交了,任期2那两个日志,也就可以在s1提交上了。

    image-20240521160609577

    ③在进行读操作前,leader要向所有节点发送心跳,并得到大多数节点的反馈。(为了确保自己仍是leader)

    ④leader把自己已提交的日志号设为readIndex,只要leader应用到了readIndex的日志,就可以查询状态机结果并返回client了。

  • 优化过后的线性一致性读,也至少需要一轮RPC(leader确认自己仍然是leader的心跳)。并不比写操作快多少(写操作最少也就一轮AppendEntries RPC)。

  • 所以,还可以更进一步,因为读的这轮RPC仅仅是为了确认集群中没有新leader产生。那么如果leader上一次心跳发送的时间还不到选举超时时间的下界,集群就不能选出一个新leader,那么这段时间就可以不经过这轮心跳确认,直接返回读的结果。(但不建议使用这种方法,因为这种方式并不安全,作为一个分布式系统,可能因为时钟偏移,full GC之类的情况,通常认为时钟是不可靠的,所以在追求线性一致性读的时候时候,尽量不要节省这个RPC的时间)

  • 实际上,大部分业务系统对读的要求没那么高,如果不要求强一致性读,如RC隔离级别一样,一般只要避免脏读就行了,不要求读到最新的数据来保持线性一致性,怎样为弱一致性读实现更高的读性能呢?怎么样利用follower承载更大的读压力呢?通常为读备份。

  • 理论上也可以通过follower进行满足线性一致性的读,如下所示:

    ①follower接收到读请求后,向leader请求readIndex

    ②follower等待自身状态机应用日志到readIndex

    ③follower查询状态机结果,并返回客户端

7.5 性能及Paxos比较

分析Raft的性能

  • 最根本的,每完成一个日志(命令)的复制与提交,需要的网络(RPC)来回次数。因为我们通常认为机器内部非IO操作的时间,是要远小于网络时间的,所以这里的rpc来回次数决定了共识算法最根本的性能。raft在理想情况下,也就是大部分节点正常且快速,网络也正常的情况下,只需要一次AppendEntries RPC来回,leader就能收到大多数节点的success,即可提交日志(一次rpc来回是理论上的极限)。

    • 因此Raft在本质上是足够高效的。

      • 什么情况下,raft无法达到这个极限性能,即影响Raft性能的因素以及优化方法。

        ①选举及维持leader所需的代价->合理设置选举超时时间

        【通过随机选取超时来保证集群可以顺利选出leader,随机选举时间对raft的性能有着极大影响,如果这个时间过长,意味着选举时间的直接增加,而ra ft在选举期间是无法响应请求的,但是如果这个时间过短,可能有多个节点在相近的时间同时开始选举,造成平分选票,而选举失败,需要重新选举,代价更大,所以设置一个适用所有系统环境的选举超时时间,是很重要的】。

        Batch:一个日志可以包含多个命令,然后批量进行复制,来节省网络。

        如果一个命令一次rpc,非常消耗网络资源,那么leader可以一次接受多个客户端命令,然后放在一个日志中批量发送给follower。对一个日志的大小有所限制,论文中给出的参考值是1M。

        Pipeline:leader不用等待follower的回复,就继续给follower发送下一个日志。

        正常情况下,leader要等到follower的success回复后才能发送下一个日志,需要等待。pipline机制,就可以让leader跳过这段等待,直接发送下一个日志,来更好的利用网络连接和加快日志复制进度。

        此外,原始的raft中,leader会先把日志写入自己的磁盘中,再同步日志给follower。等follower写完磁盘后再回复,这里的两部落盘可以并行进行。

        就如下图所示,leader接受一个日志后,直接发送给follower,并同时进行自身的落盘,进一步提高并发。image-20240521161719402

        ④Multi-Raft:将数据分组,每组数据是独立的,用自己的raft来同步。

        在数据量很大的情况下,raft的同步速度和日志的前后顺序关系成为系统的瓶颈,这时可以将数据进行划分,每一组数据单独拥有副本并独立组成raft集群,单独进行复制,从而提高并发。

Raft与Paxos比较

  • “raft不允许日志空洞,所以性能没Paxos好。”

    • raft最大的一个特性就是拥有长生命周期的强leader,要保证日志只能从leader流向follower,raft选举出来的leader必须具有完整的日志,为了保证时时刻刻都具备完整日志的节点可以成为leader,raft又使用顺序日志复制的方法来避免日志空洞。这一套简单逻辑是raft异理解性的由来,也是raft得以被广泛认可和使用的关键。但也因为raft不允许日志空洞,影响了理论的性能上限。
  • 这里的Paxos,实际上指的是一个能完美处理所有日志空洞带来的边界情况,并能保证处理这些边界情况的代价,要小于允许日志空洞带来的收益的共识算法。

  • 总结:raft确实有不允许日志空洞这个性能上限,但大部分系统实现,连raft的上限,都是远远没有达到的。所以无需考虑raft本身的瓶颈。

  • raft允许日志空洞的改造 -> ParallelRaft。

8. ParralleRaft

  • 针对raft不允许日志空洞这一性能瓶颈,谈一谈一种解决此问题的实现:parallelraft.

  • ParallelRaft是阿里云原生数据库PolarDB的底层文件PolarFS对Raft的一种优化的实现。

  • PolarFS: An Ultra-low Latency and Failure Resilient Distributed File System for Shared Storage Cloud Database [VLDB 2018]

  • raft的log,无论是在leader还是follower上,都只会按照固定的顺序串行复制、提交和应用。如下图所示,第一步leader把日志按照顺序同步给follower.如果follower确认自己拥有上一个日志,那么他就会接收此日志;leader如果接收到了超过半数节点的反馈,就会在本地提交这个日志,也就是将日志中的命令应用到自己的状态机中去,并返回客户端提交成功。第三步,follower会在后续收到leader的心跳或者下一个AppendEntries RPC之后,把这个日志在自己本地提交,也就是应用到自己的状态机中。

    image-20240521175517396

  • 实际应用中大多数是并发场景,也就意味着会建立多个连接

  • 因为多个连接会并行向follower发送日志的,只要一个连接慢了,那么整个日志的顺序就乱掉了。

  • 如下图所示,Follower会拒绝掉没有前序日志的日志,造成大量的失败,同时也会有大量的拒绝发往leader,也会给leader造成额外的压力。但是现在的大型系统,多链接,高并发是非常常用的。所以解决此问题是关于系统整体性能的。

    image-20240521175945437
  • 有一些针对此问题的优化方式,如Multi-Raft将数据拆成一个个部分(Region),每个Region单独使用一个Raft组进行同步。这样,不同

    Region之间的数据就不需要保持顺序了。

  • 但Region内部的数据还是要顺序复制与提交,Multi-Raft只是想办法绕开了日志串行提交的问题,没有根本解决此问题。

  • 我们把Raft中的限制总结为以下两点:

    log复制的顺序性:Raft的follower如果接收了一个日志,意味着它具有这个日志之前的所有日志,并且和leader完全一样。

    log提交(应用)的顺序性:Raft的任何节点一旦提交了一个日志,意味着它已经提交了之前的所有日志。

8.1 ParallelRaft的乱序确认/乱序提交
  • ParallelRaft就要打破这两点规则,让log可以乱序确认(Out-of-Order Acknowledge)乱序提交(Out-of-Order Commit)
  • 乱序确认(Out-of-Order Acknowledge):ParallelRaft中,任何log成功持久化后立即返回success,无需等待前序日志复制完成,从而大大降低系统的平均延迟。
  • 乱序提交(Out-of-Order Commit):ParallelRaft中,leader在收到大多数节点的复制成功信息后,无需等待前序日志提交,即可提交当前日志。
  • 当然,直接应用这两个“乱序”会造成算法的错误,所以ParallelRaft采用了一些措施来保证在这两个“乱序”的情况下依旧保持算法的正确性。
8.1.1 乱序提交问题的解决方案
  • 因为有的日志中的命令可能会修改相同的数据,如果跳过空洞先应用了后续的日志,就可能造成状态机间的不一致,导致错误。

    • 为了解决这个问题,应该着手让同一内容的日志按顺序应用,而不同内容日志的顺序(无所谓),乱序提交就是ok的。

    • ParallelRaft引入了一种名叫look behind buffer的数据结构,论文中将这个结构比作架在日志空洞之上的桥,通过这架桥,parallelraft就可以安全的提交空洞之后的日志了。

    • 就如下图中的例子,parallelraft的每个log都附带有一个look behind buffer,look behind buffer存放了前N个log修改的LBA,也就是逻辑块地址的信息。

      这里对于polarFS这样的文件系统,look behind buffer存放的就是数据块的信息。如果是其他数据结构,比如直接把parallelraft用于数据库的数据同步,look behind buffer就可以考虑直接存储数据行的信息。下图是一个N=2的日志序列示意,可以看到,每个log下的look behind buffer都存储了前两个log修改的内容。

      image-20240521180654227
    • 通过look behind buffer,follower能够知道一个log是否与日志空洞里的log冲突,也就是可以判断出当前log的LBA和其look behind buffer中对应日志空洞的LBA是否重合。

    • 没有冲突的log entry可以被安全执行。有冲突的log要加到一个pending list中,等到日志空洞补齐,相关LBA的日志执行完成后,才能执行。

    • PolarFS把N设为2,也就是日志空洞最大为2,其也没有将可容忍的日志空洞设为很大,而是采用相对保守的方案。

    • 如下图中例子,图1中,蓝色的部分,将X的值修改为2,通过它下方的look behind buffer判断出它前面空缺的日志,修改的是Z的值,与X不冲突。所以蓝色的日志可以提交。图2中的绿色日志前空缺了三个日志,所有不能提交。图3中卢瑟的第一个日志讲x修改为4,从它下方的look behind buffer中可以到到它之前的日志空洞中有修改X,所以它不能提交,等在图4中,前面修改日志X的日志已经提交,那么这时候它就可以无视前方的日志空洞而直接乱序提交了。

      image-20240521182753313
8.1.2 乱序确认问题的解决方案

乱序确认(Out-of-Order Acknowledge)

  • 乱序确认会造成日志存在空洞(hole),日志空洞会有一个很直接的影响:怎样选举出具有完整日志的leader?(保证leader选举的安全性)

    • 因为日志只能从leader流向follower,因此raft要求leader必须具有完整的日志。所以如果我们想让parallelraft选中的leader能够提供正常服务,

    也需要保证这个选出来的leader能够具有完整且正确的日志。

    • ParallelRaft把选出来的leader定义为Leader Candidate,Leader Candidate只有经过一个Merge阶段,弥补完所有日志空洞后才能开始接收并复制日志

    • 在选举阶段,ParallelRaft的规则与Raft略有差异,会选择具有最新checkpoint的节点当选Leader Candidate。

      这时其它没有宕机的节点变为Follower Candidate。(论文中没有具体说明Follower Candidate的组成,这是我自己的理解)

Merge阶段总体流程
  • ①Follower Candidate发送自己的日志(应该是checkpoint之后的所有日志)给Leader Candidate。
    Leader Candidate把自己的日志与收到的日志进行
    Merge
  • ②Merge之后,leader candidate就具有了完整的日志,这时他就会同步状态信息给follower.【应该是具体选择哪些log,确保任期正式对外服务之前,让所有没有宕机的节点都能够对老任期的日志达成一致】
  • ③Leader Candidate提交所有日志并通知Follower Candidate提交。
  • ④Leader Candidate升级为Leader,并开始自己任期内的服务。
image-20240521183254286
  • 进入Merge阶段,Leader Candidate会通过所有Follower Candidate的log来补齐自己所有的日志空洞。

  • 下图中,s2s3宕机,选出s1为Leader Candidate,s4s5为Follower Candidate。虚线框表示日志未提交,实线框表示日志已提交。

  • 我们可以把所有的log分为三类:

    • ①**已提交的日志:**在任何节点上已经提交过的日志,如log index=4的日志,s1从s4发来的日志中发现其已提交,虽然s2s3已经宕机,s1只知道s4提交了这个日志,但是这个日志已经被提交,说明他已经复制到了大多数的节点。这类日志一定要在Leader Candidate上补上空洞并提交。
    • 对于某些日志,可能**在不同节点上log index相同,term不同,**如log index=5的日志,这类日志要选择term最大的日志。即Leader Candidate要把自己log index=5的日志改为任期号为3的日志,并提交

    image-20240521202425819

    • 未提交的日志:在任何节点上都未提交过,或确定在大多数节点上都没有的日志(拥有这个日志的节点数+没有响应(宕机)的节点数 < 不

    包含这个日志的节点数),如log index=7的日志,拥有这个日志的节点数为0,宕机节点数为2,不包含这个日志的节点数为3,0+2<3,这类日志如果Leader Candidate上没有,就可以用空日志替代,不影响安全性。

    • 不确定提没提交的日志

      如果拥有这个日志的节点数+没有响应(宕机)的节点数 > 不包含这个日志的节点数,如log index=6的日志,我们就无法判断这个日志是否提交。这时,为了保证安全,LeaderCandidate要提交这个日志。

    • 根据上述规则,可以判断merge的最终结果

      image-20240521203259075

      leader candidate s1补齐了index为4和6的位置,替换了自己index为5的日志,并在index为7的位置上填上一个空日志;进入图3,s1把这些日志的状态信息都同步给了follower candidate,并且都完成了提交,这时候他就可以正式成为leader,开始对外服务。

      merge阶段的加入,让paralleraft leader的选举代价变得非常大,leader选举时可能出现很长时间没有leader对外服务的情况。基于这个问题,要说一下目前使用共识算法的一个趋势,就是尽量避免频繁的选举。也就是说,在leader不出问题的情况下,一般不会进行选举,这样的话因为选举很不经常发生,就算选举的代价大一点也是可以接受的。

Checkpoint
  • ParallelRaft的checkpoint就是状态机的一个快照。在实际实现中,ParallelRaft会选择有最新checkpoint的节点做Leader Candidate,而不是拥有最新日志的节点,有两个原因:
    • ①Merge阶段时,Leader Candidate可以从其它节点获取最新的日志,这样parallelraft就无需像raft一样要求leader必须具有最新的日志。并且由于checkpoint之前的日志都已经确认完成提交,所以Merge阶段无需处理checkpoint之前的日志。所以checkpoint越新,Merge阶段的代价就越小②Leader的checkpoint越新,catch up时就更高效。
catch up
  • ParallelRaft把落后的Follower追上Leader的过程称为catch up,
  • 落后的follower,通常是指宕机了一段时间的follower,或者新加入集群的节点,catch up有两种类型:
    • fast-catch-up:Follower和Leader差距较小时(差距小于上一个checkpoint),仅同步日志。
    • streaming-catch-up:Follower和Leader差距较大时(差距大于上一个checkpoint),同步checkpoint和日志。
    • 以下图中的例子来解释一下catch up机制:case 1和leader的log差距大于一个checkpoint,所以case 1需要streaming-catch-up。
    • case2个leader之间的差距小鱼上个checkpoint,case 2只用fast-catch-up。
    • case 3中多出来的日志会被Leader覆盖掉,和Raft一样,也是fast-catch-up机制。

image-20240521204321107

性能提升

• 随着IO队列的增大,ParallelRaft的延迟要比Raft低(图a),吞吐量也要显著大于Raft(图b)

image-20240521204816562

8.2 总结

image-20240521205630844 image-20240521205651800
  • Raft论文原文中有这样一段话:
  • 在所有基于leader的共识算法中,leader最终都需要存储所有已提交的日志
  • 一些共识算法中,一个leader可以在被选举出来时没有所有已提交的日志,然后通过额外的机制来识别并补充这些日志。
  • 但额外的机制就会造成更高的复杂度和理解成本
  • 所以Raft选择一种更简单的方式,来使leader在被选出时天然就具有所有已提交的日志。这样就可以让日志只能从leader流向follower,leader永远不会复写自己的日志
  • 15
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
C语言是一种广泛使用的编程语言,它具有高效、灵活、可移植性强等特点,被广泛应用于操作系统、嵌入式系统、数据库、编译器等领域的开发。C语言的基本语法包括变量、数据类型、运算符、控制结构(如if语句、循环语句等)、函数、指针等。在编写C程序时,需要注意变量的声明和定义、指针的使用、内存的分配与释放等问题。C语言中常用的数据结构包括: 1. 数组:一种存储同类型数据的结构,可以进行索引访问和修改。 2. 链表:一种存储不同类型数据的结构,每个节点包含数据和指向下一个节点的指针。 3. 栈:一种后进先出(LIFO)的数据结构,可以通过压入(push)和弹出(pop)操作进行数据的存储和取出。 4. 队列:一种先进先出(FIFO)的数据结构,可以通过入队(enqueue)和出队(dequeue)操作进行数据的存储和取出。 5. 树:一种存储具有父子关系的数据结构,可以通过中序遍历、前序遍历和后序遍历等方式进行数据的访问和修改。 6. 图:一种存储具有节点和边关系的数据结构,可以通过广度优先搜索、深度优先搜索等方式进行数据的访问和修改。 这些数据结构在C语言中都有相应的实现方式,可以应用于各种不同的场景。C语言中的各种数据结构都有其优缺点,下面列举一些常见的数据结构的优缺点: 数组: 优点:访问和修改元素的速度非常快,适用于需要频繁读取和修改数据的场合。 缺点:数组的长度是固定的,不适合存储大小不固定的动态数据,另外数组在内存中是连续分配的,当数组较大时可能会导致内存碎片化。 链表: 优点:可以方便地插入和删除元素,适用于需要频繁插入和删除数据的场合。 缺点:访问和修改元素的速度相对较慢,因为需要遍历链表找到指定的节点。 栈: 优点:后进先出(LIFO)的特性使得栈在处理递归和括号匹配等问题时非常方便。 缺点:栈的空间有限,当数据量较大时可能会导致栈溢出。 队列: 优点:先进先出(FIFO)的特性使得
C语言是一种广泛使用的编程语言,它具有高效、灵活、可移植性强等特点,被广泛应用于操作系统、嵌入式系统、数据库、编译器等领域的开发。C语言的基本语法包括变量、数据类型、运算符、控制结构(如if语句、循环语句等)、函数、指针等。下面详细介绍C语言的基本概念和语法。 1. 变量和数据类型 在C语言中,变量用于存储数据,数据类型用于定义变量的类型和范围。C语言支持多种数据类型,包括基本数据类型(如int、float、char等)和复合数据类型(如结构体、联合等)。 2. 运算符 C语言中常用的运算符包括算术运算符(如+、、、/等)、关系运算符(如==、!=、、=、<、<=等)、逻辑运算符(如&&、||、!等)。此外,还有位运算符(如&、|、^等)和指针运算符(如、等)。 3. 控制结构 C语言中常用的控制结构包括if语句、循环语句(如for、while等)和switch语句。通过这些控制结构,可以实现程序的分支、循环和多路选择等功能。 4. 函数 函数是C语言中用于封装代码的单元,可以实现代码的复用和模块化。C语言中定义函数使用关键字“void”或返回值类型(如int、float等),并通过“{”和“}”括起来的代码块来实现函数的功能。 5. 指针 指针是C语言中用于存储变量地址的变量。通过指针,可以实现对内存的间接访问和修改。C语言中定义指针使用星号()符号,指向数组、字符串和结构体等数据结构时,还需要注意数组名和字符串常量的特殊性质。 6. 数组和字符串 数组是C语言中用于存储同类型数据的结构,可以通过索引访问和修改数组中的元素。字符串是C语言中用于存储文本数据的特殊类型,通常以字符串常量的形式出现,用双引号("...")括起来,末尾自动添加'\0'字符。 7. 结构体和联合 结构体和联合是C语言中用于存储不同类型数据的复合数据类型。结构体由多个成员组成,每个成员可以是不同的数据类型;联合由多个变量组成,它们共用同一块内存空间。通过结构体和联合,可以实现数据的封装和抽象。 8. 文件操作 C语言中通过文件操作函数(如fopen、fclose、fread、fwrite等)实现对文件的读写操作。文件操作函数通常返回文件指针,用于表示打开的文件。通过文件指针,可以进行文件的定位、读写等操作。 总之,C语言是一种功能强大、灵活高效的编程语言,广泛应用于各种领域。掌握C语言的基本语法和数据结构,可以为编程学习和实践打下坚实的基础。
该资源内项目源码是个人的课程设计、毕业设计,代码都测试ok,都是运行成功后才上传资源,答辩评审平均分达到96分,放心下载使用! ## 项目备注 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用! 2、本项目适合计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的在校学生、老师或者企业员工下载学习,也适合小白学习进阶,当然也可作为毕设项目、课程设计、作业、项目初期立项演示等。 3、如果基础还行,也可在此代码基础上进行修改,以实现其他功能,也可用于毕设、课设、作业等。 下载后请首先打开README.md文件(如有),仅供学习参考, 切勿用于商业用途。 该资源内项目源码是个人的课程设计,代码都测试ok,都是运行成功后才上传资源,答辩评审平均分达到96分,放心下载使用! ## 项目备注 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用! 2、本项目适合计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的在校学生、老师或者企业员工下载学习,也适合小白学习进阶,当然也可作为毕设项目、课程设计、作业、项目初期立项演示等。 3、如果基础还行,也可在此代码基础上进行修改,以实现其他功能,也可用于毕设、课设、作业等。 下载后请首先打开README.md文件(如有),仅供学习参考, 切勿用于商业用途。
C语言是一种广泛使用的编程语言,它具有高效、灵活、可移植性强等特点,被广泛应用于操作系统、嵌入式系统、数据库、编译器等领域的开发。C语言的基本语法包括变量、数据类型、运算符、控制结构(如if语句、循环语句等)、函数、指针等。下面详细介绍C语言的基本概念和语法。 1. 变量和数据类型 在C语言中,变量用于存储数据,数据类型用于定义变量的类型和范围。C语言支持多种数据类型,包括基本数据类型(如int、float、char等)和复合数据类型(如结构体、联合等)。 2. 运算符 C语言中常用的运算符包括算术运算符(如+、、、/等)、关系运算符(如==、!=、、=、<、<=等)、逻辑运算符(如&&、||、!等)。此外,还有位运算符(如&、|、^等)和指针运算符(如、等)。 3. 控制结构 C语言中常用的控制结构包括if语句、循环语句(如for、while等)和switch语句。通过这些控制结构,可以实现程序的分支、循环和多路选择等功能。 4. 函数 函数是C语言中用于封装代码的单元,可以实现代码的复用和模块化。C语言中定义函数使用关键字“void”或返回值类型(如int、float等),并通过“{”和“}”括起来的代码块来实现函数的功能。 5. 指针 指针是C语言中用于存储变量地址的变量。通过指针,可以实现对内存的间接访问和修改。C语言中定义指针使用星号()符号,指向数组、字符串和结构体等数据结构时,还需要注意数组名和字符串常量的特殊性质。 6. 数组和字符串 数组是C语言中用于存储同类型数据的结构,可以通过索引访问和修改数组中的元素。字符串是C语言中用于存储文本数据的特殊类型,通常以字符串常量的形式出现,用双引号("...")括起来,末尾自动添加'\0'字符。 7. 结构体和联合 结构体和联合是C语言中用于存储不同类型数据的复合数据类型。结构体由多个成员组成,每个成员可以是不同的数据类型;联合由多个变量组成,它们共用同一块内存空间。通过结构体和联合,可以实现数据的封装和抽象。 8. 文件操作 C语言中通过文件操作函数(如fopen、fclose、fread、fwrite等)实现对文件的读写操作。文件操作函数通常返回文件指针,用于表示打开的文件。通过文件指针,可以进行文件的定位、读写等操作。 总之,C语言是一种功能强大、灵活高效的编程语言,广泛应用于各种领域。掌握C语言的基本语法和数据结构,可以为编程学习和实践打下坚实的基础。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值