一致性协议和算法

=

而为了解决数据一致性问题,在科学家和程序员的不断探索中,就出现了很多的一致性协议和算法。比如 2PC(两阶段提交),3PC(三阶段提交),Paxos算法等等。

这时候请你思考一个问题,同学之间如果采用传纸条的方式去传播消息,那么就会出现一个问题——我咋知道我的小纸条有没有传到我想要传递的那个人手中呢?万一被哪个小家伙给劫持篡改了呢,对吧?

img

这个时候就引申出一个概念—— 拜占庭将军问题 。它意指 在不可靠信道上试图通过消息传递的方式达到一致性是不可能的, 所以所有的一致性算法的 必要前提 就是安全可靠的消息通道。

而为什么要去解决数据一致性的问题?你想想,如果一个秒杀系统将服务拆分成了下订单和加积分服务,这两个服务部署在不同的机器上了,万一在消息的传播过程中积分系统宕机了,总不能你这边下了订单却没加积分吧?你总得保证两边的数据需要一致吧?

1. 2PC(两阶段提交)

两阶段提交是一种保证分布式系统数据一致性的协议,现在很多数据库都是采用的两阶段提交协议来完成 分布式事务 的处理。

在介绍2PC之前,我们先来想想分布式事务到底有什么问题呢?

还拿秒杀系统的下订单和加积分两个系统来举例吧(我想你们可能都吐了🤮🤮🤮),我们此时下完订单会发个消息给积分系统告诉它下面该增加积分了。如果我们仅仅是发送一个消息也不收回复,那么我们的订单系统怎么能知道积分系统的收到消息的情况呢?如果我们增加一个收回复的过程,那么当积分系统收到消息后返回给订单系统一个 Response ,但在中间出现了网络波动,那个回复消息没有发送成功,订单系统是不是以为积分系统消息接收失败了?它是不是会回滚事务?但此时积分系统是成功收到消息的,它就会去处理消息然后给用户增加积分,这个时候就会出现积分加了但是订单没下成功。

所以我们所需要解决的是在分布式系统中,整个调用链中,我们所有服务的数据处理要么都成功要么都失败,即所有服务的 原子性问题

在两阶段提交中,主要涉及到两个角色,分别是协调者和参与者。

第一阶段:当要执行一个分布式事务的时候,事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 prepare 请求(其中包括事务内容)告诉参与者你们需要执行事务了,如果能执行我发的事务内容那么就先执行但不提交,执行后请给我回复。然后参与者收到 prepare 消息后,他们会开始执行事务(但不提交),并将 UndoRedo 信息记入事务日志中,之后参与者就向协调者反馈是否准备好了。

第二阶段:第二阶段主要是协调者根据参与者反馈的情况来决定接下来是否可以进行事务的提交操作,即提交事务或者回滚事务。

比如这个时候 所有的参与者 都返回了准备好了的消息,这个时候就进行事务的提交,协调者此时会给所有的参与者发送 Commit 请求 ,当参与者收到 Commit 请求的时候会执行前面执行的事务的 提交操作 ,提交完毕之后将给协调者发送提交成功的响应。

而如果在第一阶段并不是所有参与者都返回了准备好了的消息,那么此时协调者将会给所有参与者发送 回滚事务的 rollback 请求,参与者收到之后将会 回滚它在第一阶段所做的事务处理 ,然后再将处理情况返回给协调者,最终协调者收到响应后便给事务发起者返回处理失败的结果。

2PC流程

个人觉得 2PC 实现得

还是比较鸡肋的,因为事实上它只解决了各个事务的原子性问题,随之也带来了很多的问题。

img
  • 单点故障问题,如果协调者挂了那么整个系统都处于不可用的状态了。
  • 阻塞问题,即当协调者发送 prepare 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。
  • 数据不一致问题,比如当第二阶段,协调者只发送了一部分的 commit 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题。

2. 3PC(三阶段提交)

因为2PC存在的一系列问题,比如单点,容错机制缺陷等等,从而产生了 3PC(三阶段提交) 。那么这三阶段又分别是什么呢?

