Paxos算法是莱斯利·兰伯特(Leslie Lamport, 就是 LaTeX 中的"La")于1990年提出的一种基于消息传递且具有高度容错特性的一致性算法.
Paxos算法作为分布式系统的基石, 一直都是CS领域的热门话题, 这次我们来了解一下Paxos算法.
Paxos算法初步
一. Paxos算法的理论基础
在讲述Paxos算法之前, 我们必须要了解一个前置知识.
CAP定理
CAP定理是分布式系统, 特别是分布式存储领域中被讨论最多的理论, CAP定理(Consistency, Availability and Partition-tolerance)具体描述是 :
1. 数据一致性(consistency) : 如果系统对一个写操作返回成功, 那么之后的读请求都必须读到这个新数据; 如果返回失败, 那么所有读操作都不能读到这个数据, 对调用者而言数据具有强一致性(又叫原子性 atomic、线性一致性 linearizable consistency)
2. 服务可用性(availability) : 所有读写请求在一定时间内得到响应, 可终止, 不会一直等待.
3. 分区容错性(partition-tolerance) : 在网络分区的情况下, 被分隔的节点仍能正常对外服务.
而Paxos算法就是为了解决一致性问题应运而生的. 在一个分布式数据库系统中, 如果各节点的初始状态一致, 每个节点都执行相同的操作序列, 那么他们最后能得到一个一致的状态. 为保证每个节点执行相同的命令序列, 需要在每一条指令上执行一个"一致性算法"以保证每个节点看到的指令顺序与内容一致.
关于"一致性", 在不同的场景有不同的解释:
- NoSQL领域:一致性更强调"能读到新写入的", 就是读写一致性
- 数据库领域:一致性强调"所有的数据状态一致", 经过一个事务后, 如果事务成功, 所有的表数据都按照事务中的SQL进行了操作, 该修改的修改, 该增加的增加, 该删除的删除, 不能该修改的修改了, 该删除的没删掉;如果事务失败, 所有的数据还是在初始状态;
- 状态机:在状态机中的一致性更强调在每个初始状态一致的状态机上执行一串命令后状态都必须相互一致, 也就是顺序一致性.
Paxos算法中的一致性指的就是这种情况, 接下来我们会对这些场景进一步讨论.
二. Classic Paxos算法的描述
我们先来看一下算法的基本描述
1. 基本定义
算法中的参与者主要分为三个角色, 同时每个参与者又可兼领多个角色 :
- proposer 提出提案, 提案信息包括提案编号和提议的value;
- coordinator: 协调者是提案者(proposer)的代理;在下文中, 很少有提案者的事, 因为提案者被协调者代理. 协调者和决策者直接交互, 比如发出参与请求、提案等.
- acceptor 收到提案后可以接受(accept)提案;
- learner 只能"学习"被批准的提案;
算法保重一致性的基本语义:
- 决议(value)只有在被proposers提出后才能被批准(未经批准的决议称为"提案(proposal)");
- 在一次Paxos算法的执行实例中, 只批准(chosen)一个value;
- learners只能获得被批准(chosen)的value;
有上面的三个语义可演化为四个约束:
- P1 : 一个acceptor必须接受第一次收到的提案;
- P2a : 一旦一个具有value v的提案被批准, 那么之后任何acceptor 再次接受的提案必须具有value v;
- P2b : 一旦一个具有value v的提案被批准, 那么以后任何 proposer 提出的提案必须具有value v;
- P2c : 如果一个编号为n的提案具有value v, 那么存在一个多数派, 要么他们中所有人都没有接受编号小于n的任何提案, 要么他们已经接受的所有编号小于n的提案中编号最大的那个提案具有value v;
2. 基本算法(basic paxos)
算法(决议的提出与批准)主要分为两个阶段:
- prepare阶段:
- 当Porposer希望提出方案
V1
, 首先发出prepare请求至大多数Acceptor. Prepare请求内容为序列号(SN1
);- 当Acceptor接收到prepare请求(SN1)时, 检查自身上次回复过的prepare请求(
SN2
) a). 如果SN2
>SN1
, 则忽略此请求, 直接结束本次批准过程; b). 否则检查上次批准的accept请求(SNx
,Vx
), 并且回复(SNx
,Vx
);如果之前没有进行过批准, 则简单回复OK;- accept批准阶段:
- 经过一段时间, 收到一些Acceptor回复, 回复可分为以下几种: a). 回复数量满足多数派, 并且所有的回复都是OK, 则Porposer发出accept请求, 请求内容为议案(
SN1
,V1
); b). 回复数量满足多数派, 但有的回复为: (SN2
,V2
), (SN3
,V3
)……则Porposer找到所有回复中超过半数的那个, 假设为(SNx
,Vx
), 则发出accept请求, 请求内容为议案(SN1
,Vx
); c). 回复数量不满足多数派, Proposer尝试增加序列号为SN1+
(SN1+
>SN1
), 转1继续执行;- 再经过一段时间, 收到一些Acceptor回复, 回复可分为以下几种: a). 回复数量满足多数派, 则确认
V1
被接受; b). 回复数量不满足多数派,V1
未被接受, Proposer增加序列号为SN1+
(SN1+
>SN1
), 转1继续执行;- 在不违背自己向其他proposer的承诺的前提下, acceptor收到accept 请求后即接受并回复这个请求.
Paxos算法的优化与拓展
一. Fast Paxos算法的简单介绍
自从Lamport在1998年发表Paxos算法后, 对Paxos的各种改进工作就从未停止, 其中动作最大的莫过于2005年发表的Fast Paxos. 无论何种改进, 其重点依然是在消息延迟与性能、吞吐量之间作出各种权衡. 为了容易地从概念上区分二者, 称前者Classic Paxos, 改进后的后者为Fast Paxos.
1. 基本定义
Lamport在40多页的论文中不仅提出了Fast Paxos算法, 并且还从工程实践的角度重新描述了Paxos, 使其更贴近应用场景. 从一般的Client/Server来考虑, Client其实承担了Proposer和Learner的作用, 而Server则扮演Acceptor的角色, 因此下面重新描述了Paxos算法中的几个角色:
Client/Proposer/Learner:负责提案并执行提案
Coordinator:Proposer协调者, 可为多个, Client通过Coordinator进行提案
Leader:在众多的Coordinator中指定一个作为Leader
Acceptor:负责对Proposal进行投票表决
就是Client的提案由Coordinator进行, Coordinator存在多个, 但只能通过其中被选定Leader进行; 提案由Leader交由Server进行表决, 之后Client作为Learner学习决议的结果.
这种方式更多地考虑了Client/Server这种通用架构, 更清楚地注意到了Client既作为Proposer又作为Learner这一事实. 同样要注意到的是, 如果Leader宕机了, 为了保证算法的正确性需要一个Leader的选举算法, 但与之前一样, Lamport并不关心这个Leader选举算法, 他认为可以简单地通过随机或超时机制实现.
另外在Classic Paxos中, 从每次Proposer提案到决议被学习, 需要三个通信步骤:
Proposer-----Coordinator/Leader-----Acceptor-----Learner
从直观上来说, Proposer其实更"知道"提交那个Value, 如果能让Proposer直接提交value到Acceptor, 则可以把通信步骤减少到2个. Fast Paxos便是基于此而产生.
2. 基本算法(basic paxos)
Fast Paxos的标准流程基于下面的步骤 :
- P1a:Leader提交proposal到Acceptor
- P1b:Acceptor回应已经参与投票的最大Proposer编号和选择的Value
- P2a:Leader收集Acceptor的返回值
- P2a1:如果Acceptor无返回值, 则发送一个Any消息给Acceptor, 之后Acceptor便等待Proposer提交Value
- P2a2:如果有返回值
- 如果仅存在一个Value, 则作为结果提交
- 如果存在多个Value, 则根据规则选取符合条件的一个
- 如果存在多个结果并且没有符合规则的Value, 则自由决定一个
- P2b:Acceptor把表决结果发送到Learner(包括Leader)
算法的主要变化在P2a阶段, 从形式上消息仅需在 :
Proposer-----Acceptor-----Learner
之间传递即可, 也即仅需2个通信步骤.
3. 问题
Fast Paxos基本是本着乐观锁的思路: 如果存在冲突, 则进行补偿. 其中Leader起到一个初始化Progress和解决冲突的作用, 如果Progress一直执行良好, 则Leader将始终不参与一致性过程. 因此Fast Paxos理论上只需要2个通信步骤, 而Classic Paxos需要3个, 但Fast Paxos在解决冲突时有至少需要1个通信步骤, 在高并发的场景下, 冲突的概率会非常高, 冲突解决的成本也会很大. 另外, Fast Paxos把Client深度引入算法中, 致使其架构远没Classic Paxos那么清晰, 也没Classic Paxos容易扩展.
二.如何简化竞争?
Paxos算法在出现竞争的情况下, 其收敛速度很慢, 甚至可能出现活锁的情况,
活锁指的是任务或者执行者没有被阻塞, 由于某些条件没有满足, 导致一直重复尝试, 失败, 尝试, 失败, 活锁可以认为是一种特殊的饥饿, 对外表现与自旋锁冲突很相似; 解决方式其实比较简单, 可以引入一些随机性, 或者约定重试机制; 但由于分布式系统的特殊性, 可以考虑引入截断二进制指数类型退避算法.
例如当有三个及三个以上的proposer在发送prepare请求后, 很难有一个proposer收到半数以上的回复而不断地执行第一阶段的协议. 因此, 为了避免竞争, 加快收敛的速度, 在算法中引入了一个Leader这个角色, 在正常情况下同时应该最多只能有一个参与者扮演Leader角色, 而其它的参与者则扮演Acceptor的角色, 同时所有的人又都扮演Learner的角色.
在这种优化算法中, 只有Leader可以提出议案, 从而避免了竞争使得算法能够快速地收敛而趋于一致, 此时的paxos算法在本质上就退变为两阶段提交协议. 但在异常情况下, 系统可能会出现多Leader的情况, 但这并不会破坏算法对一致性的保证, 此时多个Leader都可以提出自己的提案, 优化的算法就退化成了原始的paxos算法.
一个Leader的工作流程主要有分为三个阶段:
- 学习阶段 向其它的参与者学习自己不知道的数据(决议);
- 同步阶段 让绝大多数参与者保持数据(决议)的一致性;
- 服务阶段 为客户端服务, 提议案;
Paxos算法应用
一. Paxos算法在日志系统中的使用
我们将数据持久化的需求抽象为:在N个server的机群上, 持久化数据库或者文件系统的操作日志, 并且为每条日志分配连续递增的logID, 我们允许多个客户端并发的向机群内的任意机器发送日志同步请求. 对于高可用的需求为:在N个server中只要有超过半数的server(majority)正常服务, 并且相互通信正常, 那么这个机器就可以持续的提供日志持久化和查询服务.
将每条日志的持久化流程都看作一个"Paxos Instance", 不同的logID代表不同的Paxos Instance形成的"决议(decision)". 即每一个logID标识着一轮完整paxos协议流程的执行, 最后形成decision. 机群内的每个server同时作为paxos的acceptor和proposer.
1. 获取LogID
Server收到客户端的持久化日志请求后, 先要决定这条日志的logID, 为了尽量减少后续Paxos协议流程中处理并发冲突造成的回退, 要尽量分配与目前已经持久化和正在持久化中的日志不重复的logID, 同步也要容忍少于半数的server宕机与网络故障. 因此向所有acceptor查询它们本地目前已写盘的最大logID, 而只需收集到majority返回的结果, 并选择其中最大的logID+1作为本次待持久化日志的logID. 从上面的描述可以看出, 这里并不能保证并发提交的两条日志一定被分配到不同的logID, 而是依靠后续的paxos协议流程来达到对一个logID形成唯一的decision的目的.
2. 产生ProposalID
获取LogID后, server作为proposer开始针对当前logID, 执行Paxos Instance, 先产生proposalID, 根据paxos协议的要求, proposalID要满足全局唯一和递增序, 即对同一个server来说后产生的proposalID一定大于之前产生的, 这里我们使用server的timestamp联合ip作为proposalID, 其中timestamp在高位, ip在低位, 只要时钟的误差范围小于server重启的时间, 就可以满足"同一个server后产生的proposalID一定大于之前产生的".
3. Prepare阶段
Proposer准备好proposalID后, 将proposalID作为 "提案(proposal)"发送给所有的acceptor. 根据Paxos协议P1b的约束, 这个阶段发送的proposal并不需要携带日志内容, 而只需要发送proposalID. Acceptor收到proposal后, 根据Paxos协议P1b判断是否要"回应(response)":只有在这个Paxos Instance内(即针对这个logID)没有response过proposalID大于等于当前proposal的, 并且也没有"接受(accept)"过proposalID大于当前proposal的, 才可以response, 并承诺不再accept那些proposalID小于当前proposal的.
如果已经accept过proposal, 那么连同proposalID最大的日志内容一同response. 为了遵守P1b的约束, 在宕机恢复后也能满足, 因此在response前, 需要将当前proposalID写到本地磁盘.
上述Prepare阶段的处理流程暗示, 对于分配到相同logID的不同日志, 由于他们的proposalID不同, acceptor在response一个较小proposalID后, 是允许继续response后来的较大的proposalID的.
4. Accept请求阶段
Proposer收集到majority的response后, 来决定后续是否将要发出的"accept请求(accept request)", 判断如果majority的response中的日志内容都为空, 那么可以向所有acceptor发出accept request并携带上当前日志内容;而如果有任意的response中的日志内容有效, 那么说明当前logID已经别其他日志占用, 且其他日志可能已经在majority上持久化, 因此需要回退, 回到第一步"获取logID"重新执行.
5. Accept处理阶段
Acceptor收到proposer的accept request后, 根据上文中"Prepare阶段"的承诺, 判断当前logID下, 曾经response过的最大proposalID, 如果小于等于当前proposal的, 则可以继续执行后续的accept处理逻辑;而如果大于当前proposal的, 则说明有logID切proposalID更大的proposal在并发执行, 当前proposal会被覆盖, 因此回复proposer要求回退到第一步"获取logID"重新执行.
然后Accept处理逻辑将当前proposal连同proposalID一起写到本地磁盘, 给proposer回复成功. Proposer收集到majority的回复成功后, 说明本条日志已经在机群上持久化成功, 可以保证后续一定不会被覆盖或丢失, 可以给客户端返回了.
上述accept处理阶段的流程暗示, 可能会存在针对一个logID, 日志只在少于半数的acceptor上写到本地磁盘, 而acceptor同时response了proposalID更大的proposal, 而使得当前logID下没有任何日志在机群上持久化成功. 即一个logID可能没有标识任何有效日志, 这种情况是可以接受的.
6. 日志内容读取
已经在机群上持久化成功的日志, 需要能够被读取出来, 一般的应用模式是按照logID的顺序依次读取并回放日志. 读取的时候针对每一条logID, 需要执行一轮完整的paxos协议流程, 将accept处理阶段成功的日志内容返回. 需要注意的是, 在accept请求阶段的处理逻辑变化:Proposer收集到majority的response后, 判断如果majority的response中的日志内容都为空, 那么向所有acceptor发出日志内容为空的accept request;而如果有任意的response中的日志内容有效, 则选择proposalID最大的日志内容放入accept request. 后续收到majority的accept回复成功后, 才可以返回日志内容作为读取结果.
这里的流程暗示, 针对一个logID, 如果之前已经有日志内容持久化成功, 那么这条日志一定会被选为accept request;而如果之前日志内容仅仅在小于半数的server上写到磁盘, 那么最终这条logID的内容有可能是有效日志, 也有可能内容为空.
参考:Paxos算法