《kubernetes 系列》3. 解密 Raft 协议,etcd 是如何用它实现数据一致性的?

楔子

在上一篇文章介绍 etcd 的时候,我们多次提到 Raft 协议,etcd 正是基于 Raft 协议实现高可用以及数据强一致性的。那么 Raft 到底是什么样的协议,它解决了分布式系统的哪些痛点呢?我们就来聊一聊。

首先为什么会有 Raft 协议呢?回想一下,早期我们使用的数据存储服务,它们往往是部署在单节点上的。但这种方式存在单点故障,一旦宕机会导致整个服务都不可用,对业务影响非常大。于是为了解决单点问题,我们引入了数据复制技术,数据复制意味着在通过网络连接的多台机器上保留相同数据的副本,这么做的原因无非以下几点:

  • 使得数据与用户在地理上接近(从而减少延迟)
  • 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
  • 扩展可以接受读请求的机器数量(从而提高读取吞吐量)

如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。但复制的困难之处在于处理复制数据的变更(change),数据一旦改变了,要如何保证所有副本的数据一致性呢。此时就依赖于共识算法,比如 Raft、Paxos 等等。

不过在介绍 Raft 之前,我们先来看一看现在主流的几种数据复制方式。

基于领导者的复制

当存在多个副本时,就会不可避免的出现一个问题:如何确保数据落在了所有的副本上?比如我们要修改(还有写入、删除)数据,很明显每一个副本都要修改,否则就会出现副本数据不一致的情况。

很容易想到的一种解决办法是,客户端在修改数据的时候将所有副本中的数据都进行修改,这样不就能保证数据的一致性了吗?没错,这的确是一种解决方案,但问题是这种做法对客户端不够友好,如果有 N 个副本,那么客户端要写入 N 次,而且还要知道这 N 个副本在什么位置。更推荐的做法是,每次修改数据时只写入一个副本,然后系统内部的节点之间对数据进行同步,来保证数据的一致性。

这种复制方式也被称为基于领导者的复制(或者说主从复制),每次修改数据时只写入一个副本,该副本就是领导者副本,其余副本是追随者副本。

  • 读请求可以交给领导者副本、也可以交给追随者副本;
  • 但写请求只能交给领导者副本,领导者副本修改完毕之后,再将数据同步给追随者副本;

当然这些内容在上一篇文章中已经说过了,比较简单。然后就是复制的方式,是同步复制还是异步复制。

我们以 B 站更换头像为例,在某个时间点更换头像的请求会发送给领导者,领导者在收到请求之后的某个时刻又会将数据变更发送给追随者,最后领导者通知客户更新成功。我们用一张图来描述这个过程:

追随者 1 的复制是同步的:在向用户报告写入成功,并使结果对其它用户可见之前,领导者需要等待追随者 1 的确认,确保追随者 1 已经收到数据变更并且完成了同步。追随者 2 的复制是异步的:领导者发送消息,但不等待追随者的响应。另外在上图中,追随者 2 处理消息前存在一个显著的延迟,通常情况下复制的速度是相当快的,哪怕是数据库,都能在一秒钟之内向追随者同步变更。

同步复制的优点是,追随者保证有和领导者一致的最新数据副本,如果领导者突然失效,我们可以确信这些数据仍然能在追随者上找到。但缺点是,如果同步时追随者没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),领导者就无法处理写入操作,此时必须阻止所有写入,并等待同步副本再次可用。因此将所有追随者都设置为同步复制是不切实际的,因为任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中一个追随者是同步的,而其它的则是异步的。

这里我们还要再来总结一下同步复制和异步复制,首先领导者会将数据变更同步给追随者,然后追随者再进行同步。如果拿 MySQL 为例的话,就是主库将 binlog 同步给从库(写入 relay log),然后从库再根据 binlog 来同步数据,确保和主库一致。

  • 同步复制:领导者必须确保数据变更已经发给了追随者,并且追随者也已经同步完毕,才能响应客户端
  • 异步复制:领导者只需要将数据变更发送给追随者即可,不需要关心追随者有没有收到,更不关心追随者在收到之后有没有将数据同步成功

所以同步复制和异步复制都有各自的缺点。

  • 同步复制:追随者接收到数据变更并进行同步时,领导者是无法对外提供服务的,它必须等到追随者同步完毕才可以。如果追随者因为某些原因(比如磁盘紧张,系统高负荷运行等等)迟迟不能写入成功,那么领导者就会出现长时间无法响应;
  • 异步复制:不会出现领导者无法响应的情况,因为领导者将数据变更发送之后就不管了,但很明显这种做法也是有缺点的,那就是如果追随者没有收到数据变更该怎么办?虽然领导者发了,但因为网络原因导致追随者没有接收到。此时就引出来一个新的复制模式:半复制;

