从Paxos到NOPaxos 重新理解分布式共识算法(consensus)

  首先标题有点哗众取宠之嫌,但是暂时想不到更加合适的标题,就姑且这么使用吧。分布式共识算法一直是一个热门的研究话题,之所以要分布式共识,无外乎就是单点服务容易宕机,异常,出错,从而导致系统不可用,于是就有了备份容错的机制,那么一份数据多地(location)存储,如果不发生修改操作那就无需一致性协议的引入,但是这仅仅是理想情况,真实的应用中绝大多数都是需要执行更新操作的,这才有了分布式共识的需求。目前最为认同的共识算法就是lamport大神在98年发表的论文中提及的Paxos协议(然而由于太难以理解又在01年发表了paxos made simple),即使过了这么多年,Paxos依然难以理解和难以实现,工程实现大多都是精简版,最为出名的也有Raft,以及Zookeeper中的Zab。笔者之前读过paxos made simple,虽然能理解,但是总觉得有点一知半解(也写了一篇小博客理解paxos协议-分布式共识算法(consensus))。最近刚好看了一篇新的论文,结合Paxos来重新梳理下什么是分布式共识算法,怎么实现分布式共识算法。

  Just Say NO to Paxos Overhead:Replacing Consensus with Network Ordering 这篇论文是2016年osdi上的一篇论文,第一作者是华盛顿大学计算机系统实验的一个PHD。标题看上去也非常的夺人眼球,拜读完之后,对于作者的想法还持有一点疑惑,但是这篇文章很好的阐述了怎么实现分布式共识算法,对于理解Paxos有难度的同学不妨先去阅读下这篇论文,能更好的去理解,下面笔者就用纯大白话的形式来一步步说明分布式共识算法。

何为分布式共识

  lamport大神在其论文中也提及到,所谓的分布式共识要达成的目标:

  • 达成一致的结果一定是由某个进程或者某个应用提出来的,而不是事先约定的结果。
  • 最后要达成一致表明只有一个值能被选中。
  • 只有已经被选中的值才能被其他不参与决策的人(learner)知道。

  如果直接从上面的话来理解,那么就会陷入一个误区,或者说,看不明一致性的完整过程,换一个角度,我们如何来保证多个replica一致性呢,很简单,目前最为流行的机制就是State Machine Replication(aka SMR)。SMR是这么定义的,(1).初始state要相同,(2).对于同一个state,给定相同的输入参数,执行相同的操作,输出结果是相同的。所以用SMR来保证一致性的要求就是,相同的状态,输入相同的参数,执行相同的操作。相同的状态属于上一步的结果,所以约束其实就是两个,相同的参数(argument),相同的操作(operation),说到底,paxos那些复杂的过程就是为了保证这两个约束条件。

  相同的参数:看起来简单的约束,实际实现并非易事,首先在异步网络的情况,消息乱序情况就严重干扰了这一约束,你想想看,节点1先执行request n后执行m,节点2先执行m后执行n,结果能一样吗?那么paxos是如何保证这个有序性的呢?Paxos在其运行过程,一旦提案p被大多数的acceptors接受,那么后续提出的更高编号的提案都应该包含这个p,看明白了吗,就是一个提案未被确认执行前,所有的acceptors都不允许新的提案(这里的新的提案指的是value不同的提案)发生,这就间接解决了乱序的问题,paxos保证所有的节点在每一次paxos提案期间只能执行一个提案(同一个value),从而来保证参数相同,消息有序。

  相同的操作:客户端发起状态修改必然会带着一个operation的请求(实际工程中实现是通过调用不同的接口如insert,update,delete等),那么当这个请求广播给所有的节点,那么执行自然是相同的操作。问题就在于异步网络无法保证可靠性,假如部分节点网络失效,有些没收到request,自然不会去执行。那么一部分节点执行,一部分节点不执行才是导致操作不同的原因(commit和do-nothing)。体现在paxos就是一旦经过大部分的acceptor同意的提案到被learner学习的过程。工程上常见的实现方法是用leader来管理复制日志来实现操作相同的。

  有了上述的认知,再来看一致性,是不是就会觉得明朗许多,本文标题中的另一个名字是NOPaxos,自然重点就是讲解这篇论文了,下面就来看看论文的作者是如何实现分布式共识算法。

