Paxos 实现日志复制同步(Multi-Paxos)
<div class="postBody">
<div id="cnblogs_post_body" class="blogpost-body cnblogs-markdown"><h1 id="paxos-实现日志复制同步">Paxos 实现日志复制同步</h1>
这篇文章以一种易于理解的方式来解释 Multi-Paxos 的机制。
Multi-Paxos 的是为了创建日志复制
一种实现方式是用一组基础 Paxos 实例,每条记录都有一个独立的 Paxos 实例,要想这么做只需要为每个 Prepare 和 Accept 请求增加一个小标索引(index),用来选择特定的记录,所有的服务器为日志里的每条记录都保有独立的状态。
上图展示了一个请求的完整周期。
从客户机开始,它向服务器发送所需执行的命令,它将命令发送至其中一台服务器的 Paxos 模块。
这台服务器运行 Paxos 协议,让该条命令(shl)被选择作为日志记录里的值。这需要它与其他服务器之间进行通信,让所有的服务器都达成一致。
服务器等待所有之前的日志被选定后,新的命令将被应用到状态机。
这时服务器将状态机里的结果返回给客户端。
这是个基本机制,后面会作详细介绍。
Multi-Paxos 的问题
本页介绍了 Multi-Paxos 所需解决的一些基本问题,让 Multi-Paxos 在实际中得以正确运行。
![](/qrcode.jpg)
- 对于一个请求,如何决定该使用哪条日志记录?
- 第二个问题关于性能。如果用之前的基础 Paxos 中描述的方式,运行会很慢。所以引入了 领导者(leader) 来降低 提议者(proposer) 之间的冲突。还可以消除几乎绝大多数的 准备阶段(Prepare) 的请求,只需要处理一轮 RPC 请求。
- 这个问题关于完整复制。如何保证所有的服务器最终都有每天记录,每个服务器都知道被选定的日志记录是什么。
- 客户端协议。会介绍客户端在服务器崩溃时,如何还能正常工作。
- 最后一个问题就是配置的变更。如何安全地给 Multi-Paxos 集群增加或移除服务器。
这里需要注意的是,基础 Paxos 已经有非常完整地描述,而且分析也证明它是正确的。非常易于理解。但是 Multi-Paxos 确不是这样,它的描述很抽象,有很多选择处理方式,没有一个是很具体的描述。而且,Multi-Paxos 并没有为我们详细的描述它是如何解决这些问题的。
这篇文章以一种易于理解的方式来解释 Multi-Paxos 的机制。现在我们还没有实现它,也还没有证明它的正确性,所以后续解释可能会有 bug 。但希望这些内容可以对解决问题、构建可用的 Multi-Paxos 协议有所帮助
选择日志记录
第一个问题是当接收到客户端请求时如何选择日志槽,我们以上图中的例子来阐述如何做到这点。假设我们的集群里有三台服务器,所以 “大多数” 指的是 2 。这里展示了当客户端发送命令(jmp)时,每台服务器上日志的状态,客户端希望这个请求值能在日志中被记录下来,并被状态机执行。
当接收到请求时,服务器 S1 上的记录可能处于不同的状态,服务器知道有些记录已经被选定(1-mov,2-add,6-ret),在后面我会介绍服务器是如何知道这些记录已经被选定的。服务器上也有一些其他的记录(3-cmp),但此时它还不知道这条记录已经被选定。在这个例子中,我们可以看到,实际上记录(3-cmp)已经被选定了,因为在服务器 S3 上也有相同的记录,只是 S1 和 S3 还不知道。还有空白记录(4-,5-)没有接受任何值,不过其他服务器上相应的记录可能已经有值了。
现在来看看发生些什么:
当 jmp 请求到达 S1 后,它会找到第一个没有被选定的记录(3-cmp),然后它会试图让 jmp 作为该记录的选定值。为了让这个例子更具体一些,我们假设服务器 S3 已经下线。所以 Paxos 协议在服务器 S1 和 S2 上运行,服务器 S1 会尝试让记录 3 接受 jmp 值,它正好发现记录 3 内已经有值(3-cmp),这时它会结束选择并进行比较,S1 会向 S2 发送接受(3-cmp)的请求,S2 会接受 S1 上已经接受的 3-cmp 并完成选定。这时(3-cmp)变成粗体,不过还没能完成客户端的请求,所以我们返回到第一步,重新进行选择。找到当前还没有被选定的记录(这次是记录 4-),这时 S1 会发现 S2 相应记录上已经存在接受值(4-sub),所以它会再次放弃 jmp ,并将 sub 值作为它 4- 记录的选定值。所以此时 S1 会再次返回到第一步,重新进行选择,当前未被选定的记录是 5- ,这次它会成功的选定 5-jmp ,因为 S1 没有发现其他服务器上有接受值。
当下一次接收到客户端请求时,首先被查看的记录会是 7- 。
在这种方式下,单个服务器可以同时处理多个客户端请求,也就是说前一个客户端请求会找到记录 3- ,下一个客户端请求就会找到记录 4- ,只要我们为不同的请求使用不同的记录,它们都能以并行的方式独立运行。不过,当进入到状态机后,就必须以一定的顺序来执行命令,命令必须与它们在日志内的顺序一致,也就是说只有当记录 3- 完成执行后,才能执行记录 4- 。
提高效率
下一个需要解决的就是效率问题。在之前描述过的内容中存在两个问题:
第一个问题就是当有多个 提议者(proposer) 同时工作时,仍然会有可能存在竞争冲突的情况,有些请求会被要求重新开始,可能大家还会记得在 基础 Paxos 里介绍过的死锁情况。同样的状况也可以在这里发生,当集群压力过大时,这个问题会非常明显,如果有很多客户端并发的请求集群,所有的服务器都试图在同一条记录上进行值的选定,就可能会出现系统失效或系统超负荷的情况。
第二个问题就是每次客户端的请求都要求两轮的远程调用,第一轮是提议的准备(Prepare)请求阶段,第二轮是提议的接受(Accept)请求阶段。
为了让事情更有效率,这里会做两处调整。首先,我们会安排单个服务器作为活动的 提议者(proposer) ,所有的提议请求都会通过这个服务器来发起。我们称这个服务器为 领导者(leader) 。其次,我们有可能可以消除几乎所有的准备(Prepare)请求,为了达到目的,我们可以为 领导者(leader) 使用一轮提议准备(Prepare),但是准备的对象是完整的日志,而不是单条记录。一旦完成了准备(Prepare),它就可以通过使用接受(Accept)请求,同时创建多条记录。这样就不需要多次使用准备(Prepare)阶段。这样就能减少一半的 RPC 请求。
领导者(leader)选举
选举领导者的方式有很多,这里只介绍一种由 Leslie Lamport 建议的简单方式。这个方式的想法是,因为服务器都有它们自己的 ID ,让有最高 ID 的服务器作为领导者。可以通过每个服务器定期(每 T ms)向其他服务器发送心跳消息的方式来实现。这些消息包含发送服务器的 ID ,当然同时所有的服务器都会监控它们从其他服务器处收到的心跳检测,如果它们没有能收到某一具有高 ID 的服务器的心跳消息,这个间隔(通常是 2T ms)需要设置的足够长,让消息有足够的通信传递时间。所以,如果这些服务器没有能接收到高 ID 的服务器消息,然后它们会自己选举成为领导者。也就是说,首先它会从客户端接受到请求,其次在 Paxos 协议中,它会同时扮演 提议者(proposer) 和 接受者(acceptor) 两种角色。如果机器能够接收到来自高 ID 的服务器的心跳消息,它就不会作为领导者,如果它接收到客户端的请求,那么它会拒绝这个请求,并告知客户端与 领导者(leader) 进行通信。另外一件事是,非 领导者(leader) 服务器不会作为 提议者(proposer) ,只会作为 接受者(acceptor) 。这个机制的优势在于,它不太可能出现两个 领导者(leader) 同时工作的情况,即使这样,如果出现了两个 领导者(leader) ,Paxos 协议还是能正常工作,只是不是那么高效而已。
应该注意的是,实际上大多数系统都不会采用这种选举方式,它们会采用基于租约的方式(lease based approach),这比上述介绍的机制要复杂的多,不过也有其优势。
减少准备(Prepare)的 RPC 请求
另一个提高效率的方式就是减少准备请求的 RPC 调用次数,我们几乎可以摆脱所有的准备(Prepare)请求。为了理解它的工作方式,让我们先来回忆一下为什么我们需要准确请求(Prepare)。首先,我们需要使用提议序号来阻止老的提议,其次,我们使用准备阶段来检查已经被接受的值,这样就可以使用这些值来替代原本自己希望接受的值。
第一个问题是阻止所有的提议,我们可以通过改变提议序号的含义来解决这个问题,我们将提议序号全局化,代表完整的日志,而不是为每个日志记录都保留独立的提议序号。这么做要求我们在完成一轮准备请求后,当然我们知道这样做会锁住整个日志,所以后续的日志记录不需要其他的准备请求。
第二个问题有点讨巧。因为在不同接受者的不同日志记录里有很多接受值,为了处理这些值,我们扩展了准备请求的返回信息。和之前一样,准备请求仍然返回 接受者(acceptor) 所接受的最高 ID 的提议,它只对当前记录这么做,不过除了这个, 接受者(acceptor) 会查看当前请求的后续日志记录,如果后续的日志里没有接受值,它还会返回这些记录的标志位 noMoreAccepted 。
最终如果我们使用了这种领导者选举的机制,领导者会达到一个状态,每个 接受者(acceptor) 都返回 noMoreAccepted ,领导者知道所有它已接受的记录。所以一旦达到这个状态,对于单个 接受者(acceptor) 我们不需要再向这些 接受者(acceptor) 发送准备请求,因为它们已经知道了日志的状态。
不仅如此,一旦从集群大多数 接受者(acceptor) 那获得 noMoreAccepted 返回值,我们就不需要发送准备的 RPC 请求。也就是说, 领导者(leader) 可以简单地发送接受(Accept)请求,只需要一轮的 RPC 请求。这个过程会一直执行下去,唯一能改变的就是有其他的服务器被选举成了 领导者(leader) ,当前 领导者(leader) 的接受(Accept)请求会被拒绝,整个过程会重新开始。
复制的完整性
这个问题的目的是让所有的 接受者(acceptor) 都能完全接受到日志的最新信息。现在算法并没有提供完整的信息。例如,日志记录可能没有在所有的服务器上被完整复制,所选择的值只是在大多数服务器上被接受。但我们要保证的就是每条日志记录在每台服务器上都被完全复制。第二个问题是,现在只有 提议者(proposer) 知道某个已被选定的特定值,知道的方式是通过收到大多数 接受者(acceptor) 的响应,但其他的服务器并不知道记录是否已被选定。例如, 接受者(acceptor) 不知道它们存储的记录已被选定,所以我们还想通知所有的服务器,让它们知道已被选定的记录。提供这种完整信息的一个原因在于,它让所有的服务器都可以将命令传至它们的状态机,然后通过这个状态机执行这些命令。所以这些状态机可以和领导者服务器上的状态机保持一致。如果我没有这么做,他们就没有日志记录也不知道哪个日志记录是被选定的,也就无法在状态机中执行这些命令。
下面会通过四步来解释这个过程:
第一步,在我们达成仲裁之前不会停止接受(Accept)请求的 RPC 。也就是说如果我们知道大多数服务器已经选定了日志记录,那么就可以继续在本地状态机中执行命令,并返回给客户端。但是在后台会不断重试这些 Accept RPC 直到获得所有服务器的应答,由于这是后台运行的,所以不会使系统变慢。这样就能保证在本服务器上创建的记录能同步到其他服务器上,这样也就提供了完整的复制。但这并没有解决所有问题,因为也可能有其他更早的日志记录在服务器崩溃前只有部分已复制,没有被完整复制。
第二步,每台服务器需要跟踪每个已知被选中的记录,需要做到两点:首先,如果服务器发现一条记录被选定,它会为这条记录设置 acceptedProposal 值为无穷大 ∞ 。这个标志表示当前的提议已被选定,这个无穷大 ∞ 的意义在于,永远不会再覆盖掉这个已接受的提议,除非获得了另外一个有更高 ID 的提议,所以使用无穷大 ∞ 可以知道,这个提议不再会被覆盖掉。除此之外,每台服务器还会保持一个 firstUnchosenIndex 值:这个值是表示未被标识选定的最小下标位置。这个也是已接受提议值不为无穷大 ∞ 的最低日志记录
第三步, 提议者(proposer) 为 接受者(acceptor) 提供已知被选定的记录信息,它以捎带的方式在接受请求中提供相关的信息。每条由 提议者(proposer) 发送给 接受者(acceptor) 的请求都包括首个未被选定值的下标索引位置 firstUnchosenIndex ,换句话说 接受者(acceptor) 现在知道所有记录的提议序号低于这个值的都已经被选定,它可以用这个来更新自己。为了解释这个问题,我们用例子来进行说明,假设我们有一个 接受者(acceptor) 里的日志如上图所示。在它接收到接受请求之前,日志的信息里知道的提议序号为 1、2、3、5 已经被标记为了选定,记录 4、6 有其他提议序号,所以它们还没有被认定是已选定的。现在假设接收到接受请求
Accept(proposal=3.4, index=8, value=v, firstUnchosenIndex=7)
它的提议序号是 3.4 ,firstUnchosenIndex 的值为 7 ,这也意味着在 提议者(proposer) 看来,所有 1 至 6 位的记录都已经被选定, 接受者(acceptor) 使用这个信息来比较提议序号,以及日志记录里所有已接受的提议序号,如果存在任意记录具有相同的提议序号,那么就会标记为 接受者(acceptor) 。在这个例子中,日志记录 6 有匹配的提议序号 3.4 ,所以 接受者(acceptor) 会标记这条记录为已选定。之所以能这样,是因为 接受者(acceptor) 知道相关信息。首先,因为 接受者(acceptor) 知道当前这个日志记录来自于发送接受消息的同一 提议者(proposer) ,我们同时还知道记录 6 已经被 提议者(proposer) 选定,而且我们还知道, 提议者(proposer) 没有比这个日志里更新的值,因为在日志记录里已接受的提议序号值与 提议者(proposer) 发送的接受消息中的提议序号值相同,所以我们知道这条记录在选定范围以内,它还是我们所能知道的, 提议者(proposer) 里可能的最新值。所以它一定是一个选定的值。所以 接受者(acceptor) 可以将这条记录标记为已选定的。因为同时我们还接收到关于新记录 8 的请求,所以在接收到接受消息之后,记录 8 处提议序号值为 3.4 。
这个机制无法解决所有的问题。问题在于 接受者(acceptor) 可能会接收到来自于不同 提议者(proposer) 的某些日志记录,这里记录 4 可能来自于之前轮次的服务器 S5 ,不幸的是这种情况下, 接受者(acceptor) 是无法知道该记录是否已被选定。它也可能是一个已失效过时的值。我们知道它已经被 提议者(proposer) 选定,但是它可能应该被另外一个值所取代。所以还需要多做一步。
- 第四步, 接受者(acceptor) 接收的处理日志记录来自于旧领导者(leader),所以它不确定记录的值是否已被选定。当 接受者(acceptor) 响应接受请求时,它会返回自己的第一个为选定值的下标 firstUnchosenIndex ,当 提议者(proposer) 接收到响应时,会将自己的 firstUnchosenIndex 与响应里的 firstUnchosenIndex 作比较,如果 提议者(proposer) 的下标位置更大,这就表明 接受者(acceptor) 的某些状态是不确定的,这时 提议者(proposer) 会将准确的内容发送给 接受者(acceptor) ,这都可以通过一个新的 RPC 调用来完成。这是 Multi-Paxos 使用的第三种 RPC 调用,这个调用包括两个参数,日志记录的下标位置及该位置的值。这条消息可以让某一特定位置的某一特定值被选定,不会再存在未知的情况,所以接受者只要将这个信息从消息中提取并更新它自己的日志记录即可,然后返回它更新的 firstUnchosenIndex ,因为可能存在有多个未知状态的情况,所以 提议者(proposer) 会发送多个 RPC 请求,直到最后 接受者(acceptor) 与 提议者(proposer) 的日志状态达成一致。
这一系列机制可以保证最终,所有的服务器里的日志记录都可以被选定,而且它们知道已被选定。在通常情况下,是不会有额外开销的,额外的开销仅存在与领导者被切换的情况,这个时间也非常短暂。
客户端协议
Multi-Paxos 第五个问题是客户端如何与系统进行交互的。如果客户端想要发送一条命令,它将命令发送给当前集群的 领导者(leader) 。如果客户端正好刚启动,它并不知道哪个服务器是作为 领导者(leader) 的,这样它会向任一服务器发送命令,如果服务器不是 领导者(leader) 它会返回一些信息,让客户端重试并向真正的 领导者(leader) 发送命令。一旦 领导者(leader) 收到消息, 领导者(leader) 会为命令确定选定值所处位置,在确定之后,就会将这个命令传递给它自己的状态机,一旦状态机执行命令后,它就会将结果返回给客户端。客户端会一直向某一 领导者(leader) 发送命令,知道它无法找到这个 领导者(leader) 为止,例如, 领导者(leader) 可能会崩溃,此时客户端的请求会发送超时,在此种情况下,客户端会随便选择任意随机选取一台服务器,并对命令进行重试,最终集群会选择一个新的 领导者(leader) 并重试请求,最终请求会成功得到应答。
但是这个重试机制存在问题。
如果 领导者(leader) 已经成功执行了命令,在响应前的最后一秒崩溃了?这时客户端会尝试在新 领导者(leader) 下重试命令,这样就可能会导致相同的命令被执行两次,这是不允许发生的,我们需要保证的是每个命令都仅执行一次。为了达到目的,客户端需要为每条命令提供一个唯一的 ID ,这个 ID 可以是客户端的 ID 以及一个序列号,这条记录包含客户端发送给服务器的信息,服务器会记录这个 ID 以及命令的值,同时,当状态机执行命令时,它会跟踪最近的命令的信息,即最高 ID 序号,在它执行新命令之前,它会检查命令是否已经被执行过,所以在 领导者(leader) 崩溃的情况下,客户端会以新的 领导者(leader) 来重试,新的 领导者(leader) 可以看到所有已执行过的命令,包括旧 领导者(leader) 崩溃之前已执行的命令,这样它就不会重复执行这条命令,只会返回首次执行的结果。
结果就是,只要客户端不崩溃,就能获得 exactly once 的保证,每个客户端命令仅被集群执行一次。如果客户端出现崩溃,就处于 at most once 的情况,也就是说客户端的命令可能执行,也可能没有执行。但是如果客户端是活着的,这些命令只会执行一次。
配置变更
最后的一个问题在配置变更的情况。
这里说的系统配置信息指的是参与共识性协议的服务器信息,通常也就是服务器的 ID ,服务器的网络地址。这些配置的重要性在于,它决定了仲裁过程,当前仲裁的大多数代表什么。如果我们改变服务器数量,那么结果也会发生变化。我们有对配置提出变更的需求,例如如果服务器失败了,我们可能需要切换并替代这台服务器,又或我们需要改变仲裁的规模,我们希望集群更加可靠(比如从 5 台服务器提升到 7 台)。
这些变更需要非常小心,因为它会改变仲裁的规模。
另外一点就是需要保证在任何时候都不能出现两个不重叠的多数派,这会导致同一日志记录选择不同值的情况。假设我们将集群内服务器从 3 台提升到 5 台时,在某些情况下,有些服务器会相信旧的配置是有效的,有些服务器会认为新配置是有效的。这可能会导致最上图最左侧的两台服务器还以旧的配置信息进行仲裁,选择的值是(v1),而右侧的三台服务器认为新配置是有效的,所以 3 台服务器构成了大多数,这三台服务器会选择一个不同的值(v2)。这是我们不希望发发生的。
Paxos 配置变更解决方案
Leslie Lamport 建议的 Paxos 配置变更方案是用日志来管理这些变更,当前的配置作为日志的一条记录存储,并与其他的日志记录一同被复制同步。所以上图中 1-C1、3-C2 表示两个配置信息,其他的用来存储普通的命令。这里有趣的是,配置所使用每条记录是由它的更早的记录所决定的。这里有一个系统参数 å 来决定这个更早是多早,假设 å 为 3,我们这里有两个配置相关的记录,C1 存于记录 1 中,C2 存于记录 3 中,这也意味着 C1 在 3 条记录内不会生效,也就是说,C1 从记录 4 开始才会生效。C2 从记录 6 开始才会生效。所以当我们选择记录 1、2、3 时,生效的配置会是 C1 之前的那条配置,这里我们将其标记为 C0 。这里的 å 是在系统启动时配置好的参数,这个参数可以用来限制同时使用的配置信息,也就是说,我们是无法在 i+å 之前选择使用记录 i 中的配置的。因为我们无法知道哪些服务器使用哪些配置,也无法知道大多数所代表的服务器数量。所以如果 å 的值很小时,整个过程是序列化的,每条记录选择的配置都是不同的,如果 å 为 3 ,也就意味着同时有三条记录可以使用相同的配置,如果 å 大很多时,事情会变得更复杂,我们需要长时间的等待,让新的配置生效。也就是说如果 å 的值是 1000 时,我们需要在 1000 个记录之后才能等到这个配置生效。
Paxos 总结
参考
参考来源:
2013 Paxos lecture, Diego Ongaro
Wiki: Byzantine fault tolerance