千万不要吧PC理解成个人电脑了,其实他们是 phase-commit 的缩写,即阶段提交。

  1. CanCommit阶段:协调者向所有参与者发送 CanCommit 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO 。
  2. PreCommit阶段:协调者根据参与者返回的响应来决定是否可以进行下面的 PreCommit 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 PreCommit 预提交请求,参与者收到预提交请求后,会进行事务的执行操作,并将 UndoRedo 信息写入事务日志中 ,最后如果参与者顺利执行了事务则给协调者返回成功的响应。如果在第一阶段协调者收到了 任何一个 NO 的信息,或者 在一定时间内 并没有收到全部的参与者的响应,那么就会中断事务,它会向所有参与者发送中断请求(abort),参与者收到中断请求之后会立即中断事务,或者在一定时间内没有收到协调者的请求,它也会中断事务。
  3. DoCommit阶段:这个阶段其实和 2PC 的第二阶段差不多,如果协调者收到了所有参与者在 PreCommit 阶段的 YES 响应,那么协调者将会给所有参与者发送 DoCommit 请求,参与者收到 DoCommit 请求后则会进行事务的提交工作,完成后则会给协调者返回响应,协调者收到所有参与者返回的事务提交成功的响应之后则完成事务。若协调者在 PreCommit 阶段 收到了任何一个 NO 或者在一定时间内没有收到所有参与者的响应 ,那么就会进行中断请求的发送,参与者收到中断请求后则会 通过上面记录的回滚日志 来进行事务的回滚操作,并向协调者反馈回滚状况,协调者收到参与者返回的消息后,中断事务。
3PC流程

这里是 3PC 在成功的环境下的流程图,你可以看到 3PC 在很多地方进行了超时中断的处理,比如协调者在指定时间内为收到全部的确认消息则进行事务中断的处理,这样能 减少同步阻塞的时间 。还有需要注意的是,3PCDoCommit 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交。为什么这么做呢?是因为这个时候我们肯定保证了在第一阶段所有的协调者全部返回了可以执行事务的响应,这个时候我们有理由相信其他系统都能进行事务的执行和提交,所以不管协调者有没有发消息给参与者,进入第三阶段参与者都会进行事务的提交操作。

总之,3PC 通过一系列的超时机制很好的缓解了阻塞问题,但是最重要的一致性并没有得到根本的解决,比如在 PreCommit 阶段,当一个参与者收到了请求之后其他参与者和协调者挂了或者出现了网络分区,这个时候收到消息的参与者都会进行事务提交,这就会出现数据不一致性问题。

所以,要解决一致性问题还需要靠 Paxos 算法⭐️ ⭐️ ⭐️ 。

3. Paxos 算法

Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一,其解决的问题就是在分布式系统中如何就某个值(决议)达成一致

Paxos 中主要有三个角色,分别为 Proposer提案者Acceptor表决者Learner学习者Paxos 算法和 2PC 一样,也有两个阶段,分别为 Prepareaccept 阶段。

3.1. prepare 阶段

  • Proposer提案者:负责提出 proposal,每个提案者在提出提案时都会首先获取到一个 具有全局唯一性的、递增的提案编号N,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在第一阶段是只将提案编号发送给所有的表决者
  • Acceptor表决者:每个表决者在 accept 某提案后,会将该提案编号N记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个编号最大的提案,其编号假设为 maxN。每个表决者仅会 accept 编号大于自己本地 maxN 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 Proposer

下面是 prepare 阶段的流程图,你可以对照着参考一下。

paxos第一阶段

3.2. accept 阶段

当一个提案被 Proposer 提出后,如果 Proposer 收到了超过半数的 Acceptor 的批准(Proposer 本身同意),那么此时 Proposer 会给所有的 Acceptor 发送真正的提案(你可以理解为第一阶段为试探),这个时候 Proposer 就会发送提案的内容和提案编号。

表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 大于等于 已经批准过的最大提案编号,那么就 accept 该提案(此时执行提案内容但不提交),随后将情况返回给 Proposer 。如果不满足则不回应或者返回 NO 。

paxos第二阶段1

