再谈Raft一致性算法

454 阅读18分钟

背景

之前读了论文,然后在组内进行了分享:Raft一致性算法

在分享过程中,组内同学提了很多问题,并解答了我很多疑问,学到了很多,后续自己也去查了一些资料,所以本文基于上次内容,浅浅的聊一下分布式存储以及Raft算法。涉及Raft相关部分建议先阅读原论文以及上期分享的文章。

分布式存储

为何分布式存储

随着用户量的增长,请求的并发量会越来越大,数据存储规模也会越来越大,对于单一机器来说,其内存与磁盘的压力会越来越大,机器响应延迟会升高,甚至会变成不可用状态。因此,就需要采用某种方式,使得机器能够应对高并发时保持高可用状态。

一般来说,会有两种。

  1. 增加单一机器的硬件资源,比如扩大机器的内存、磁盘大小,选择更快的存储设备和CPU等。

    这种方式称为垂直扩展,通过提升单一机器的性能来提升系统的容量与性能。垂直扩展会比较简单,但是这种方式的成本较高,硬件的升级与维护需要较大投入,同时爆发式的请求量(如618、双11的购物订单)性能提升总有瓶颈。

  2. 联合多个机器来应对客户端请求来缓解单一机器的压力。

    这种方式称为水平扩展,即分布式存储。通过增加/减少系统中服务器节点,系统可以灵活动态的扩缩容来应对不同量级的请求。水平扩展则需要平衡CAP定理的三大特性。

一般在分布式系统中,垂直扩展和水平扩展方式会结合使用,根据不同应用场景,来选择更加合适的扩展方式。

何为分布式存储

分布式存储就是将数据分布存储在不同的机器上,这些机器组成了一个集群来响应客户端对数据的存取请求,而这些机器被称为集群中的节点

分布式的好处是:

  1. 容错/高可用性: 即使集群中部分节点宕机了,集群仍然可以响应客户端的请求。
  2. 低延迟: 客户端的请求会分发到离客户端地址位置上较近的节点。
  3. 可扩展性: 当系统无法应对目前的并发请求量,可以通过新增节点的方式水平扩展系统的容量。

对于数据在集群节点中的存储方式,分布式存储分为两种。

  1. 复制(Replication)

    数据通过副本的方式存储在所有节点上,每个节点上都有完整的数据。Raft算法就是这种复制式的分布式算法。

  2. 分区(Partition)

    随着数据规模越来越大,每个节点上都存有完整的数据副本会对各个节点。

    通过将数据集进行分片(shard)方式存储在不同节点上,每个节点都存储部分数据。

一般来说,复制和分区也是结合使用的,使得每个分区的副本复制存储在多个节点上。

拜占庭将军问题

一组拜占庭将军分别各率领一支军队共同围困一座城市。为了简化问题,将各支军队的行动策略限定为进攻或撤离两种。因为部分军队进攻部分军队撤离可能会造成灾难性后果,因此各位将军必须通过投票来达成一致策略,即所有军队一起进攻或所有军队一起撤离。因为各位将军分处城市不同方向,他们只能通过信使互相联系。在投票过程中每位将军都将自己投票给进攻还是撤退的信息通过信使分别通知其他所有将军,这样一来每位将军根据自己的投票和其他所有将军送来的信息就可以知道共同的投票结果而决定行动策略。

——维基百科

具体可看从拜占庭将军问题到分布式系统的一致性 - 掘金,在分布式系统中,节点通过通信交换信息达成共识后做出相同的操作。但如果系统中的部分节点发生了异常,可能会导致系统的成员做出了不同的操作,从而破坏系统一致性。

拜占庭容错算法