摘要

  声明:这里不是纯翻译论文,如果想了解全部的过程,可以点阅前面的链接查看。 传统的一致性算法如paxos是把上述两个约束条件放在应用层去实现,这样的好处是不依赖于特定的网络结构,但是同时也有一些弊端,首先是一致性的时间比较长,性能较低,第二是实现难度比较大且复杂。作者另辟蹊径,如果网络层能保证消息有序,那么paxos前面整个投票过程就无需存在了,这样就把一致性的责任分摊在了网络层(并非指TCP/IP协议栈中的网络层)和应用层。论文主要做了四方面的工作:

  • 定义了一个有序不可靠的广播模型(ordered unreliable multicast model,OUM),能提供强有序的package,但是不保证package一定被接收/处理。
  • 描述了如何实现这样一个OUM模型,提供了三种不同的实现方案。
  • 介绍应用层的NOPaxos协议,如何在应用层协调state一致性。
  • 为上述的实现做实验验证。

Order Unreliable Multicast

  按照论文本身的说法,最简单的实现,就是给每个消息增加一个单调递增1的序列号(sequence number),这样,节点在接受到消息的时候,就能知道每个消息的先后顺序了。除此之外,这一层无需再提供任何的保证,这样使得设计和实现都比较简单。简单的情况下,OUM为每个request都添加一个sequence number,应用层通过libOUM调用接收到这个request之后,判断是否是当前想要接受的信息。只要sequence number是递增1,就能判断出是否乱序或者正常情况。如果出现了跳跃,比如当前需要的消息序列号是n,然后却接受到了一个序列号为m(m > n)的消息,那么上层应用就知道n-m中间的消息是丢失,进而执行其他操作,这样保证每个replica接收到的消息是相同的顺序。下图是NOPaxos的一个整体架构:

![NOPaxos的一个整体架构](./NOPaxos的整体架构.png)

  每个客户端都需要集成两个库,一个是用于处理网络消息的libOUM,一个是用于协调多个replica之间操作的NOPaxos。底层网络有一个sequencer,负责为每个消息加上一个序列号,为了防止sequencer故障或者失效,需要一个controller来进行监控。总的来说,这个架构总共有三种角色,controller,sequencer,client,其中client有两种协议OUM和NOPaxos。下面就一个个的来介绍这些角色分别承担的功能和用途。

1. sequencer

  正如前面所说,sequencer的功能很简单,就是为每个消息添加一个序列号,这个序列号必须是单调递增1的。论文中,作者提供了三种不同的实现方式,分别是,基于可编程的交换机内实现,基于middlebox原型的硬件实现,纯软件实现。在介绍这三种实现之前,先来考虑这样一个网络结构,

三层胖树

  上图是一个三层胖树的结构3-level fat-tree,根据设计,所有的客户端发出来的信息都要经过sequencer,如果原本客户端与replica在同一个局域网内,这样的设计首先会导致消息路径增长,因为消息首先要走到sequencer,再从sequencer转发回来,明显消息走过的路径就变长了。所以这里对于sequencer最合理的位置,就是放在root switch(至于为啥是放在这个位置会使得性能最好,可以参考网络拓扑fat-tree的设计思路,笔者对于网络拓扑没有深入研究,这里就不展开)。且论文作者在真实的环境下测试得到,对于这样一个三层胖树结构的网络,88%的情况下,添加一个序列号并不会增加额外的延迟开销,99%的情况下,只有5us的延迟。因此,增加这样一个sequencer并不会带来性能的下降(论文解释的原因是,不管存不存在这个sequencer,大部分的package都是需要走到root-switch才能达到大多数的group)。sequencer的位置确定了,那么接下来就是实现了:

  • in-switch:理想情况下,如果有一个可编程的交换机,那么这个交换机可以直接拿来做sequencer,因为交换机本身就是优化设计来作为消息投递,拆包解包的。然而这些交换机大部分都是商业闭源的,市面上比较少见很难买到,作者的实验是在Intel的Arista7150交换机上实现的,基本能达到0延迟。不仅如此,交换机本身的计算模型是非常有限的,且只能用类似于P4的编程语言开发。
  • Hardware Middlebox Prototype Sequencing:相比于可编程交换机来说,基于SDN的OpenFlow交换机实现难度降低了很多,可以采用C语言开发,根据作者的实验,这种情况下能99%的case有16us的延迟。
  • End-host Sequencing:使用普通的终端机(如果linux server)来作为sequencer,这种实现最为简单,但是性能最差。毕竟交换机是数据链路层或者网络层工作的,而终端机是应用层的级别了,且交换机本身是专门用于package转发和处理,性能差距自然很明显。