Proposer 收到超过半数的 accept ,那么它这个时候会向所有的 acceptor 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 acceptor 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要向未批准的 acceptor 发送提案内容和提案编号并让它无条件执行和提交,而对于前面已经批准过该提案的 acceptor 来说 仅仅需要发送该提案的编号 ,让 acceptor 执行提交就行了。

paxos第二阶段2

而如果 Proposer 如果没有收到超过半数的 accept 那么它将会将 递增Proposal 的编号,然后 重新进入 Prepare 阶段

对于 Learner 来说如何去学习 Acceptor 批准的提案内容,这有很多方式,读者可以自己去了解一下,这里不做过多解释。

3.3. paxos 算法的死循环问题

其实就有点类似于两个人吵架,小明说我是对的,小红说我才是对的,两个人据理力争的谁也不让谁🤬🤬。

比如说,此时提案者 P1 提出一个方案 M1,完成了 Prepare 阶段的工作,这个时候 acceptor 则批准了 M1,但是此时提案者 P2 同时也提出了一个方案 M2,它也完成了 Prepare 阶段的工作。然后 P1 的方案已经不能在第二阶段被批准了(因为 acceptor 已经批准了比 M1 更大的 M2),所以 P1 自增方案变为 M3 重新进入 Prepare 阶段,然后 acceptor ,又批准了新的 M3 方案,它又不能批准 M2 了,这个时候 M2 又自增进入 Prepare 阶段。。。

就这样无休无止的永远提案下去,这就是 paxos 算法的死循环问题。

img

那么如何解决呢?很简单,人多了容易吵架,我现在 就允许一个能提案 就行了。

4、Raft 算法

工作流程

首先需要明确的是一致性算法的目标是什么,主要面对的问题是在只使用单个服务器时由于发生错误导致数据丢失等事情发生。解决这个问题的思路也很简单,就是备份,将操作重复到多个机器上就不怕单个机器出错了。但随之而来的就是,数据不一致、乱序等问题,一致性算法想要做到的是即使有结点出错,对外仍是一个完整的可以正常工作的整体。

Raft 是一个非拜占庭的一致性算法,即所有通信是正确的而非伪造的。N 个结点的情况下(N为奇数)可以最多容忍 (N−1)/2(N−1)/2 个结点故障。

img

Raft 正常工作时的流程如上图,也就是正常情况下日志复制的流程。Raft 中使用 日志 来记录所有操作,所有结点都有自己的日志列表来记录所有请求。算法将机器分成三种角色:Leader、Follower 和 Candidate。正常情况下只存在一个 Leader,其他均为 Follower,所有客户端都与 Leader 进行交互。

所有操作采用类似两阶段提交的方式,Leader 在收到来自客户端的请求后并不会执行,只是将其写入自己的日志列表中,然后将该操作发送给所有的 Follower。Follower 在收到请求后也只是写入自己的日志列表中然后回复 Leader,当有超过半数的结点写入后 Leader 才会提交该操作并返回给客户端,同时通知所有其他结点提交该操作。

通过这一流程保证了只要提交过后的操作一定在多数结点上留有记录(在日志列表中),从而保证了该数据不会丢失。

领导选举

在了解了算法的基本工作流程之后,就让我们开始解决其中会遇到的问题,首先就是 Leader 如何而来。

初次选举

在算法刚开始时,所有结点都是 Follower,每个结点都会有一个定时器,每次收到来自 Leader 的信息就会更新该定时器。

img

如果定时器超时,说明一段时间内没有收到 Leader 的消息,那么就可以认为 Leader 已死或者不存在,那么该结点就会转变成 Candidate,意思为准备竞争成为 Leader。

成为 Candidate 后结点会向所有其他结点发送请求投票的请求(RequestVote),其他结点在收到请求后会判断是否可以投给他并返回结果。Candidate 如果收到了半数以上的投票就可以成为 Leader,成为之后会立即并在任期内定期发送一个心跳信息通知其他所有结点新的 Leader 信息,并用来重置定时器,避免其他结点再次成为 Candidate。

如果 Candidate 在一定时间内没有获得足够的投票,那么就会进行一轮新的选举,直到其成为 Leader,或者其他结点成为了新的 Leader,自己变成 Follower。