那什么是半复制呢?我们说数据同步分为两步:第一步是领导者发送变更数据给追随者;第二步是追随者在收到变更数据之后进行数据同步。同步复制要求领导者必须等待这两步都完成,才可以响应客户端,异步复制则是两步都不关心。而半复制要求的是:领导者只要确认第一步完成(追随者收到数据变更),即可响应客户端。这样既保证了追随者一定能收到数据变更(这个过程很快),又不会陷入长时间的阻塞。

以 MySQL 为例的话,半复制就是:领导者只要保证 binlog 一定发给了追随者即可(只关心第一步),至于后续追随者通过 binlog 回放来同步数据(也就是第二步)什么时候可以完成,领导者并不关心。当然在 MySQL 里面,我们更习惯叫主库和从库,不过都是一个意思。

多主复制

单个领导者的复制架构有一个缺点:只有一个领导者,而所有的写入都必须通过它。如果出于任何原因(例如和领导者之间的网络连接中断)无法连接到领导者, 就无法向数据库等存储系统中写入数据。

而基于领导者的复制模型的自然延伸就是允许多个节点接受写入,复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据更改转发给所有其它节点,我们称之为多领导者配置(也称多主、多活复制)。在这种情况下,每个领导者同时也会扮演其它领导者的追随者,比如有 A、B、C 三个领导者,当数据写入 A 时,那么 B、C 就是 A 的追随者;当数据写入 B 时,那么 A、C 就是 B 的追随者。

所以在使用多主复制的时候,你必须有多个数据中心,因为单个数据中心使用多主复制没有太大意义,因为复杂性要大于带来的好处。

假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。如果使用常规的基于领导者的复制,领导者必须位于其中一个数据中心,且所有写入都必须经过该数据中心。而多领导者配置,可以在每个数据中心都有领导者、或者说主库。下图展示了这个架构,在每个数据中心内使用常规的基于领导者的复制;在数据中心之间,每个数据中心的领导者都会将其更改复制到其它数据中心的领导者中。

  • 如果只有一个数据中心,那么采用基于领导者的复制即可,如果有多个领导者,反而会徒增麻烦;
  • 如果有多个数据中心,那么采用多主复制。如果此时还整体采用基于领导者复制,那么假设有两个数据中心,分别在北京和江苏,北京的数据中心的某个节点是领导者。那么往位于江苏的节点写一条数据,还要写请求还要先转发给位于北京的 Leader,然后它写完了,再同步给位于江苏的 Follower,显然这是不合理的。

所以每个数据中心内部采用基于领导者的复制,而不同的数据中心有不同的领导者,这些领导者之间也会数据同步。

无主复制

不管单主复制还是多主复制,它们都是一样的做法:客户端向一个领导者发送写请求,而存储系统负责将数据复制到其它副本。领导者决定写入的顺序,而追随者按相同顺序应用领导者的写入。

而在无领导配置中,故障切换不存在。比如客户端并行发送写入到所有的三个副本,并且两个可用副本(replica1、replica2)接受写入,但是不可用副本(replica3)错过了它。假设三个副本中的两个承认写入是足够的:在客户端已经收到两个确定的响应之后,我们认为写入成功。因此,客户简单地忽略了其中一个副本错过了写入的事实。

现在想象一下,不可用的节点重新联机,客户端开始读取它,显然节点关闭时发生的任何写入都从该节点丢失。因此,如果你从该节点读取数据,则可能会将陈旧(过时)的值视为响应。

为了解决这个问题,当一个客户端从系统中读取数据时,它不仅仅发送它的请求到一个副本,读请求也被并行地发送到多个节点。客户端可能会从不同的节点获得不同的响应,即来自一个节点的最新值和来自另一个节点的陈旧值,版本号用于确定哪个值更新。

读修复和反熵

复制方案应确保最终将所有数据复制到每个副本,在一个不可用的节点重新联机之后,它如何赶上它错过的写入?在 Dynamo 风格的数据存储中经常使用两种机制:

读修复(Read repair)

当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如客户端获得了来自 replica3 的版本号为 6 的值和来自 replica1 和 replica2 的版本号为 7 的值,因此客户端会发现 replica3 具有陈旧值,那么会将新值写回,这种方法适用于频繁阅读的值。

反熵过程(Anti-entropy process)

一些数据存储具有后台进程,该进程不断查找副本之间的数据差异(随机挑选两个副本),并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。

另外并不是所有的系统都实现了这两种机制,例如 Voldemort 目前没有反熵过程。请注意,如果没有反熵过程, 某些副本中很少读取的值可能会丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。

读写的法定人数

还是上面那个例子,我们认为即使在三个副本中的两个副本上进行处理,写入仍然是成功的。但如果三个副本中只有一个接受了写入,会怎样呢?

因为每个成功的写操作意味着在三个副本中至少写入两个,那么这意味着最多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,那么可以确定至少有一个是最新的。因此第三个副本停机或响应速度缓慢时,读取仍可以继续返回最新值。