2.controller

  有了sequencer自然能实现消息有序性,但是同时也引进了一个问题,sequencer是一个单节点,一下子就使得整个系统脆弱了很多,虽然说发生故障的概率不高,但是一旦发生故障,整个系统不可用不说,还可能出错。这个时候就需要controller出场了,controller的主要目的是监视sequencer,一旦发现sequencer不可用或者不可达,则会选择一个替换的sequencer,并更新其路由表。这一过程引入一个新的概念,session。每当一个sequencer失效了,需要挑选新的sequencer的时候,首先重新选定一个session number,并将信息更新到新的sequencer上。这样用一个session的概念就能来维护跨sequencer的消息有序了(会在消息头上加上一个二元组 [session-number, sequencer-number] )。一旦libOUM接收到了一个更高编号的session number,则说明,旧的session已经失效了,但是此时,libOUM并不知道是否有丢失旧的session中的package,所以不能返回一个drop-notification,只能返回一个session-terminated,由上层应用去决定改如何处理。至于上层如何处理,后面会讲。这里session number可以采用本地时间戳,或者将session number持久化到磁盘然后递增。此外controller的可靠性可以由多个节点来保证,controller选举sequencer的算法甚至可以直接使用paxos或者raft等,毕竟节点失效并不是一个经常发生的事。

NOPaxos

  NOPaxos从架构图中可知,属于一致性协议最上层的协议了,通过调用底层的libOUM保证消息有序,剩下的如何保证操作一致就是在这个协议中实现的。下面会分别介绍不同的情况下,NOPaxos是如何执行的,首先讲明一些概念和变量:

  • 总节点数为2f+1,f是最多允许fail的节点数,这个就不必说了,paxos也是这么限制的,最少要半数以上节点同意提案方可commit。
  • replica number:每个replica(每个副本称为一个replica)都有一个number,
  • status:每个replica都有一个status,normal或者viewchange
  • view-id:视图的id,是一个二元组,[leader-num, session-num],这的leader-num实际就是leader的replica number,session-num就是前文中OUM层的session number
  • session-msg-num:在一个session内,每个message都有一个单调递增1的序列号,这个就是该序列号
  • log-slot:log用来appendrequest操作,log-slot用来表明这个操作在日志中的位置。
  • sync-point:最新的同步点

  系统运行只有,协议的运行过程,只会出现四种不同的情况,正常的操作,出现消息丢失,发生视图转换,系统状态同步。下面就分别讲解这四种情况下接收到不同消息时该如何处理,另外关于leader选举可以参考viewstamped-replication。

1. Normal Operation

  正常的情况下,客户端广播(broadcast)一个request的消息(消息内容为,[request, op, request-id],其中request-id是用于response的时候,判断是哪个消息,理解为消息的unique key)当replica接受到这样一个消息的时候,首先OUM带过来的一个session-msg-num判断是不是自己正在等待的消息,如果是,则递增session-msg-num并将op写入日志(注意,这里并不执行)。如果replica是leader的话,那么就执行这个操作,并写入日志。然后每个replica会回复客户端一个消息(内容为,[reply, view-id, log-slot-num, request-id, result],这里的result只有leader才有回复,其他为null)。客户端会等待f+1个reply,能match上view-id和log-slot-nums的回复,其中必须有一个是leader,如果没有接收到足够的回复,则会超时甚至重试。上述是正常的情况下,正常的处理过程。【问题:如果leader提交了,然而client并没有收到f+1个reply,这个时候怎么办,没有任何机制能反驳leader?raft的机制就是确认replica已经写入了日志才commit的,所以他这里没写明我也不明白是为啥】

