vlambda博客
学习文章列表

从Paxos幽灵复现谈Raft实现原理

幽灵复现


Mutlti-Paxos下存在Leader切换情况,因而可能出现下面的场景


  • 第一轮中A被选为 Leader,写下了 1-10 号日志,其中 1-5 号日志形成了多数派,并且已给客户端应答,而对于 6-10 号日志,客户端超时未能得到应答。

  • 第二轮,A 宕机,B 被选为 Leader,由于 B 和 C 的最大的 LogID 都是 5,因此 B 不会去重确认 6 - 10 号日志,而是从 6 开始写新的日志,此时如果客户端来查询的话,是查询不到上一轮 6 - 10 号 日志内容的,此后第二轮又写入了 6 - 20 号日志,但是只有 6 号和 20 号日志在多数派。

  • 第三轮,A 又被选为 Leader,从多数派中可以得到最大 LogID 为 20,因此要将 7 - 20 号日志执行重确认,其中就包括了 A 上的 7-10 号日志,这次重确认会发现7-20都已形成多数派,因而之后客户端再来查询的话,会发现上次查询不到的 7 - 10 号日志又像幽灵一样重新出现了。

    该问题发生的原因就是日志非顺序确认(形成多数派)。Multi-Paxos协议里,每条日志是一个Paxos实例,是完全独立的,同一个Leader下的日志写入有顺序,多数派确认是异步的,不一定是顺序的。这个有点像多线程并发执行(日志同步),同一个线程里的顺序是确定,线程之间的执行顺序谁知道呢?比如上面,就出现1~6, 20已经形成多数派确认了,7~19还未形成多数派。就会出现客户端开始访问不到第7条记录,但是能访问到第20条记录,等到后面7重确认后,客户端又能访问到第7条日志了。如果这些日志之间没有任何顺序关联,这倒也不是问题。但是现实场景下,几乎都是顺序执行事务的场景,比如数据库日志。具体到上面的案例,假设日志6是"A给B转账100万", A,B后面查询发现这笔转账未成功(未达到多数派),因而再次发起转账(日志20), 然后日志20被确认,即执行了100万金额的操作。不曾想在第三轮,日志6被再次确认,A又被转了100万,结果导致A被转了200万这一错误。那该如何解决这个问题呢?

  • 死等法

    在第二轮,客户端在请求日志7结果时,系统应该返回未确定状态而不是失败状态,这样客户端知道未确定状态下是不会发起新的和7有关联的日志的,即日志20肯定和7无关,那么他们的确认先后顺序自然也无关紧要,即不解决幽灵复现,但也不会导致损失。当然这种应用场景有限,很少有客户能忍受一个不确定的状态?你发起了转账结果系统告诉你不确定,也不知道什么时候能确定。

  • 丢弃未被确认的老日志

    新任 Leader 在完成日志重确认,开始写入新的 Log 之前,先写出一条被称为 StartWorking 的日志,这条日志的内容中记录了当前 Leader 的 EpochID( 可以使用 Proposalid 的值),并且 Leader 每写一条日志都在日志内容中携带现任 Leader 的 EpochID。回放时,经过了一条 StartWorking 日志之后,再遇到 EpochID 比它小的日志,就直接忽略掉,比如按照上面例子画出的这张图,7 - 19 号日志要在回放时被忽略掉。

从Paxos幽灵复现谈Raft实现原理


这个是Multi-Paxos实现采用的方案。

上面讨论的所有前提是在一个Leader的周期里,不会存在开始日志7未形成多数派,突然又形成多数派的情况,如果有这种情况,上面的方案也解决不了。其实这个本身比较好满足的,因为必须要有新Leader,才会有变数, 且才会重新确认,才会导致日志7状态从未确认到确认。
  • 日志完全按顺序确认

        如果做到这个,很自然不会有上面这个问题。关键是怎么做到这个,Zab, Raft就是采用的这种方法,下面我们就来分析Raft是如何实现的。


Raft协议架构