更一般地说,如果有 \({n}\) 个副本,每个写入必须由 \({w}\) 个节点确认才能被认为是成功的,并且我们必须至少为每个读取查询 \({r}\) 个节点(在我们的例子中,\({n=3}\)\({w=2}\)\({r=2}\))。只要 \({w + r > n}\),我们在读取时就能获得最新的值,因为 \({r}\) 个读取中至少有一个节点是最新的。遵循这些 \({r}\) 值和 \({w}\) 值的读写就被称为法定人数(quorum)的读和写。你可以认为,\({r}\)\({w}\) 是有效读写所需的最低票数。

在 Dynamo 风格的数据库中,参数 \({n}\)\({w}\)\({r}\) 通常是可配置的,一个常见的选择是使 \({n}\) 为奇数(通常为 3 或 5)并设置 \({w = r = (n + 1) / 2}\)。但是可以根据需要更改数字,例如设置 \({w = n}\)\({r = 1}\),这样写请求会发给所有节点,但读请求只需要读一个即可,因此写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有"只要有一个失败节点,就会导致所有数据写入失败"的缺点。

所以仲裁条件 w + r > n 允许系统容忍不可用的节点,如下所示:

  • w < n 时,如果节点不可用,我们仍然能处理写入
  • r < n 时,如果节点不可用,我们仍然能处理读取
  • 对于 n = 3、w = 2、r = 2,我们可以容忍一个不可用的节点
  • 对于 n = 5、w = 3、r = 3,我们可以容忍两个不可用的节点

以上就是基于领导者的复制、多主复制和无主复制,为了解决单点故障,引入了多副本。但基于复制算法实现的存储系统,为了保证服务的可用性,大多数提供的都是最终一致性。总而言之,不管是哪一种复制方式,都存在一定的缺陷,而想解决这一点,就需要依赖共识算法了。

Raft 共识算法概述

共识算法的祖师爷是 Paxos, 但是由于它过于复杂,难于理解,工程实践上也较难落地,导致在工程界落地较慢。绝大多数选用 Paxos 算法的系统(比如 Cubby、Spanner)都是在 Raft 算法发布前开发的,当时没得选。而全新的系统大多选择了 Raft 算法(比如 Etcd、Consul、CockroachDB)。

对于我们来说,掌握这个算法,可以得心应手地处理绝大部分场景的容错和一致性需求,比如分布式配置系统、分布式 NoSQL 存储等等,轻松突破系统的单机限制。

如果要用一句话概括 Raft 算法,那么是这样的:Raft 算法是通过一切以领导者为准的方式,实现一系列值的共识和各节点日志的一致。领导者就是 Raft 算法中的霸道总裁,通过霸道的「一切以我为准」的方式,决定了日志中命令的值,也实现了各节点日志的一致。

对于一个复杂的共识问题,斯坦福大学的 Diego 将其拆分成三个子问题:

  • Leader 选举,Leader 故障后集群能快速选出新 Leader
  • 日志复制, 集群只有 Leader 能写入日志,Leader 负责复制日志到 Follower 节点,并强制 Follower 节点与自己保持相同
  • 安全性,一个任期内集群只能产生一个 Leader;已提交的日志条目在发生 Leader 选举时,一定会存在于更高任期的新 Leader 日志中;各个节点的状态机应用的任意位置的日志条目内容应该一样

也正式 Deigo 提出了 Raft 算法,并解决了这些问题。下面我们就来探讨一下这三个子问题,并看看 Raft 是如何解决的,以及在 etcd 中的应用。

Leader 选举

当 etcd server 收到 client 发起的 put hello 写请求后,KVServer 模块会向 Raft 模块提交一个 put 提案,我们知道只有集群 Leader 才能处理写提案,如果此时集群中无 Leader, 整个请求就会超时。

那么 Leader 是怎么诞生的呢?Leader crash 之后其它节点又如何竞选呢?在多个节点的集群中,当发生节点故障、分区错误等异常情况时,Raft 算法如何保证同一时间,集群中只有一个领导者呢?

首先在 Raft 协议中给节点定义了几个状态,任何时刻,每个节点肯定处于其中一个状态:

  • 追随者(Follower):相当于普通群众,默默地接收和处理来自领导者的消息,当等待领导者心跳信息超时的时候,就主动站出来,推荐自己当候选人。
  • 候选人(Candidate):向其它节点发送请求投票(RequestVote)的 RPC 消息,通知其它节点来投票,如果赢得了大多数选票,就晋升当领导者。所以当 Follower 等待领导者心跳信息超时的时候,会变成 Candidate 发起投票,如果赢得了大多数选票就会变成 Leader。
  • 领导者:蛮不讲理的霸道总裁,一切以我为准,平常的主要工作内容就是 3 部分:处理写请求;管理日志复制;不断地发送心跳信息,通知其它节点:"我是领导者,我还活着,你们现在不要发起新的选举,找个新领导者来替代我"。另外需要注意的是,Raft 算法是强领导者模型,集群中只能有一个霸道总裁。