针对存在恶意节点的情况,就有了拜占庭容错算法,如PBFT (Practical Byzantine Fault Tolerance ,它是解决拜占庭将军问题的经典算法,它允许系统容忍超过三分之一的节点是拜占庭的。

非拜占庭问题

多数派算法主要解决了非拜占庭将军问题,即假设节点不会恶意发出错误消息,那么在集群中,一个节点会向其他节点发送通信并拿到其他节点的响应,只有响应结果超过 [N/2]+1,才认可这个结果。Raft算法就是这样的多数派算法,任何操作只有经过大部分节点的认可才会进行下去。

幽灵复现

在分布式系统中,“幽灵复现”(Phantom Reappearance)指的是那些在系统中已经被删除或移除的对象或记录,在某些情况下会再次出现在系统中的一种问题。究其缘由,主要还是由系统中的数据同步问题、一致性问题引起的。

假设有一个分布式数据库系统,其中数据在多个节点之间进行复制和同步。现在考虑以下场景:

系统中有节点A、B、C,节点A此时为Leader节点。

  1. 数据写入和删除

    1. 客户端发起请求给节点A 写入一条记录(例如:{"id": 1, "name": "Alice"}),节点A 写入记录。
    2. 节点A 将此记录复制数据给节点B 和 C,确保所有节点都有一致的数据。
  2. 删除操作

    1. 客户端发起请求给节点A 删除这条记录,节点A上执行删除操作。
    2. 节点A 将删除操作同步到节点B 和 C,以确保数据在所有节点上被删除。
  3. 网络分区

    1. 在删除操作正在复制过程中,网络此时出现了分区,节点A 和节点B 无法再通信,而节点A 和节点C 之间的通信仍然正常, 节点B 和节点C之间通信也仍然正常。
    2. 由于网络分区,节点B 未能接收到删除记录的消息。
  4. 分区恢复

    1. 网络分区问题解决后,节点A和节点B重新建立通信连接。

    2. 通信建立后会有两种情况。

      • 正常情况

        1. 节点A 将其最新的数据日志复制给节点B,包括之前的删除操作,节点B 接收后同步了自己的数据状态。
      • 数据同步冲突

        1. 假设在分区恢复后,节点B 发起了选举,并成为了Leader,它会将其数据同步到节点A 。包括那条写入记录({"id": 1, "name": "Alice"})也会一同复制给节点A 。
        2. 那么此时,节点A 上原本已经删除的记录“幽灵般”地再次出现。

这就是幽灵复现问题。

CAP定理

CAP定理(CAP theorem)即CAP原则,又被称作布鲁尔定理(Brewer's theorem)。它是指在一个分布式计算系统来说,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)不可能同时满足。

  • 一致性(Consistency):系统需要保证系统内所有节点都访问的是同一份最新的数据副本

  • 可用性(Availability): 系统对于客户端的请求都能获取到响应

  • 分区容错性(Partition tolerance):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C 和A 之间做出选择。

Raft算法

介绍

这里对Raft算法仅做简单介绍,详细请看之前的文章。Raft算法是一种通过领导者选举的复制型共识算法。在Raft算法中,时间被划分为一个一个任期(Term) ,每个节点都有自己观测到的任期(currentTerm) ,并且在每个任期内,至多有一位领导者存在,并由这个领导者负责客户端的响应以及向其余节点发送复制指令操作日志以达到集群一致性。

Raft算法的日志条目结构是由一个一个有序号的条目组成,而日志条目除了包含相应的状态机指令之外还有着本条目创建的任期,log index则标志着当前log entry所在的位置。

下面是Raft算法中的三种状态转变。对于F追随者来说,它只接收请求,不主动发送请求,但当一段时间内(称为选举超时,election timeout)接收不到请求,就会变成Candidate,发起Leader选举。

一般来说,Raft系统采用3节点/5节点副本形式构建集群。

CAP原则

Raft算法的出现给大家带来了新的想法即它在一定条件下保证了CAP三者。

  1. 一致性 (Consistency)

    1. Raft 属于多数派算法,它保证了大多数节点上有序的操作日志,即使在某些节点发生故障的情况下。所有操作必须通过大多数Follower认可才能在日志中提交,确保大多数节点看到相同的日志顺序。
    2. 领导选举:Raft 在任意任期内至多只有一个领导者。所有客户端请求必须由领导者响应,即使请求到Follower,也会被重定向给领导者进行响应,这确保了日志的一致性。
    3. 日志复制:领导者将日志条目同步给所有Follower,并在大多数Follower确认日志条目之后,提交日志条目并应用到状态机。
  2. 可用性 (Availability)

    1. 在领导节点故障时,Raft 可以快速选举出一个新的领导节点,使系统在较短时间内恢复服务。
    2. 只要大多数节点(包括新的领导节点)存活且能够互相通信,系统就能够继续处理请求并保持可用性。
  3. 分区容忍性 (Partition Tolerance)

    1. Raft 保证了在网络分区的情况下,系统能够继续工作。虽然在分区期间可能无法处理写请求(因为写请求需要领导节点和大多数节点的确认),但是系统可以继续处理读请求,保持部分可用性。
    2. 当网络分区恢复后,系统会进行必要的恢复操作,确保所有节点的一致性。

总的来说,Raft 优先保证了 C 和 A ,但在网络分区的情况下,客户端的请求可能会受到限制,直到分区恢复。这种设计使得 Raft 在分布式系统中能够在一致性和可用性之间找到一个平衡点,同时具备一定的分区容忍能力。

领导者选举

领导者选举(Leader Election)是指节点Server在一定时间内没有收到其余节点的请求,那么他就会认为集群中没有Leader,此时Server从Follower转变成Candidate,把自己观测到的任期自增1,然后向其他节点请求投票。

  1. 如果接收到大部分其他Server的投票,那么节点转变成Leader,开始向其他节点发送请求。

  2. 如果Candidate接收到某一节点返回的任期比自己的大,更新自我的currentTerm,然后立即转变成Follower。

  3. 如果选举超时(没有收到大部分Server投票并且没有新Leader也没发请求 也属于选举超时),那么自增currentTerm,重新发起选举。

活锁问题

活锁并不是真的有锁,它是指任务或者执行者没有被阻塞,但是没有满足一定条件导致任务或者执行者一直重复尝试并失败。一般死锁的条件是资源互相侵占,等待对方释放资源。而活锁有意思的点在于其实资源没有互相侵占,也没有等待别人的资源。比如一个三节点副本的系统。

A B C 三个节点,A 为Leader,其经历了以下的过程。

  1. Leader 节点A 发生了宕机。
  2. 节点B、C 此时没有收到节点A的请求,并且节点B、C从 Follower 转变成 Candidate。
  3. Candidate节点B、C 会增加自己的观测任期(假设二者观测任期一样)投票给自己,然后互相发送请求投票请求。
  4. 节点B、C发现对方的观测任期不比自己大,所以拒绝投票给对方。
  5. 选举超时,节点B、C再次自增观测任期,发起新的一轮选举。

这个时候我们发现,节点B、C由于一直投票给自己,所以导致每个任期都没有选举出Leader,两个节点一直处于Candidate状态导致系统不可用。

为了避免活锁问题,Raft算法规定每个节点的election timeout是随机的且不固定,一般从10ms~500ms之间随机选择,超时时间在这个时间段,大多数情况下只有一个服务器会超时。

Prevote

Raft作者在自己的博士论文CONSENSUS: BRIDGING THEORY AND PRACTICE第九章里提到了预投票(prevote)。预投票是在正式选举之前进行投票,旨在减少无效选举的发生,增强系统的稳定性和一致性。

在领导者选举这一节,Candidate获得投票的条件如下:

  1. Candidate的Term比Server的Term大。
  2. Candidate的日志至少比大部分Server的要新(先比较任期号大小,大者新;任期号相同情况下比较日志索引号大小,同样大者新)。

我们来考虑一种网络分区的异常情况。假设A与E都可以与B、C、D三台机子通信,但是A 和 E 二者无法通信。

暂时无法在飞书文档外展示此内容

  1. 假设某一时刻 A 变成了Leader,此时 E 因为网络问题或者机器故障而导致无法与A通信。
  2. Follower E因为没有接收到Leader A的请求而超时,转变成Candidate,并把自己观测到的任期+1,并发起选举。
  3. 如果 A 还未更新日志给 B、C、D(即E的日志条目至少和B、C、D一样新) ,那么由于 E 的 任期 比 B、C、D 大,满足被投票条件,所以 E 当选Leader。
  4. E当选Leader后发送请求给B、C、D,更新三个节点 观测到的任期。
  5. A 发请求给B、C、D,发现自己的任期比B、C、D的小,自己退回Follower状态。
  6. 而后A 也会像E一样收不到Leader的请求,发起选举,循环往复。

当然这一场景的前提是Leader未能及时同步日志给各个Follower。

Raft作者在论文中提到了一种Prevote算法,即选举阶段,引入一个新的阶段——Prevote阶段,Server在发起选票之前会先确认自己能够赢得大部分Server投票,才会把自己的item + 1,然后才会真正发起投票。这一阶段会发起Pre-vote Request,这一请求与RequestVoteRPC没有什么区别,只是不会更改自身的状态(如任期不变,也不会投票给自己)。

日志复制

Raft算法规定了一些特性以及规则来保证系统内日志的完整性与统一性。

首先,Raft算法的领导者仅追加特性(Leader Append-Only特性) 规定其日志条目仅会在原来的基础上进行增加,而不会删除覆盖任何之前的日志

其次,其日志匹配特性(Log Matching Property ) 又保证了各个服务器之间日志的统一性。

最后,Raft算法规定了Leader不会通过拿到各个Follower成功响应的方式提交这些日志,而是会默认提交这些日志,只有Leader自己任期内的日志才会需要得到大多是Follower响应才能提交

这样就解决了“幽灵复现”的问题。具体可以看上一篇文章的Safety验证阶段。

成员变更

联合共识变更

Raft算法提到了一种2PC(两阶段提交,2 Phase Commit)的变更方式称为联合共识变更(joint consensus),这里稍微复述一下论文中的理论。

第一阶段,当Leader接收到需要从旧配置 C-old 变更到新配置 C-new 的请求时,Leader会为了联合共识阶段将这个配置存储一个特殊的日志条目(称之为 C-old,new配置日志)并把它复制给所有的Server。一旦Server接收了该日志条目后,不管这个日志条目是否提交,Server都会使用新配置。后续Leader会使用 联合共识 **的配置来决定 C-old,new 配置 日志何时会被提交。此后所有日志都需要 C-oldC-new 两个多数派的确认。

第二个阶段,Leader会创建一条关于 C-new 的日志条目并复制给集群。当这条 C-new 的日志条目被提交,后续日志确认只需要得到 C-new 的成功响应即可,并且C-old 中机器下线。

单成员变更

所谓单节点变更指的是集群每次只增加/删除一个节点。这也是Raft论文作者在博士论文第四章里提出的变更算法。

那为什么这样是安全的呢?因为对于一个集群来说,他只有获得集群中大部分Server的选票才能当选。如果每次只增加或者删除一个节点,那么在C-oldC-new所谓大部分Server总有一个节点是交集,这个节点只会进行一次投票,不投给C-old就投给C-new,那么总有一个集群选举时无法达到“大部分Server”的投票。

单成员变更的流程如下:

  1. Leader收到一个成员变更请求,向自己的日志中追加一条 C-new配置 的日志,日志内容为请求添加/删除一个节点,并通过AppendEntriesRPC请求同步给所有的Follower。
  2. C-new配置 日志被Server接收后立即生效,不需要等待提交,此时新旧配置集群仍然都可以选举成Leader。
  3. 当Leader确认 C-new配置 日志被复制到大部分节点后,就会提交 C-new配置 日志。

以上就是整个单节点变更的流程,在日志被提交以后,Leader返回请求响应,本轮变更已经完成,继续下一轮的成员变更。

下面RPC请求是论文给到的成员变更请求。

从上面可以看出,单成员变更的做法要比联合共识的设计要简单的多,因此Raft算法单成员变更要比联合共识变更实用的更广泛,工业上选择这种one by one成员变更的方式较多。

应用场景

Raft算法是一种用于实现分布式系统中强一致性(consensus)的算法。它设计简洁、易于理解,相较于其他一致性算法如Paxos更容易实现和维护。在我的理解中,Raft更适用于容错保活、分布协调类的系统,如以下场景:

  1. 容错服务

Raft算法可以用于构建容错服务,如TCC事务中的事务协调者(Transaction Manager,TM),再比如在分布式应用中的领导者选举、配置管理、协调服务等场景中,Raft能够确保在面对节点故障时系统依然能够正常运行。

  1. 分布式协调服务

类似于Zookeeper的服务需要保证分布式节点之间的协调和一致性。Raft算法也可以用于实现这种协调服务,确保多个节点之间的一致性决策。

参考

CONSENSUS: BRIDGING THEORY AND PRACTICE

分布式的核心问题:复制和分区

Raft Prevote--如何避免惊群效应

Raft的PreVote实现机制

分布式系统架构设计之分布式数据存储的扩展方式、主从复制以及分布式一致性

Raft 算法之集群成员变更

如何解决分布式系统中的“幽灵复现”?