从Paxos幽灵复现谈Raft实现原理


复制状态机的结构。一致性算法管理着来自客户端指令的复制日志。状态机从日志中处理相同顺序的相同指令,所以产生的结果也是相同的。


Raft的几个假设


Raft协议的目标是实现一个满足如下规范的系统

从Paxos幽灵复现谈Raft实现原理

  • Election Safety

        一个任期里最多只有一个Leader

  • Leader Append-Only

     Leader不会修改和删除自己的日志记录,只会新增自己的日志记录

  • Log Matching

        简化来说就是

    • 相同term和logIndex值对应的日志一定存储一样的数据 

    • 相同term和logIndex值对应的日志之前的日志一定也是相同的

具体见下面的日志复制章节。

  • Leader Completeness

        高Term U的Leader一定包含所有低Term T已提交的日志。这个是通过Leader选举策略保障的。

  • State Machine Safety

        如果一个日志已经被一个服务确认提交了(提交到状态机),那么不可能有其他节点在该日志所在logIndex提交不同的日志,具体见下章节。

Raft假设保障


Raft基本概念


    Raft里有Leader, Follower, Candidate三个角色,一开始每个节点都是Candidate,然后投票,获得最多票数的成为Leader, 其他人成为Follower。Leader需要发送heartbeat告知自身状态,当Follower接收不到Leader的心跳会认为Leader宕机了,会变为Candidate发起新一轮Leader选举。Leader在它们宕机之前会一直保持Leader的状态,一个Leader生命周期为一个term, 时间随着Leader的交替分为一个个term。

从Paxos幽灵复现谈Raft实现原理

    Leader负责新增日志记录,然后复制到集群中的Follower。


Raft日志复制


从Paxos幽灵复现谈Raft实现原理

    日志文件由有序编号的日志组成。每个日志包含它被创建时的任期号(term)(每个方块中的数字),并且包含用于状态机执行的命令。如果一个日志被能够被状态机安全执行(至少已被多数节点复制),就被认为可以提交了。

    日志的Term用来检测在不同服务器上日志的不一致性,每个日志记录也包含一个整数索引来表示它在日志中的位置。

    Term相同时,不同节点上相同logIndex上的日志都是从同一个leader上相同logIndex的日志复制过来的,自然相同,因而满足Log Matching的第一条

相同term和logIndex值对应的日志一定存储一样的数据 

    Raft中 , Leader的新增日志在其他Follower被复制之前,对应Follower必须先同步到Leader的前一个日志,由于相同term和index的日志之前的日志是从相同的Leader复制过来的,自然也相同,因而满足Log Matching的第二条

相同term和logIndex值对应的日志之前的日志一定也是相同的

    上面这个“先同步老数据再添加新数据“的规范就是Raft的日志复制规范,详情如下。

  • Leader新增传播新日志时会传递preLogIndex和PrevLogTerm,  Follower接收到Leader的新日志消息时,会和本地的logIndex及Term比较来检测本地日志和Leader日志是否有冲突,如果有冲突会以Leader的为准并清除本地的冲突数据到共同的祖先日志点,同时给Leader返回两者日志的共同祖先对应的logIndex,下次Leader就会携带合适的日志数据集给到Follower, Follewer然后就能复制拥有和Leader一样的日志,然后才会完成新日志添加并给Leader一个同意的回复, Leader收到超过1/2这样的回复才算commit。

  • 上面的规范说明Leader有覆盖Follower日志的能力,但是如果不加限制,随意修改会乱套。比如如果只有低term日志记录的节点被选为Leader, 那它就会清除其他Follower已经被多数派确认提交的高term 日志,本来是这位新Leader自身能力不够没能跟上最新日志却让其他上进的Follower跟它一起回退,这自然不能允许的。当然如果在Follewer的日志复制逻辑里添加待复制日志的term必须大于本地term能解决这个问题,但是这样Leader就变成了一个闲人,提交不了任意新日志,空转做无用功。因而Raft给Leader选举增加了限制以避免这种情况,  很简单,就是Leader选举时选出最强的人,这个具体到Raft就是上述提到的Leader Completeness:高Term U的Leader一定包含所有低Term T已提交的日志。这个是通过下面Leader选举策略保障的。