然后我们来看一下领导者是怎么选出来的,假设当前有三个节点组成的集群,首先在初始状态下,集群中所有的节点都是跟随者的状态。

Raft 算法实现了随机超时时间的特性。也就是说,每个节点等待领导者节点心跳信息的超时时间是随机的。通过上面的图片你可以看到,集群中没有领导者,而节点 A 的等待超时时间最小(150ms),它会最先因为没有等到领导者的心跳信息,发生超时。而一旦超时,节点 A 就增加自己的任期编号,并推举自己为候选人,先给自己投上一张选票,然后向其他节点发送请求投票 RPC 消息,请它们选举自己为领导者。

如果其它节点接收到候选人 A 的请求投票 RPC 消息,在编号为 1 的这届任期内,自己还没有进行过投票,那么它将把选票投给节点 A,并增加自己的任期编号。

如果候选人在选举超时时间内赢得了大多数的选票,那么它就会成为本届任期内新的领导者。

节点 A 当选领导者后,将周期性地发送心跳消息,通知其它服务器自己是领导者,阻止跟随者发起新的选举,篡权。

说到这儿,你是不是发现领导者选举很容易理解?与现实中的议会选举也蛮类似?当然,你可能还是对一些细节产生一些疑问:

  • 节点间是如何通讯的呢?
  • 什么是任期呢?
  • 选举有哪些规则?
  • 随机超时时间又是什么?

下面我们分别剖析这几个细节。

节点间如何通讯?

在 Raft 算法中,服务器节点间的沟通联络采用的是远程过程调用(RPC),在领导者选举中,需要用到这样两类的 RPC:

  • 请求投票(RequestVote)RPC,是由候选人在选举期间发起,通知各节点进行投票;
  • 日志复制(AppendEntries)RPC,是由领导者发起,用来复制日志和提供心跳消息。

需要注意的是:日志复制 RPC 只能由领导者发起,这是实现强领导者模型的关键之一,要注意这一点,后续能更好地理解日志复制,理解日志的一致是怎么实现的。

什么是任期?

我们知道,议会选举中的领导者是有任期的,领导者任命到期后,要重新开会再次选举。Raft 算法中的领导者也是有任期的,每个任期由单调递增的数字(任期编号)标识,比如节点 A 的任期编号是 1。

任期编号是随着选举的举行而变化的:

  • 跟随者在等待领导者心跳信息超时后,推举自己为候选人时,会增加自己的任期编号,比如节点 A 的当前任期编号为 0,那么在推举自己为候选人时,会将自己的任期编号增加为 1。
  • 如果一个服务器节点,发现自己的任期编号比其它节点小,那么它会将自己的编号更新为较大的编号值。比如节点 B 的任期编号是 0,当收到来自节点 A 的请求投票 RPC 消息时,因为消息中包含了节点 A 的任期编号,且编号为 1,那么节点 B 将把自己的任期编号也更新为 1。

需要强调的是,与现实议会选举中的领导者的任期不同,Raft 算法中的任期不只是时间段,任期编号的大小也会影响领导者选举和请求的处理。

  • 在 Raft 算法中约定,如果一个候选人或者领导者,发现自己的任期编号比其它节点小,那么它会立即恢复成跟随者状态。比如分区错误恢复后,任期编号为 3 的领导者节点 B,收到来自新领导者的、包含任期编号为 4 的心跳消息,那么节点 B 将立即恢复成跟随者状态。
  • 还约定如果一个节点接收到一个包含较小的任期编号值的请求,那么它会直接拒绝这个请求。比如节点 C 的任期编号为 4,收到包含任期编号为 3 的请求投票 RPC 消息,那么它将拒绝这个消息(官还没我大,居然让我投你成为领导,我才不干呢)。

在这里你可以看到,Raft 算法中的任期比议会选举中的任期要复杂。同样,在 Raft 算法中,选举规则的内容也会比较多。

选举有哪些规则?

在议会选举中,比成员的身份、领导者的任期还要重要的就是选举的规则,比如一人一票、弹劾制度等。毕竟无规矩不成方圆,在 Raft 算法中,也约定了选举规则,主要有以下几点。

  • 领导者周期性地向所有跟随者发送心跳消息,通知大家我是领导者,阻止跟随者发起新的选举。另外心跳消息本质上是日志复制 RPC 消息,但不包含具体的日志项,主要用于告知追随者自己还活着。
  • 如果在指定时间内,跟随者没有接收到来自领导者的消息,那么它就认为当前没有领导者,于是推举自己为候选人,发起领导者选举。
  • 在一次选举中,赢得大多数选票的候选人,将晋升为领导者。
  • 在一个任期内,领导者一直都会是领导者,直到它自身出现问题(比如宕机),或者因为网络延迟,导致其他节点发起一轮新的选举。
  • 在一次选举中,每个服务器节点最多会对一个任期编号投出一张选票,并且按照 "先来先服务" 的原则进行投票。比如节点 C 的任期编号为 3,先收到了 1 个包含任期编号为 4 的投票请求(来自节点 A),然后又收到了 1 个包含任期编号为 4 的投票请求(来自节点 B)。那么节点 C 将会把唯一一张选票投给节点 A,当再收到节点 B 的投票请求 RPC 消息时,对于编号为 4 的任期,已没有选票可投了。