再次选举

再次选举会在两种情况下发生。

img

第一种情况是 Leader 下线,此时所有其他结点的计时器不会被重置,直到一个结点成为了 Candidate,和上述一样开始一轮新的选举选出一个新的 Leader。

img

第二种情况是某一 Follower 结点与 Leader 间通信发生问题,导致发生了分区,这时没有 Leader 的那个分区就会进行一次选举。这种情况下,因为要求获得多数的投票才可以成为 Leader,因此只有拥有多数结点的分区可以正常工作。而对于少数结点的分区,即使仍存在 Leader,但由于写入日志的结点数量不可能超过半数因此不可能提交操作。这解释了为何 Raft 至多容忍 (N−1)/2(N−1)/2 个结点故障。

image-20220429200948937

这解释了每个结点会如何在三个状态间发生变化。

任期 Term

Leader 的选举引出了一个新的概念——任期(Term)。

image-20220429200956676

每一个任期以一次选举作为起点,所以当一个结点成为 Candidate 并向其他结点请求投票时,会将自己的 Term 加 1,表明新一轮的开始以及旧 Leader 的任期结束。所有结点在收到比自己更新的 Term 之后就会更新自己的 Term 并转成 Follower,而收到过时的消息则拒绝该请求。

在一次成功选举完成后,Leader 会负责管理所有结点直至任期结束。如果没有产生新的 Leader 就会开始一轮新的 Term。任期在 Raft 起到了类似时钟的功能,用于检测信息是否过期。

投票限制

在投票时候,所有服务器采用先来先得的原则,在一个任期内只可以投票给一个结点,得到超过半数的投票才可成为 Leader,从而保证了一个任期内只会有一个 Leader 产生(Election Safety)。

在 Raft 中日志只有从 Leader 到 Follower 这一流向,所以需要保证 Leader 的日志必须正确,即必须拥有所有已在多数节点上存在的日志,这一步骤由投票来限制。

image-20220429201010920

投票由一个称为 RequestVote 的 RPC 调用进行,请求中除了有 Candidate 自己的 term 和 id 之外,还要带有自己最后一个日志条目的 index 和 term。接收者收到后首先会判断请求的 term 是否更大,不是则说明是旧消息,拒绝该请求。如果任期更大则开始判断日志是否更加新。日志 Term 越大则越新,相同那么 index 较大的认为是更加新的日志。接收者只会投票给拥有相同或者更加新的日志的 Candidate。

由于只有日志在被多数结点复制之后才会被提交并返回,所以如果一个 Candidate 并不拥有最新的已被复制的日志,那么他不可能获得多数票,从而保证了 Leader 一定具有所有已被多数拥有的日志(Leader Completeness),在后续同步时会将其同步给所有结点。

定时器时间

定时器时间的设定实际上也会影响到算法性能甚至是正确性。试想一下这样一个场景,Leader 下线,有两个结点同时成为 Candidate,然后由于网络结构等原因,每个结点都获得了一半的投票,因此无人成为 Leader 进入了下一轮。然而在下一轮由于这两个结点同时结束,又同时成为了 Candidate,再次重复了之前的这一流程,那么算法就无法正常工作。

为了解决这一问题,Raft 采用了一个十分“艺术”的解决方法,随机定时器长短(例如 150-300ms)。通过这一方法避免了两个结点同时成为 Candidate,即使发生了也能快速恢复。这一长短必须长于 Leader 的心跳间隔,否则在正常情况下也会有 Candidate 出现导致算法无法正常工作。

日志复制

在之前的工作流程章节中已经描述了日志是如何被复制到其他结点上的,但实际中还会发生结点下线,从而产生不一致的情况的发生,也是这一章我们将要讨论的内容。

前提