这里我们归纳出日志的三个状态。

  1. record

日志已经被记录,Leader创建新日志, Follower收到Leader的日志记入日志本。

  1. precommit

日志已经被多数节点record我们称为进入precommit,论文里没有明确指明这种状态,为了方便描述,这里新增了这个状态。

  1. commit

是不是处于precommit状态的日志会立即被commit?这个是不一定。因为当一个新Leader当选时,由于节点日志进度不同,很可能需要继续复制前面日志,同时即使为前面的日志被复制到多数服务器并且提交了, 如步骤C中的日志2已经达到多数派了,但是在D中还是可能被覆盖,这就产生了不一致。

  • 实现上是如果存在一个N满足 N>commitIndex, 且多数的matchIndex[i] >= N,并且 log[N].term == currentTerm,设置commitIndex = N。最开始我看论文时对这个commit时机也是很迷惑,看了raft的实现代码才豁然开朗。

  • 具体就是在c阶段,S1成为Leader,此时的Term是4。S1一样向其他服务器发送日志2,当发送到多数服务器S1,S2,S3时,此时并不提交该日志,而是继续复制日志4,直到日志4到达多数服务器后,提交日志4,即leader只会提交当前Term的日志。如果提交了4之后宕机,S5就不会被选举为新的 Leader,如果在提交4之前宕机,那么日志2,日志4还是可能被覆盖(如d),但是由于没有提交,也就没有执行日志中的命令,即使被覆盖也无关系。我们知道Multi-Paxos的多数派达成后Proposer是立即提交的,其他Acceptor节点是通过发起paxos重确认来获取到多数派信息并提交的, Raft是通过Leader独特的选举规范实现的, 这种方式比较巧妙且性能高。

  • 总结来说节点只会提交当前Term的日志,C中Term=4不等于已达多数派的日志2的term 2不会提交。那可能会说,e这种情况下如果S1提交了4, 其他节点S2, S3还没提交这时S1 crash了会有问题吗?不会,因而S1, S2, S3的term=4最高,下一个Leader一定是S2, S3中的一个,后续就可以继续复制4到S4, S5达到多数派然后会提交。即最开始只有Leader知道日志的commit状态,Leader在传播新日志时会携带commitIndex信息来向其他节点同步commit状态。

    从Paxos幽灵复现谈Raft实现原理

    Raft Leader选举


    Leader选举流程如下

    Candidate

      • 将自己的任期号加1.

      • 为自己投一票用以选举出新的Leader。

      • 将本地的计时器重置

      • 发送投票请求到网络中的其他所有的服务器。

      • 等待下一次的计时器超时

    Follower

      • Candidate的Term>Follower的Term,同意。

      • Candidate的Term<Follower的Term,说明发起者早就落后了,直接拒绝。发起者和自己的Term相同,但是别人到的早,因为一个Term只能给一个人投票,也会拒绝。如果Follower发现这个term里已经给该Candidate投过票,也会返回true,因为可能是上一次的回复消息把消息丢掉了。

      • Cadidate的Term=Follower的Term,且没有投票给别人,那就比日志长度,拿到candidate的lastLogIndex和自己的logIndex比较,如果candidate的不小则同意,否则拒绝。



    Raft假设证明


    Leader Completeness


        Raft的原文其实有完整证明,一开始看好像也是那么回事,很简单的就完成了证明,但是里面有个细节未能描述清晰,当然文中的其他细节足够在此忽略掉这个理所当然的假设,但总觉得不够完整。

    接下来我将结合论文和自己的关注点使用归纳法来重新验证这个假设

    高Term 的Leader一定包含所有低Term已提交(commit)的日志

    通过归纳迭代来证明

    1. 当n=1时, 没有低Term, 也没有已提交的日志,符合条件。

    1. 当n=U时,假设LeaderU包含了低Term已提交的日志,那么Term U+1的leader(U+1)也一定也包含Term(0, U)之间已提交的日志及Term U期间已提交的日志。

      1. 证明leader(U+1)一定包含Term(0, U)之间已提交的日志

        1. 设最大的Term(0, U)之间已提交的日志为logT(TermT, indexT)。设Leader(U+1)竞选Leader时的lastTerm=termV,  lastIndex=indexV,  last日志=logV。

        1. 如果termV < termT,  由于logT是已提交的日志,那至少存在一个包含logT日志的节点voterM(已经commit说明大多数节点已经有replica)投票给了leader(U+1),因此voterM的lastTerm(termT) 应该<= leader(U+1)的lastTerm(termV),这与前提条件termV < termT矛盾。


    从Paxos幽灵复现谈Raft实现原理

        1. 如果termV> termT, 说明是leaderV 将logV复制给了 Leader(U+1), 由于U>= V > T, 按照前面的假设,LeaderV包含低于Term V的已提交的日志,自然包括logT。这样LeaderV复制logV给Leader(U+1)的过程中,Leader(U+1)也会包含logT,即包含所有Term T及以下的已提交的日志。

        1. 如果termV = termT, 那么logV是由leaderT复制的,leaderT包含小于Term T的所有已提交的日志,同时由于indexV>=indexT,这说明leader(U+1)会从leaderT复制logT及之前的所有日志,自然符合条件。

        1. 综上所证,可得出Leader(U+1)肯定会包含Term(0, U)之间已提交的日志

      1. 证明Leader(U+1)还包含Term U期间新commit的日志。

        1. 假设Term U期间未commit过新的日志,那无需证明,Leader(U+1)符合上面的条件。

        1. 假设Term U期间新commit过日志logK(设logIndex=K),根据上面commit的规则可知logK的term=U,  同时logK得到多数派同意,即logK也同步到了多数节点, 这样给Leader(U+1)投赞成票的节点中至少有一个voterX包含记录logK。按照Leader选举规范,由于logK(term=U),  leader(U+1)的lastTerm也必然为U(>=U, 最高为U,所以=U),且Leader(U+1)的lastIndex>=K, 这说明leaderU拥有voterV所有的日志(令lastTerm=P,voterX和leaderU的最后几条log都是leaderP append的,那么leaderU log 自然是serverM的超集, 进而肯定包含logT), 自然也拥有logK,同时leader(U+1)同步到logK时必然也包含logK之前新commit的日志(相同term和logIndex值对应的日志及之前的日志一定也是相同的) , 于是得证。

            

         我们结合上图来更具体的说明上面各种case的证明。

    • 在c时刻(LeaderU),如果S1已将4同步到多数派达到e图,由于日志4的term等于当前term4, 会commit,且多数派的lastTerm=4,  那么新的Leader的Term必然>=4,所以Leader(U+1)只能是S1, S2, S3,自然包含包括4在内的已提交日志,这就是上面b.2这种case, Leader(U+1)包含老的commit日志1及LeaderU阶段新增的commit日志2和日志4,即 Leader(U+1)包含所有低term commit的日志1, 2, 4。

    • 在c时刻(LeaderU),如果S1未将4同步到多数派(比如只同步到S1),那么2还未被commit,这时新的Leader存在两种case, d, e两种case都是合理的,都属于a.3这种case, Leader(U+1)的lastTerm termV(3, 4) > termT(1)

    • b属于a.4这种case, Leader(U+1) 的lastTerm termV(1) = termT(1)。


    State Machine Safety证明


    如果一个之前任期leaderT commit了index上的日志到状态机,根据Leader Completeness,之后任期的leaderU在index上有相同的log,那么他commit到index上的log必然和leaderT一样的。

    分布式一致性协议算是一个比较复杂的系统,需要多思考讨论,欢迎文章下面留言讨论


    所有的码客文章,都在这里,欢迎长按关注

    记得点击下面的「好看」哟👇