并且还有一点需要注意,当任期编号相同时,日志完整性高的跟随者(也就是最后一条日志项对应的任期编号值更大,索引号更大)会拒绝投票给日志完整性低的候选人。比如节点 B、C 的任期编号都是 3,但节点 B 的最后一条日志项对应的任期编号为 3,而节点 C 为 2,那么当节点 C 请求节点 B 投票给自己时,节点 B 将拒绝投票。

关于日志项我们一会再说,不过你应该也能猜到原因,日志项的任期编号值越大,证明它同步的数据越完整。

另外值得强调的是,选举是跟随者发起的,它会先推举自己为候选人,然后发起投票;大多数选票是指集群成员半数以上的选票;大多数选票规则的目标,是为了保证在一个给定的任期内最多只有一个领导者。

然后在选举中,除了选举规则外,我们还需要避免一些会导致选举失败的情况。比如同一任期内,多个候选人同时发起选举,导致选票被瓜分,选举失败。那么在 Raft 算法中,如何避免这个问题呢?答案就是随机超时时间。

如何理解随机超时时间?

在议会选举中,常出现未达到指定票数,选举无效,需要重新选举的情况。在 Raft 算法的选举中,也存在类似的问题,那它是如何处理选举无效的问题呢?其实,Raft 算法巧妙地使用随机选举超时时间的方法,把超时时间都分散开来,在大多数情况下只有一个服务器节点先发起选举,而不是同时发起选举,这样就能减少因选票瓜分导致选举失败的情况。

但需要注意的是:在 Raft 算法中,随机超时时间是有 2 种含义的,这里是很多人容易理解出错的地方,需要注意一下:

  • 1. 跟随者等待领导者心跳信息超时的时间间隔,是随机的;
  • 2. 当没有候选人赢得过半票数,选举无效了,这时需要等待一个随机时间间隔,也就是说,等待选举超时的时间间隔,是随机的。

以上我们就了解了是什么是 Leader 选举。

补充:Raft 算法属于 Multi-Paxos 算法,它是在兰伯特 Multi-Paxos 思想的基础上,做了一些简化和限制。

  • 首先在 Raft 中,不是所有节点都能当选领导者,只有日志最完整的节点,才能当选领导者;其次在 Raft 中,日志必须是连续的。
  • Raft 算法通过任期、领导者心跳消息、随机选举超时时间、先来先服务的投票原则、大多数选票原则等,保证了一个任期只有一位领导,也极大地减少了选举失败的情况。
  • 本质上,Raft 算法以领导者为中心,选举出的领导者;以 "一切以我为准" 的方式,达成值的共识,和实现各节点日志的一致。

在这里,我们使用 Raft 算法在集群中选出了领导者节点 A。那么选完领导者之后,领导者需要处理来自客户的写请求,并通过日志复制实现各节点日志的一致。

日志复制

我们知道了 Raft 除了能实现一系列值的共识之外,还能实现各节点日志的一致,不过你也许会有这样的疑惑:什么是日志呢?它和我的业务数据有什么关系呢?

想象一下,一个木筏(Raft)是由多根整齐一致的原木(Log)组成的,而原木又是由木质材料组成,如果把日志比喻成原木,那么日志项就是木质材料,所以你可以认为日志是由多条日志项(Log entry)组成的。在 Raft 算法中,副本数据是以日志的形式存在的,领导者接收到来自客户端写请求后,处理写请求的过程就是一个复制和提交日志项的过程。

那 Raft 是如何复制日志的呢?又如何实现日志的一致的呢?这些内容是 Raft 中非常核心的内容,也是重点。首先,咱们先来理解日志,这是掌握如何复制日志、实现日志一致的基础。

如何理解日志?

刚刚提到,副本数据是以日志的形式存在的,日志是由日志项组成,那么日志项究竟是什么样子呢?

其实,日志项是一种数据格式,它主要包含用户指定的数据,也就是指令(Command),还包含一些附加信息,比如索引值(Log index)、任期编号(Term)。那我们该怎么理解这些信息呢?

  • 指令:一条由客户端请求指定的、状态机需要执行的指令。你可以将指令理解成客户端指定的数据。
  • 索引值:日志项对应的整数索引值。它其实就是用来标识日志项的,是一个连续的、单调递增的整数。
  • 任期编号:创建这条日志项的领导者的任期编号。