Raft 保证了如下几点:

  • Leader 绝不会覆盖或删除自己的日志,只会追加 (Leader Append-Only
  • 如果两个日志的 index 和 term 相同,那么这两个日志相同 (Log Matching
  • 如果两个日志相同,那么他们之前的日志均相同

第一点主要是因为选举时的限制,根据 Leader Completeness,成为 Leader 的结点里的日志一定拥有所有已被多数节点拥有的日志条目,所以先前的日志条目很可能已经被提交,因此不可以删除之前的日志。

第二点主要是因为一个任期内只可能出现一个 Leader,而 Leader 只会为一个 index 创建一个日志条目,而且一旦写入就不会修改,因此保证了日志的唯一性。

第三点是因为在写入日志时会检查前一个日志是否一致。换言之就是,如果写入了一条日志,那么前一个日志条目也一定一致,从而递归的保证了前面的所有日志都一致。从而也保证了当一个日志被提交之后,所有结点在该 index 上提交的内容是一样的(State Machine Safety)。

日志同步

接下来我们就可以看到 Raft 实际中是如何做到日志同步的。这一过程由一个称为 AppendEntries 的 RPC 调用实现,Leader 会给每个 Follower 发送该 RPC 以追加日志,请求中除了当前任期 term、Leader 的 id 和已提交的日志 index,还有将要追加的日志列表(空则成为心跳包),前一个日志的 index 和 term。

image-20220429201026366

当接收到该请求后,会先检查 term,如果请求中的 term 比自己的小说明已过期,拒绝请求。之后会对比先前日志的 index 和 term,如果一致,那么由前提可知前面的日志均相同,那么就可以从此处更新日志,将请求中的所有日志写入自己的日志列表中,否则返回 false。如果发生 index 相同但 term 不同则清空后续所有的日志,以 Leader 为准。最后检查已提交的日志 index,对可提交的日志进行提交操作。

对于 Leader 来说会维护 nextIndex[] 和 matchIndex[] 两个数组,分别记录了每个 Follower 下一个将要发送的日志 index 和已经匹配上的日志 index。每次成为 Leader 都会初始化这两个数组,前者初始化为 Leader 最后一条日志的 index 加 1,后者初始化为 0。每次发送 RPC 时会发送 nextIndex[i] 及之后的日志,成功则更新两个数组,否则减少 nextIndex[i] 的值重试,重复这一过程直至成功。

这里减少 nextIndex 的值有不同的策略,可以每次减一,也可以减一个较大的值,或者是跨任期减少,用于快速找到和该结点相匹配的日志条目。实际中还有可能会定期存储日志,所以当前日志列表中并不会太大,可以完整打包发给对方,这一做法比较适合新加入集群的结点。

日志提交

只要日志在多数结点上存在,那么 Leader 就可以提交该操作。但是 Raft 额外限制了 Leader 只对自己任期内的日志条目适用该规则,先前任期的条目只能由当前任期的提交而间接被提交。

image-20220429201034249

例如论文中图 8 这一 corner case。一开始如 (a) 所示,之后 S1 下线,(b) 中 S5 从 S3 和 S4 处获得了投票成为了 Leader 并收到了一条来自客户端的消息,之后 S5 下线。(c) 中 S1 恢复并成为了 Leader,并且将日志复制给了多数结点,之后进行了一个致命操作,将 index 为 2 的日志提交了,然后 S1 下线。(d) 中 S5 恢复,并从 S2、S3、S4 处获得了足够投票,然后将已提交的 index 为 2 的日志覆盖了。

为了解决这个问题,Raft 只允许提交自己任期内的日志,从而日志 2 只能像 (e) 中由于日志 3 同步而被间接提交,避免了 Follower 中由于缺少新任期的日志,使得 S5 能够继续成为 Leader。

总结

这张图实乃整个算法的精髓所在,值得细细品读

image-20220429201042129

Raft 简单来说就是用 Leader 负责复制日志,超过半数就可以提交。然后用了诸多限制(Election Safety、Leader Append-Only、Log Matching、Leader Completeness、State Machine Safety)保证了日志的正确性和一致性。这些限制和算法的流程可以说是强耦合在一起的,如果单拿一个出来会容易想不通为什么,但是这些限制结合到一起就成了一个极其精妙的算法~

https://blog.zinglix.xyz/2020/06/25/raft/

posted @ 2022-06-08 09:48  Maple~  阅读(89)  评论(0编辑  收藏  举报