2. Gap Agrement

  假如此时libOUM本来在等待session-msg-num编号的请求,却来了一个更大的请求,说明,中间发生丢包了。那么此时,libOUM就会向上层返回一个drop-notification,告知session-msg-num丢失了(同一个session内)并且递增session-msg-num。如果是非leader接收到drop-notification,那么可以向相邻节点copy请求,或者不做任何事。如果leader节点接收到了这样一个返回值,则会在日志中追加一个NO-OP,并且执行下面的操作:

  • 发送一个[gap-commit, log-slot]给所有的replica
  • 非leader节点接收到消息之后,会在指定的位置插入一个NO-OP(可能位置已经插入了一个request的op),并且向leader响应一个[gap-commit-rep, log-slot]的消息。leader等待这个消息,必要的时候要重试。

  对于drop操作,客户端是不需要显式通知到的,因为可以等待客户端超时。当然这里在实际开发的时候可以进行一些优化,比如leader没有接收到的时候,也可以向其他的节点进行copy,减少NO-OP的数目。

3. view change

  前文也说到了,NOPaxos这一层有一个leader的概念,且OUM有一个session的概念,如果这两个一旦有一个发生改变,就需要进行view change操作了。view change协议能保证新老视图中间的状态一致性,且能很好的从老的视图切换到新的视图。NOPaxos中的视图变换协议类似于Viewstamped Replication[42]。算法阐述如下:当一个replica怀疑当前的leader挂了,或者接收到了一个session-terminated,亦或者接收到一个view-change/view-change-req的消息。此时他就适当的增加leader-num或者session-num,并且将状态status设置为viewchange。一旦session-num发生改变,session-msg-num则重置为0。然后广播一个消息[view-change-req, view-id]给其他的replica,并且发送一个[view-change, view-id, v`, session-msg
-num, log]给新的leader,v`表示上一个状态status为normal的视图的view-id。当一个replica处于viewchange状态的时候,会忽略其他消息,除了start-view, view-change,view-change-req。如果超时,则重新广播和发送指定消息给新leader。当新leader接收到f+1个view-change的消息的时候,则会执行下列操作:从最近最新的view且status为normal的replica合并日志(每个replica都会发送一个log信息给新的leader)。合并规则是,如果大家都是no-op,那就是no-op,如果有一个是request,那就是request。leader拿到新的view-id之后设置session-msg-num比合并日志大的数字,然后广播一个消息[start-view, view-id, session-msg-num, log]给所有的replica.当其他的replica收到了这个start-view的消息之后,会更新自己监听的信息,包括view-id,session-msg-num等,并要先同步下日志,如果发生了日志更新,则会发送reply信息给客户端。最后把自己的status设置为normal。

4. Synchronization
  定时同步,这个属于优化范畴,前文也说到,在正常的情况下,leader进行commit操作,replica只进行日志append,但是随着系统的运行,会导致log越来越大,如果leader发生变更,那么就会有一个问题,新leader恢复时间非常长。为了在leader变更的时候,恢复时间缩短,NOPaxos决定周期性的进行数据同步。这一步骤的目的就是确保在sync-point前的所有数据,log状态都是一致的。小论文并没有详细的指出如何解决这一问题,放在了大论文中去讲了。

正确性证明

  首先定义正确性是:一个request被执行或者NO-OP这样的日志被写进f+1个节点中,且如果客户端确保一个request执行成功的标记是收到f+1个回复,并且我们说一个view v的log是stable的,表明这个会成为所有高于view v的视图的前缀日志。
(1).stable log中的成功操作,在resulting log中也是同样正确的。
(2).replica总是开始于一个view中一个正确的session-msg-num
  值得注意的是,一个操作(request或者no-op一旦被commit到日志中,将会永远呆着),因为如果在view change期间,同时发生了操作commit,意味着f+1个节点同意了view change,而f+1个节点提交了日志,也就说明了,至少有一个节点同时执行了这两件事。而且新leader会跟其他的节点同步日志,所以如果是大多数的节点承认的日志会同步到leader中去。

  后记:阅读完之后,总觉得有点问题,github上有这个代码的开源实现,NOPaxos的源码,笔者还没来得及去看,后续如果看了会继续开更,希望更多喜欢分布式共识和分布式一致性的朋友能一起谈论这个话题。


chasel
280 声望10 粉丝

想造轮子。