从图中你可以看到,一届领导者任期,往往有多条日志项。而且日志项的索引值是连续的,这一点需要注意。

看到这儿你可能会问:不是说 Raft 实现了各节点间日志的一致吗?那为什么图中 4 个跟随者的日志都不一样呢?日志是怎么复制的呢?又该如何实现日志的一致呢?别着急,接下来咱们就来解决这几个问题。先来说说如何复制日志。

如何复制日志?

你可以把 Raft 的日志复制理解成一个优化后的二阶段提交(将二阶段优化成了一阶段),减少了一半的往返消息,也就是降低了一半的消息延迟。那日志复制的具体过程是什么呢?

首先,领导者进入第一阶段,通过日志复制(AppendEntries)RPC 消息,将日志项复制到集群其他节点上;接着,如果领导者接收到大多数的"复制成功"响应后,它将日志项提交到它的状态机,并返回成功给客户端。如果领导者没有接收到大多数的"复制成功"响应,那么就返回错误给客户端。

到这里有人可能有这样的疑问了,领导者将日志项提交到它的状态机,怎么没通知跟随者提交日志项呢?这是 Raft 中的一个优化,领导者不直接发送消息通知其他节点提交指定日志项。因为领导者的日志复制 RPC 消息或心跳消息,包含了当前最大的,将会被提交的日志项索引值。所以通过日志复制 RPC 消息或心跳消息,跟随者就可以知道领导者的日志提交位置信息。

因此,当其他节点接收领导者的心跳消息,或者新的日志复制 RPC 消息后,就会将这条日志项提交到它的状态机。而这个优化,降低了处理客户端请求的延迟,将二阶段提交优化为了一段提交,降低了一半的消息延迟。为了更好理解,我们画了一张过程图,然后再走一遍这个过程,这样可以更加全面地掌握日志复制。

  • 1. 接收到客户端请求后,领导者基于客户端请求中的指令,创建一个新日志项,并附加到本地日志中。
  • 2. 领导者通过日志复制 RPC,将新的日志项复制到其他的服务器。
  • 3. 当领导者将日志项,成功复制到大多数的服务器上的时候,领导者会将这条日志项提交到它的状态机中。
  • 4. 领导者将执行的结果返回给客户端。
  • 5. 当跟随者接收到心跳信息,或者新的日志复制 RPC 消息后,如果跟随者发现领导者已经提交了某条日志项,而它还没提交,那么跟随者就将这条日志项提交到本地的状态机中。

不过,这是一个理想状态下的日志复制过程。在实际环境中,复制日志的时候,你可能会遇到进程崩溃、服务器宕机等问题,这些问题会导致日志不一致。那么在这种情况下,Raft 算法是如何处理不一致日志,实现日志的一致的呢?

如何实现日志的一致?

在 Raft 算法中,领导者通过强制跟随者直接复制自己的日志项,处理不一致日志。也就是说,Raft 是通过以领导者的日志为准,来实现各节点日志的一致的。具体有 2 个步骤。

  • 首先,领导者通过日志复制 RPC 的一致性检查,找到跟随者节点上,与自己相同日志项的最大索引值。也就是说,这个索引值之前的日志,领导者和跟随者是一致的,之后的日志是不一致的了。
  • 然后,领导者强制跟随者更新覆盖的不一致日志项,实现日志的一致。

下面再详细地走一遍这个过程,为了方便演示,我们引入 2 个新变量。

  • PrevLogEntry:表示当前要复制的日志项的前一条日志项的索引值。比如在下图中,如果领导者将索引值为 8 的日志项发送给跟随者,那么此时 PrevLogEntry 值为 7。
  • PrevLogTerm:表示当前要复制的日志项的前一条日志项的任期编号。比如在图中,如果领导者将索引值为 8 的日志项发送给跟随者,那么此时 PrevLogTerm 值为 4。

假设当前有 8 条日志项:

  • 领导者通过日志复制 RPC 消息,发送当前最新日志项到跟随者,这个消息的 PrevLogEntry 值为 7,PrevLogTerm 值为 4。
  • 如果跟随者在它的日志中,找不到与 PrevLogEntry 值为 7、PrevLogTerm 值为 4 的日志项,也就是说它的日志和领导者的不一致了,那么跟随者就会拒绝接收新的日志项,并返回失败信息给领导者。
  • 这时,领导者会递减要复制的日志项的索引值,并发送新的日志项到跟随者,这个消息的 PrevLogEntry 值为 6,PrevLogTerm 值为 3。
  • 如果跟随者在它的日志中,找到了 PrevLogEntry 值为 6、PrevLogTerm 值为 3 的日志项,那么日志复制 RPC 返回成功。这样一来,领导者就知道在 PrevLogEntry 值为 6、PrevLogTerm 值为 3 的位置,跟随者的日志项与自己相同。
  • 领导者通过日志复制 RPC,复制并更新覆盖该索引值之后的日志项(也就是不一致的日志项),最终实现了集群各节点日志的一致。

