1、介绍
paxos算法用于构建一个容错的分布式系统,它一直被认为是难以理解的,原因很可能是对于很多读者来说原始手稿是用希腊文实现的。实际上,这个算法基于最简单并且容易理解的分布式算法。它的核心是一个一致性算法:议会算法。下一章展示了这个一致性算法不可避免的遵循我们希望它满足的属性。最后一章展示了完整的paxos算法的实现,该算法从用于建立分布式系统的一个一致性状态机模型的直接应用中获得。这个一致性状态机模型需要是众所周知的,因为它是分布式系统理论中最常被引用的主题。
2、一致性算法
2.1 问题描述
设想一个可以提出提案的进程集合。一个一致性算法保证多个提出的提案中只有一个被选中。如果没有提案被提出,那么不会有提案被选中。如果一个提案被接受,那么所有进程都需要知道这个提案被接受了。一致性的安全要求如下:
只有被提出的提案才有可能被接受
只有一个提案会被接受
只有一个提案确实的被接受之后,进程才会知道这个value被接受了
我们并不尝试定义明确的活性要求。我们的目标是保证一个被提出的提案最终被选中了,并且进程可以最终知道这个提案被接受了。
我们定义三种角色,通过三种代理实现:
proposers:提案提出者
acceptors:提案接受者
learners:知道提案被接受的进程
在一次选举的实例中,一个进程可以作为多种角色存在,我们并不关心代理和进程之间的映射关系
设想每个代理可以和其他代理通过消息传递通信。我们使用异步模式,并假设不存在拜占庭将军问题,具体如下:
每个代理都使用不同速度处理消息,可能失败或者暂停,可能重启。由于所有代理都可能在一个提案被接受之后失败并重启,那么除非代理有在重启后记住提案的方法,否则没有方案可以解决这种问题。
消息被分发的时间是任意的,可以是重复的,可以丢失,但是不会是损坏的(本文不解决拜占庭将军问题)
2.2 选择一个提案
最简单的接受一个提案的方法是只有一个acceptor。一个proposer发送提案到一个acceptor,这个acceptor接受第一个收到的提案。尽管很简单,但是这个解决方案不令人满意,因为只有一个acceptor会发生单点故障。
因此,选择其他接受提案的方法。使用多个acceptor。一个proposer发送提案到一个acceptor的集合。一个acceptor可能接受提案。当集合中数量足够大的acceptor接受提案的时候,这个提案被认定为接受。多大是足够大?为了保证只有一个提案被接受,足够大至少要在acceptor全集中占多数。因为两个多数派中至少有一个acceptor是相同的,因此要求一个acceptor只能接受最多一个提案(这是一个显然的结论,在很多论文中有描述)。
忽略失败和消息丢失的情况,我们希望即使只有一个proposer的时候,提案也可以被接受。由此得到了要求:
P1:一个acceptor必须接受第一个它收到的提案
但是这个要求导致了一个问题。同时可能存在多个不同的proposer发起了提案,导致每个acceptor都收到了一个提案,但是没有一个提案被多数acceptor接受。即使只有两个提案,如果每个提案被几乎一般的acceptor收到,一个acceptor的单点故障就可以导致没有多数派,从而没有提案被接受。
P1和每个提案必须被多数acceptor接受才算被接受的要求意味着,一个acceptor必须可以接受多个提案。我们为每个可能被acceptor接受的提案跟踪一个序列号,这样一个提案包括一个序列号和提案内容。为了消除混淆,我们要求不同的提案必须有不同的序列号。这个怎么做到看实现,我们现在只是这样假设。一个提案只有被多数派接受才算被接受。
我们可以允许多个提案被接受,但是必须保证被接受的每个提案都有一样的提案内容。通过归纳序列号,足够保证:
P2:如果一个提案内容为v的提案被接受了,那么每个更高序列号的被接受的提案的内容必须是v
由于序列号全局有序,条件P2保证了重要的安全性:只有一个提案内容被接受
如果提案要被接受,那么提案一定被至少一个acceptor接受了。因此,我们可以约束P2:
P2a:如果一个提案内容为v的提案被接受了,那么一个acceptor接受的更高序列号的提案内容必须是v
我们仍然保持P1来保证至少有一个提案被接受。因为消息是异步的,一个提案可能被一个特殊的acceptor c接受,c之前从失败中回复,没有接受过任何一个提案。假设一个新的proposer加入并提出了一个更高序列号的提案,这个提案内容和之前被接受的提案内容不同。P1要求c接受这个提案,违背了P2a。同时保持P1和P2a要求加强p2a的约束:
P2b:如果一个内容为v的提案被接受了,那么proposer提出的更高序列号的提案内容必须为v
显然,一个提案被接受之前一定会被proposer提出,P2b可以保证P2a,进而保证了P2
要发现怎样保证P2,让我们考虑怎么证明它。我们假设一个序列号为m,内容为v的提案被接受了,证明任何序列号比m大的n的提案内容一定是v。我们可以通过对n使用归纳法很容易的证明,我们可以通过附加的假设每个序列号在m到n之间的提案内容都是v来证明序列号为n的提案也有内容v。既然提案m被接受了,说明有一个多数派集合C,C中的每一个acceptor都接受了这个提案。把这个和附加假设一起考虑,m被接受了说明:多数派中的每个acceptor都接受了序列号从m到n-1之间的提案,并且这些提案中每个acceptor的内容都是v。
由于任何多数派S一定含有至少一个多数派C中的acceptor,我们可以得出结论序列号为n的提案内容为v,通过保持以下约束:
P2c:如果一个序列号为n,内容为v的提案被接受了,那么一定有一个多数派的集合S,S中的每个acceptor满足下面两个条件中的任何一个:没有接受过序列号小于n的提案;v是自己接受过的所有序列号小于n的提案中最大的提案的内容。
我们可以通过保持P2c的不变性来保证P2b
为了保持P2c的不变性,一个想要提案序列号为n的proposer必须知道比n小的最大的被接受提案的序列号,若有这样的提案,这个提案一定被多数派接受过。知道已经被接受的提案很容易,预测未来的接受很难。为了不需要预测未来,proposer要求未来没有接受的承诺。换言之,proposer要求acceptor不接受任何序列号小于n的提案。这个要求导致了如下提案算法:
1.一个proposer选择一个新的提案序列号n,将n发送到一些acceptor,要求这些acceptor回应:
a)承诺永远不接受小于n的序列号的提案
b)比n的序列号小的最大序列号的被接受提案的序列号
我会将这个动作称为prepare协议。
2. 若proposer接受到满足多数派数量的acceptor的回应,它可以提案序列号为n,内容为v的提案,v是回应的多数派中接受过的最大序列号的提案内容。若多数派中没有acceptor接受过提案,那么proposer可以提自定义内容。
一个proposer通过发送给一个acceptor的集合来提案。这个集合只要是多数派即可,不需要是之前收到回应的多数派。我们称为accept协议。
这些描述了propser的算法,那么acceptor呢?它可以收到两种proposer发来的协议:prepare和accept。acceptor可以忽略任何一个协议而不会破坏安全性。我们只在acceptor允许回应的时候进行描述。acceptor永远可以回应prepare协议。acceptor可以回应accept协议接受提案,当且仅当它没有承诺不去回应的,换言之:
P1a:一个acceptor可以接受一个序列号为n的提案当且仅当它没有相应过序列号比n大的提案。
观察到P1a包含P1。
现在我们有了一个完整的算法用于选择一个满足安全性属性的提案--假设有唯一序列号。最终算法基于此做了一个改进。
想象一个acceptor收到了prepare协议,序列号为n,但是已经回应过其他序列号大于n的prepare协议,从而承诺不接受序列号为n的提案。因此acceptor没有理由回应这个新的prepare协议,毕竟这个序列号的提案不会被接受。我们让这个acceptor忽略这个prepare协议。我们也让acceptor忽略已经被接受过的提案的prepare协议。
通过这个优化,一个acceptor只需要记住自己接受过的序列号最大的提案和已经响应过prepare协议的最大序列号。考虑到P2c必须在失败等情况下保持不变性,acceptor必须记住这些信息即使自己失败重启。注意一个proposer可以废弃一个提案并忘记这个提案的存在--只要它永远不尝试将这个序列号用作另一个提案。
将proposer和acceptor的动作结合在一起考虑,我们看到了算法操作的两个阶段:
阶段1.
a)一个proposer选择一个序列号n并发送一个prepare协议到多数派
b)若一个acceptor收到的prepare协议中的序列号比之前响应过的所有序列号都大,那么它承诺不接受任何比n小的序列号的提案,并回复自己接受过的提案的最大序列号
阶段2.
a)如果一个proposer收到prepare协议响应的数量达到了多数派,它发送accept协议到这些多数派,使用序列号n和内容v,v是多数派中已经接受过的最大序列号的提案的内容。若没有v,那么proposer可以自定义v
b)如果一个acceptor收到了一个accept协议,序列号为n,那么除非它响应过序列号大于n的prepare协议,否则接受
一个proposer可以提出多个提案,只要每个提案都遵循算法。它可以在协议中途终止一个提案。即使提案被废弃很久后协议或者响应到达了目的地,算法的正确性也可以得到保证。在其他proposer提出更高序列号的提案的时候,看起来终止低序列号的提案是一个好主意。因此,如果一个acceptor忽略了一个prepare或者accept协议因为它已经响应过更高序列号的prepare协议,那么它或许应该通知proposer去终止。这是一个不影响正确性的改进。
2.3 了解到一个被选中的提案
要了解到一个提案被接受了,一个learner必须发现一个提案被多数派接受了。一个明显的算法是,每个acceptor接受一个提案的同时,发送给所有learner。这允许了learner尽可能快的发现一个被接受的提案,但是这要求每个acceptor发送消息给每个learner,消息通信数量是acceptor数量和learner数量的乘积。
没有拜占庭将军问题的假设保证了一个learner可以很容易的从其他learner了解到已经接受的提案。我们可以让acceptor报告它们的发现给一个众所周知的learner,然后这个learner再通知其他learner。这个方法增加了一个额外的阶段让所有learner发现被接受的提案。这同样是不可靠的,因为众所周知的learner可能失败。但是这个方法需要的通信数量只是所有acceptor数量和所有learner数量的加和。
普遍的说,acceptor可以通知它接受的提案给一组众所周知的learner,组中的每个learner都通知所有其他learner有提案被接受了。使用一个大数量的众所周知learner提供了更好的可靠性但是同时增加了通信花销。
因为消息可能丢失,一个被接受的提案可能不被任何learner发现。learner可以向acceptor查询哪个提案被接受了,但是acceptor失败可能导致一个提案是否被多数派接受不会被发现。在这种情况下,只有一个新的提案被接受的时候,learner才有机会发现。如果learner需要知道每个被接受的提案,那么它可以作为proposer提出一个提案,应用上面描述的算法。
2.4 进展性
很容易构建这样一个场景,两个proposer,每个都按照递增的序列号提出提案,但是没有一个会被接受。proposer p完成了阶段1,使用序列号n1。另一个proposer q随后完成了阶段1,使用序列号n2,n2 > n1。p随后开始阶段2,要求n1被接受,但是这个协议会被acceptor忽略,因为acceptor已经承诺不接受任何小于n2的提案,于是p随后开始一个序列号n3的阶段1,n3 > n2,导致第二个q的阶段2也被忽略了,周而复始。
为了保证进展性,一个众所周知的proposer必须被选中,只有它可以提出提案。如果一个众所周知的proposer可以成功的和多数派通信,并且它使用的序列号比所有已经接受过的提案的序列号都大,那么它的提案就会成功被接受。通过废弃提案并且重试更大的序列号,最终众所周知的proposer可以选中一个足够大的序列号。
如果proposer,acceptor,通信网络都很好的工作,可以通过选中一个众所周知的proposer来保证活性。Fischer的著名成果证明了一个可靠的选举proposer算法必须使用或者随机或者实时,例如使用超时时间。但是,不管选举失败或者成功,正确性可以得到保证。
2.5 实现
paxos算法设想了一组进程。在它的一致性算法中,每个进程扮演proposer,acceptor,learner的角色。算法选择一个领导者,扮演众所周知proposer和众所周知learner的角色。paxos一致性算法就是上面描述的算法,要求和回应都是普通的消息。回应中包含相应的提案序列号来消除迷惑。在失败过程中保持固定存储,用来保持acceptor必须记住的消息。一个acceptor记录自己想要发送的回应到本地存储,在实际发送之前。
剩下的就是描述保证两个提案没有相同序列号的机制。不同的proposer从不相交的集合中选择提案序列号,这样两个不同的proposer就不会选择相同的序列号。每个proposer在固定存储中记住自己尝试提案的最大序列号,并开始阶段1使用一个比自己使用过的最大序列号还大的序列号。
3. 实现一个状态机
一个简单的方式去实现一个分布式系统是一组客户提交命令给中央服务器。服务器可以被描述为一个确定状态机,按照一个序列执行客户命令。状态机有一个当前状态,它执行一步,读取一个命令,产生一个输出,进入一个新的状态。举个例子,一个分布式银行系统的客户可能是出纳,状态机状态可能是所有用户的账户余额。一次取款可能被解释为执行一个状态机命令,当且仅当账户余额充足的时候,进行一笔取款,产出旧余额和新余额。
使用单个服务器的实现可能失败,如果这个服务器失败的话。因此我们使用一组服务器,每个独立的执行状态机。因为状态机是确定的,所有服务器对于相同序列的命令输出相同的状态。一个客户可以使用任何一个服务器产生的输出。
为了保证所有server执行相同序列的状态机命令,我们实现一系列独立的paxus算法实例,第i个实例被接受的提案作为第i个状态机命令。算法的每个实例中,每个服务器扮演所有角色(proposer,acceptor,learner)。现在,我假设服务器集合是固定的,所以一致性算法的所有实例都使用相同的代理集合。
在正常情况下,一个单独的服务器被选举为leader,在一致性算法的所有实例中扮演众所周知的proposer(唯一可以提出提案)。客户发送命令给leader,leader决定这个命令处在一系列状态机命令中的哪个位置。如果leader决定某个客户命令是第135个命令,那么这个命令作为第135个实例提案的value。这通常会成功。它可能失败因为宕机或者另一个服务器认为自己是leader并且提出了一个不同的命令作为第135个实例的提案value。尽管如此,一致性算法可以保证最多只有一个命令被接受为第135个实例的提案value。
这个方法有效性的关键是,paxus一致性算法中,只有到达第二阶段,一个提案的value才会被接受。回忆一下,完成阶段1之后,提案的value要么是被决定的,要么可以被proposer自定义。
现在我将描述正常状态下,paxus状态机实现。稍后,我将讨论出现错误的情况如何处理。我考虑前一个leader失败然后一个新的leader被选出的情况(系统启动是一种特殊的case,没有命令被接受)。
一个新的leader作为一致性算法所有实例的learner,需要知道大多数被接受的提案value。假设它知道命令1-134,138,139,这就是一致性算法对应1-134,138,139实例的提案value(我们后面会看到这些空隙是怎么产生的)。随后leader开始135-137和超过139的实例的阶段1(我下面描述这个过程是怎么进行的)。假设执行的结构导致135和140的提案value被指定,但是其他实例的value是无约束的。leader随后进行135和140的阶段2,因此这两个命令被接受。
leader和其他所有了解到leader了解的命令的服务器,现在可以开始执行1-135的命令。但是不能执行138-140,因为136和137还没被指定。leader可以从客户获取两个命令填充136和137。或者,我们可以立刻填充这个空隙,用不会导致状态改变的“no-op”命令(通过执行一致性算法的136和137的阶段2实现)。当这两个no-op命令被接受后,138-140的命令可以被执行。
命令1-140现在被接受了。leader对大于140的一致性算法实例执行阶段1,并自由提案阶段2的value。leader分配一个序号141给下一个客户请求的命令,将命令作为提案的value执行阶段2。当收到下一个客户命令的时候,使用142,以此类推。
leader可以提案142在它了解到141被接受之前。可能它对于141发送的所有消息都丢失了,并且142被接受,在其他服务器了解到141是什么命令之前。当leader收不到阶段2的回应,它会重试。若重试顺利,那么这个提案会被接受。但是,它可能失败,导致一系列命令中有一个空隙。总体上来说,一个leader可以提前获取a个命令,这样,它可以在i被接受后,提出最多a个,从i+1到i+a,留下最大a-1个空隙。
一个新的被选择的leader可以执行无限个实例的阶段1,在上面的情况中,就是135-137和超过139的实例。对所有实例使用相同的提案号,它可以通过发送短消息给所有服务器实现。在阶段1,一个acceptor只有在收到了其他proposer的阶段2消息的时候才会发送不止一个简单的ok(在上面的场景中,就是135和140)。因此,一个作为acceptor的服务器可以用一个简单合理的消息回应所有实例。因此执行无限次数的阶段1没有问题。
鉴于leader失败和重新选举的情况非常少,有限状态机主要的开销在一致性算法的阶段2。可以证明阶段2在故障时保持一致的所有算法中有最小的开销。因此,paxos算法基本是最优的。
这个对于常规操作的讨论假设只有一个单独的leader,除了一个简短的当前leader失败重新选举新的leader的时期。在异常情况下,leader选举可能失败。如果没有服务器作为leader,那么没有新的命令会被接受。如果多个服务器都认为自己是leader,那么它们都可以在一致性算法的同一个实例中提案,导致任何一个value都没有被接受。但是,安全性可以得到保证,两个不同的服务器永远不会对于i阶段的状态机命令产生异议。选举一个单独的leader只是为了保证进展性。
如果服务器集合是可以改变的,那么必须有方法决定那些服务器实现一致性算法的哪些实例。最简单的方法是通过状态机自己。当前服务器集合可以作为状态的一部分并可以被状态机命令更改。我们可以让leader提前取a个命令,通过让执行i+a一致性算法实例的服务器被执行第i个状态机命令后的状态指定。这提供了一个简单的实现用于实现任意复杂的重配置算法。