从上面步骤中我们可以看到,领导者通过日志复制 RPC 一致性检查,找到跟随者节点上与自己相同日志项的最大索引值,然后复制并更新覆盖该索引值之后的日志项,实现了各节点日志的一致。但需要注意的是,跟随者中的不一致日志项会被领导者的日志覆盖,而且领导者从来不会覆盖或者删除自己的日志(就是这么霸道)。


总结:

  • 在 Raft 中,副本数据是以日志的形式存在的,其中日志项中的指令表示用户指定的数据。
  • 兰伯特的 Multi-Paxos 不要求日志是连续的,但在 Raft 中日志必须是连续的。而且在 Raft 中,日志不仅是数据的载体,日志的完整性还影响领导者选举的结果。也就是说,日志完整性最高的节点才能当选领导者。
  • Raft 是通过以领导者的日志为准,来实现日志的一致的。

因此可以看到,值的共识和日志的一致都是由领导者决定的,所以领导者的唯一性很重要。那如果我们需要对集群进行扩容或缩容,比如将 3 节点集群扩容为 5 节点集群,这时候是可能同时出现两个领导者的。这是为什么呢?在 Raft 中,又是如何解决这个问题的呢?下面我们来聊一聊。

有一个网址,可以通过动画的形式来了解 Raft 算法,非常的形象,推荐玩一玩。

如何解决成员变更的问题?

在日常工作中,你可能会遇到服务器故障的情况,这时你就需要替换集群中的服务器。如果遇到需要改变数据副本数的情况,则需要增加或移除集群中的服务器。总的来说,在日常工作中,集群中的服务器数量是会发生变化的。然后你可能会好奇:Raft 是共识算法,对集群成员进行变更时(比如增加 2 台服务器),会不会因为集群分裂,出现 2 个领导者呢?

毫无疑问,的确会出现这个问题,因为 Raft 的领导者选举,建立在大多数的基础之上。那么当成员变更时,集群成员发生了变化,就可能同时存在新旧配置的 2 个大多数,出现 2 个领导者,破坏了 Raft 集群的领导者唯一性,影响了集群的运行。

而关于成员变更,不仅是 Raft 算法中比较难理解的一部分,也是 Raft 算法中唯一被优化和改进的部分。比如,最初实现成员变更的是联合共识(Joint Consensus),但这个方法实现起来难,后来 Raft 的作者就提出了一种改进后的方法,单节点变更(single-server changes)。

下面我们就来聊一聊这块内容。

假设我们有一个由节点 A、B、C 组成的 Raft 集群,现在我们需要增加数据副本数,增加 2 个副本(也就是增加 2 台服务器),扩展为由节点 A、B、C、D、E, 5 个节点组成的新集群:

那么 Raft 算法是如何保障在集群配置变更时,集群能稳定运行,不出现两个领导者呢?带着这个问题,我们开始学习下面的内容。先来看一看,成员变更时,到底会出现什么样的问题?

成员变更的问题?

在集群中进行成员变更的最大风险是,可能会同时出现 2 个领导者。比如在进行成员变更时,节点 A、B 和 C 之间发生了分区错误,节点 A、B 组成旧配置中的大多数,也就是变更前的 3 节点集群中的大多数,那么这时的领导者(节点 A)依旧是领导者。另一方面,节点 C 和新节点 D、E 组成了新配置的大多数,也就是变更后的 5 节点集群中的大多数,它们可能会选举出新的领导者(比如节点 C)。那么这时,就出现了同时存在 2 个领导者的情况。

如果出现了 2 个领导者,那么就违背了 "领导者的唯一性" 原则,进而影响到集群的稳定运行。那么要如何解决这个问题呢?也许有人想到了一个解决方法。

因为我们在启动集群时,配置是固定的,不存在成员变更,在这种情况下,Raft 的领导者选举能保证只有一个领导者。也就是说,这时不会出现多个领导者的问题,因为我可以先将集群关闭再启动新集群啊。也就是先把节点 A、B、C 组成的集群关闭,然后再启动节点 A、B、C、D、E 组成的新集群。

这是一个解决办法,但明显不友善, 如果你每次变更都要重启集群,意味着在集群变更期间服务不可用,这肯定不行啊,太影响用户体验了。想象一下,你正在玩王者荣耀,打的正嗨的时候弹出一个对话框通知你:系统升级,游戏暂停 3 分钟。这体验糟糕不糟糕?

既然这种方法影响用户体验,根本行不通,那到底怎样解决成员变更的问题呢?最常用的方法就是单节点变更。

如何通过单节点变更解决成员变更的问题?

单节点变更,就是通过一次变更一个节点实现成员变更。如果需要变更多个节点,那你需要执行多次单节点变更。比如将 3 节点集群扩容为 5 节点集群,这时你需要执行 2 次单节点变更,先将 3 节点集群变更为 4 节点集群,然后再将 4 节点集群变更为 5 节点集群。

现在,让我们回到开始的思考题,看看如何用单节点变更的方法,解决这个问题。为了演示方便,我们假设节点 A 是领导者:

目前的集群配置为 [A, B, C],我们先向集群中加入节点 D,这意味着新配置为 [A, B, C, D]。成员变更,是通过这么两步实现的:

  • 第一步,领导者(节点 A)向新节点(节点 D)同步数据;
  • 第二步,领导者(节点 A)将新配置 [A, B, C, D] 作为一个日志项,复制到新配置中的所有节点(节点 A、B、C、D)上,然后将新配置的日志项提交到本地状态机,完成单节点变更。

在变更完成后,现在的集群配置就是 [A, B, C, D],我们再向集群中加入节点 E,也就是说,新配置为 [A, B, C, D, E]。成员变更的步骤和上面类似:

  • 第一步,领导者(节点 A)向新节点(节点 E)同步数据;
  • 第二步,领导者(节点 A)将新配置 [A, B, C, D, E] 作为一个日志项,复制到新配置中的所有节点(A、B、C、D、E)上,然后再将新配置的日志项提交到本地状态机,完成单节点变更。

这样一来,我们就通过一次变更一个节点的方式,完成了成员变更,保证了集群中始终只有一个领导者,而且集群也在稳定运行,持续提供服务。以上是增加节点,移除节点也是类似的,因此在正常情况下,不管旧的集群配置是怎么组成的,旧配置的大多数和新配置的大多数都会有一个节点是重叠的。

从上图中可以看到,不管集群是偶数节点,还是奇数节点,不管是增加节点,还是移除节点,新旧配置的大多数都会存在重叠(图中的绿色节点)。但需要注意的是,在分区错误、节点故障等情况下,如果我们并发执行单节点变更,那么就可能出现一次单节点变更尚未完成,新的单节点变更又在执行,导致集群出现 2 个领导者的情况。

如果你遇到这种情况,可以在领导者启动时,创建一个 NO_OP 日志项(也就是空日志项),只有当领导者将 NO_OP 日志项提交后,再执行成员变更请求。这个解决办法,记住就可以了,可以自己试着研究下。

总结一下:

  • 成员变更的问题,主要在于进行成员变更时,可能存在新旧配置的 2 个大多数,导致集群中同时出现两个领导者,破坏了 Raft 的领导者的唯一性原则,影响了集群的稳定运行。
  • 单节点变更是利用一次变更一个节点,不会同时存在旧配置和新配置 2 个大多数的特性,实现成员变更。
  • 因为联合共识实现起来复杂,不好实现,所以绝大多数 Raft 算法的实现,采用的都是单节点变更的方法(比如 Etcd、Hashicorp Raft)。其中,Hashicorp Raft 单节点变更的实现,是由 Raft 算法的作者迭戈·安加罗(Diego Ongaro)设计的,很有参考价值。

有很多人把 Raft 当成一致性算法,其实 Raft 不是一致性算法而是共识算法,是一个 Multi-Paxos 算法,实现的是如何就一系列值达成共识。并且,Raft 能容忍少数节点的故障。虽然 Raft 算法能实现强一致性,也就是线性一致性(Linearizability),但需要客户端协议的配合。在实际场景中,我们一般需要根据场景特点,在一致性强度和实现复杂度之间进行权衡。比如 Consul 实现了三种一致性模型。

  • default:客户端访问领导者节点执行读操作,领导者确认自己处于稳定状态时(在 leader leasing 时间内),返回本地数据给客户端,否则返回错误给客户端。在这种情况下,客户端是可能读到旧数据的,比如此时发生了网络分区错误,新领导者已经更新过数据,但因为网络故障,旧领导者未更新数据也未退位,仍处于稳定状态。
  • consistent:客户端访问领导者节点执行读操作,领导者在和大多数节点确认自己仍是领导者之后返回本地数据给客户端,否则返回错误给客户端。在这种情况下,客户端读到的都是最新数据。
  • stale:从任意节点读数据,不局限于领导者节点,客户端可能会读到旧数据。

一般而言,在实际工程中,Consul 的 consistent 就够用了,可以不用线性一致性,只要能保证写操作完成后,每次读都能读到最新值就可以了。比如为了实现幂等操作,我们使用一个编号(ID)来唯一标记一个操作,并使用一个状态字段(nil/done)来标记操作是否已经执行,那么只要我们能保证设置了 ID 对应状态值为 done 后,能立即和一直读到最新状态值就可以了,也就通过防止操作的重复执行,实现了幂等性。

posted @ 2023-05-29 00:08  古明地盆  阅读(985)  评论(0编辑  收藏  举报