分布式-分布式理论

应用发展

单体应用架构

在企业发展的初期,一般公司的网站流量都比较小,只需要一个应用,将所有的功能代码打包成一个服务,部署到服务器上就能支撑公司的业务。这样也能够减少开发、部署和维护的成本。

这种架构特点有其优点:

架构简单,项目开发和维护成本低。所有项目模块部署到一起,对于小型项目来说,维护方便。

垂直应用架构

垂直应用架构,就是将原来一个项目应用进行拆分,将其拆分为互不想干的几个应用,以此来提升系统的整体性能。

这种架构的优点:

  • 系统进行了拆分,可根据不同系统的访问情况,有针对性的进行优化。
  • 能够实现应用的水平扩展。
  • 各系统能够分担整体访问的流量,解决了并发问题。
  • 一个系统发生了故障,不应用其他系统的运行情况,提高了整体的容错率。 

 这种架构的缺点:

  • 拆分后的各系统之间相对比较独立,无法进行互相调用
  • 各系统难免存在重叠的业务,会存在重复开发的业务,后期维护比较困难。

分布式架构

       我们将系统演变为垂直应用架构之后,当垂直应用越来越多,重复编写的业务代码就会越来越多。此时,我们需要将重复的代码抽象出来,形成统一的服务供其他系统或者业务模块来进行调用。此时,系统就会演变为分布式架构。

       在分布式架构中,我们会将系统整体拆分为服务层和表现层。服务层封装了具体的业务逻辑供表现层调用,表现层则负责处理与页面的交互操作。

这种架构的优点:

  • 将重复的业务代码抽象出来,形成公共的访问服务,提高了代码的复用性。
  • 可以有针对性的对系统和服务进行性能优化,以提升整体的访问性能。

这种架构的缺点: 

  • 系统之间的耦合度变高,调用关系变得复杂,难以维护。

SOA架构

      在分布式架构下,当部署的服务越来越多,重复的代码就会越来越多,对于容量的评估,小服务资源的浪费等问题比较严重。此时,我们就需要增加一个统一的调度中心来对集群进行实时管理。此时,系统就会演变为SOA(面向服务)的架构。

这种架构的优点: 

  • 使用注册中心解决了各个服务之间的服务依赖和调用关系的自动注册与发现。

这种架构的缺点:

  • 各服务之间存在依赖关系,如果某个服务出现故障可能会造成服务的雪崩(关于穿透、击穿和雪崩的问题。
  • 服务之间的依赖与调用关系复杂,测试部署的困难比较大。

微服务架构

      随着业务的发展,我们在SOA架构的基础上进一步扩展,将其彻底拆分为微服务架构。在微服务架构下,我们将一个大的项目拆分为一个个小的可以独立部署的微服务,每个微服务都有自己的数据库。

这种架构的优点:

  • 服务彻底拆分,各服务独立打包、独立部署和独立升级
  • 每个微服务负责的业务比较清晰,利于后期扩展和维护。
  • 微服务之间可以采用REST和RPC协议进行通信。

这种架构的缺点:

  • 开发的成本比较高。涉及到各服务的容错性问题。涉及到数据的一致性问题。涉及到分布式事务问题

分布式理论

分布式理论(一) - CAP定理

CAP的定义

Consistency (一致性):

“all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。

Availability (可用性):

可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。

Partition Tolerance (分区容错性):

即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性或可用性的服务。

分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。

CA - 单点集群,满足一致性,可用性的系统,通常在可扩展性上不太强大。
CP- 满足一致性,分区容忍必的系统,通常性能不是特别高。
AP - 满足可用性,分区容忍性的系统,通常可能对一致性要求低一些。

 CAP定理的证明

现在我们就来证明一下,为什么不能同时满足三个特性?

       假设有两台服务器,一台放着应用A和数据库V,一台放着应用B和数据库V,他们之间的网络可以互通,也就相当于分布式系统的两个部分。

       在满足一致性的时候,两台服务器 N1和N2,一开始两台服务器的数据是一样的,DB0=DB0。在满足可用性的时候,用户不管是请求N1或者N2,都会得到立即响应。在满足分区容错性的情况下,N1和N2有任何一方宕机,或者网络不通的时候,都不会影响N1和N2彼此之间的正常运作。 

      当用户通过N1中的A应用请求数据更新到服务器DB0后,这时N1中的服务器DB0变为DB1,通过分布式系统的数据同步更新操作,N2服务器中的数据库V0也更新为了DB1,这时,用户通过B向数据库发起请求得到的数据就是即时更新后的数据DB1。 

    上面是正常运作的情况,但分布式系统中,最大的问题就是网络传输问题,现在假设一种极端情况,N1和N2之间的网络断开了,但我们仍要支持这种网络异常,也就是满足分区容错性,那么这样能不能同时满足一致性和可用性呢?

    假设N1和N2之间通信的时候网络突然出现故障,有用户向N1发送数据更新请求,那N1中的数据DB0将被更新为DB1,由于网络是断开的,N2中的数据库仍旧是DB0;

     如果这个时候,有用户向N2发送数据读取请求,由于数据还没有进行同步,应用程序没办法立即给用户返回最新的数据DB1,怎么办呢?有二种选择,第一,牺牲数据一致性,响应旧的数据DB0给用户;第二,牺牲可用性,阻塞等待,直到网络连接恢复,数据更新操作完成之后,再给用户响应最新的数据DB1。 

    上面的过程比较简单,但也说明了要满足分区容错性的分布式系统,只能在一致性和可用性两者中,选择其中一个。也就是说分布式系统不可能同时满足三个特性。这就需要我们在搭建系统时进行取舍了,那么,怎么取舍才是更好的策略呢?

取舍策略

CAP三个特性只能满足其中两个,那么取舍的策略就共有三种:

CA without P:如果不要求P(不允许分区),则C(强一致性)和A(可用性)是可以保证的。但放弃P的同时也就意味着放弃了系统的扩展性,也就是分布式节点受限,没办法部署子节点,这是违背分布式系统设计的初衷的。

CP without A:如果不要求A(可用),相当于每个请求都需要在服务器之间保持强一致,而P(分区)会导致同步时间无限延长(也就是等待数据同步完才能正常访问服务),一旦发生网络故障或者消息丢失等情况,就要牺牲用户的体验,等待所有数据全部一致了之后再让用户访问系统。设计成CP的系统其实不少,最典型的就是分布式数据库,如Redis、HBase等。对于这些分布式数据库来说,数据的一致性是最基本的要求,因为如果连这个标准都达不到,那么直接采用关系型数据库就好,没必要再浪费资源来部署分布式数据库。

 AP wihtout C:要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。典型的应用就如某米的抢购手机场景,可能前几秒你浏览商品的时候页面提示是有库存的,当你选择完商品准备下单的时候,系统提示你下单失败,商品已售完。这其实就是先在 A(可用性)方面保证系统可以正常的服务,然后在数据的一致性方面做了些牺牲,虽然多少会影响一些用户体验,但也不至于造成用户购物流程的严重阻塞。

分布式理论(二) - BASE理论

BASE 思想中的 BA(Basically Available)基本可用

是鼓励通过预先的架构设计或者前期规划,尽量在分布式的系统中,把以前可能影响全平台的严重问题,变成只会影响平台中的一部分数据或者功能的非严重问题。

在了解BASE理论的时候先说说 2PC(两阶段提交)方案

2PC(两阶段提交)方案,这个方案本身能保证在数据库切分的情况下,原来的事务依然保留着自身的 ACID 性质。即:

  1. Atomicity(原子性),不管事务里执行多少命令,对外它们都是一体的,要么都执行,要么都不执行。

  2. Consistency(一致性),正因为事务里要么做要么都不做,所以数据库的状态变化只能由事务变更后,才会叫一致性状态。

  3. Isolation(隔离性),事务里做的事儿事务外面谁也看不到,就跟个盒子把数据罩起来一样,到底中间怎么变化的,事务外面的观察不到。

  4. Durability(持久性),事务确认成功了,那这状态就永久不变了。

但也正因为这 4 个特性,2PC才有一定的问题

  • 首先,数据库拆分了,那么根据事务的原子性,事务自身必须是一体的,那么事务涉及到的不同的数据库就必须都访问一遍,而这本身就意味着很高的通信成本。

再加上,为了保持一致性,事务失败后,还必须恢复各个数据库原来的状态,这就必须让已经成功执行过本地事务的数据库全部回滚。

而稍微懂点数据库的人都知道,这个成本有多大。

  • 数据库的拆分会造成整个平台的可用性下降。 
  • coordinator如果在发起提议后宕机,那么participant将进入阻塞(block)状态、一直等待coordinator回应以完成该次决议。这时需要另一角色把系统从不可结束的状态中带出来,我们把新增的这一角色叫协调者备份(coordinator watchdog)。coordinator宕机一定时间后,watchdog接替原coordinator工作,通过问询(query) 各participant的状态,决定阶段2是提交还是中止。这也要求 coordinator/participant 记录(logging)历史状态,以备coordinator宕机后watchdog对participant查询、coordinator宕机恢复后重新找回状态。
  • 从coordinator接收到一次事务请求、发起提议到事务完成,经过2PC协议后增加了2次RTT(propose+commit),带来的时延(latency)增加相对较少。  

假设我现在有一台数据库,它的可用性是 99.9%。如果因为分库,数据库从一台变成两台,那么平台的可用性就会变成:

平台的可用性 = 99.9% * 99.9% = 99.8%

从 99.9% 变成了 99.8%,这意味着可用性下降了 0.1%,每个月的不可用时间会增加 43 分钟之多。

一边是硬件升级已经到顶,单机数据库也优化到了极限,再不做数据库拆分,平台可能随时瘫痪。一边是没有好的策略,可能拆分数据库后,每个月都有宕机的风险,同时性能也可能会出现剧烈的下降。

 由于上面的问题,我们在聊聊 BASE理论

用广告平台举例

从业务上,要求广告的访问数据都要保证及时入库不能丢,因为丢了就可能造成计费的损失,而这些损失全是钱。所以,每当用户点击广告或者广告展示出来的时候,为了保证不丢失,这些数据都是实时入库的。

又根据业务需求,当广告流量入库时,还需要往广告预算表和媒体流水表里同时根据这笔流量进行记账,以供后续财务计算。

如果完全不考虑事务,则拆分库后,操作可能会是这个样子。

 

这三个操作可能会并行发往不同的数据库执行。由于三个操作之间没有事务的约束,所以,一个操作出问题了,另外的操作并不会受到影响。

而这却也引发了另外一个问题,数据状态不一致。

如果在上面的业务中,插入广告流量表的操作失败了,但其余两张表插入成功了,业务就会面临一个很尴尬的情况:他们算出的财务报表没有依据。财务流水中找不到产生了这笔流水的依据。

而这种不一致的状态由于已经被持久化到了数据库中,就会导致这种不一致的状态永久存在了数据库中。这业务能接受吗?但凡有点职业精神的程序员能接受吗?

要有折中的办法!!!

现在再回过头来看看 2PC 的问题。假设 2PC 的实现是一步步执行的(当然,不管是一步一步还是异步并发,他们总是要确保大家要么一起成功要么一起失败的。

所以,即使并发操作,也不会节省多少性能,因为短板在执行最慢的那条语句上。如果执行我们上面的事务需要几步呢?

假如现在要执行事务 A:

  1. 协调器发出事务 A 中的第一条语句 Insert into 流量表

  2. 协调器等待结果

  3. 协调器发出事务 A 中的第二条语句 Insert into 预算表

  4. 协调器等待结果

  5. 协调器发出事务 A 中的第三条语句 Insert into 流水表

  6. 协调器等待结果

如果中间有失败的,协调器还需要做额外的操作:

  1. 协调器告诉事务 A 中第一条语句做回滚操作

  2. 协调器等待结果

  3. 协调器告诉事务 A 中第二条语句做回滚操作

  4. 协调器等待结果

  5. 协调器告诉事务 A 中第三条语句做回滚操作

  6. 协调器等待结果

这简直是让人窒息的操作步骤了。如果有一种方法既能节省步骤又能节省事务执行时间该有多好啊。

嗯……我只能说当时的自己实在是长得丑却想的美。

世上尚不存在这种方法的。但是,世上还存在另外的解决此类事情的方式:

异步处理,时间分摊

我们分析下关于插入广告流量这块儿的业务。你会发现一个神奇的现象,即广告流量表中的数据才是核心,而预算表和流水表统统都是广告流量表中数据的一种缓存而已。

如果,嗯,我是说如果有这么一种办法,即我们先把广告流量数据插入数据库,成功以后,再把以广告流量数据作为根基的附属操作(这里是插入预算表和流水表)放到一个地方持久化。然后,我们再从那个存放附属操作的地方把操作信息取出来,专门对这些操作信息进行处理。

而这种处理方式可能会非常灵活,要么可以对这些操作信息进行批量处理,要么可以对他们异步的在后台处理。处理这些操作信息成功以后,再把以前持久化好的操作信息给删除。

整个方法实施下来,相当于把应该在 A 时刻在前台阻塞着花 3 秒处理业务的操作,变成了在 A 时刻前台花 1 秒,然后在 B 时刻后台花 2 秒处理业务的操作,这不也可以变相的达到我们想节省步骤和事务执行时间的目标了吗?

这真的是一个好的思路啊,还记得当时的自己想到这个思路的时候,忍不住在内心大喊了起来:“那个存附属操作信息的地方就是 MQ 啊。用 MQ,MQ 就能做这件事情。”

那么就一起来看下 MQ 是如何帮我解决这个大难题的吧,针对上面的广告流量详情的业务,我们用了 MQ 之后会有如下的步骤:

  1. 执行 Insert into 流量表语句

  2. 等待结果

  3. 发消息到 MQ 里,内容为 Insert into 预算表

  4. 等待 MQ 持久化成功

  5. 发消息到 MQ 里,内容为 Insert into 流水表

  6. 等待 MQ 持久化成功

如果发给 MQ 消息失败:

  1. 可以降级写到本地日志中

OK那么这改进后的方法是怎么提升性能的呢?

  • 首先,我们发给 MQ 的消息可以批量发送;

  • 其次,发给 MQ 并持久化消息要比数据库执行一次事务快了一个数量级;

  • 最后,失败后,回滚操作成本降低了不止一个数量级。

这个方法本质上,在应用层其实就执行了一条语句而已,剩下的完全可以根据业务需求的不同,选择处理 MQ 中的消息的方式。比如,处理消息既可以异步慢慢处理,也可以推迟一段时间后处理,更可以凌晨定时处理。

可以看到,使用 MQ 方案后,对广告流量这个业务需求而言,其实,出现了一个中间状态:广告流量表有数据,但是以这条数据为基准的预算表和流水表暂时还没有数据。

中间这个状态此时是不满足业务需求的。而这种状态,在 BASE 理论中就被称为:

软状态(Soft state)

至于广告流量表当时没有及时插入到预算表和流水表中的数据呢,它们最终也将会随着后续对 MQ 消息的处理而被补充完整的。

而对于这种当时不符合业务需求的软状态,通过一些后续内部的自动化操作把数据状态补充完整从而最终满足业务需求的情况,在 BASE 理论中就被称为了:

最终一致性(Eventually consistent)

由此,我通过不断利用 BASE 理论中的软状态和最终一致性的思路,最终补上了平台数据库切分需要的最后一块拼图——平台性能大提升

再重复一次,BASE 理论本质上只是一种架构思想,它告诉人们世界上还存在着这么一些事情:

  1. 能通过巧妙地设计,通过局部轻微的损失减少全局严重的损失;

  2. 能通过一些解耦、异步、推迟执行、批量执行等技巧,构造出一种中间状态,从而提高系统的整体性能;

  3. 平台是为业务服务的,业务的核心是数据状态,而数据状态无论中间变成什么样,最终还要恢复到它应该处于的正确状态。

 3PC

3PC(three phase commit)即三阶段提交,既然2PC可以在异步网络+节点宕机恢复的模型下实现一致性,那还需要3PC做什么?

在2PC中一个participant的状态只有它自己和coordinator知晓,假如coordinator提议后自身宕机,在watchdog启用前一个participant又宕机,其他participant就会进入既不能回滚、又不能强制commit的阻塞状态,直到participant宕机恢复。这引出两个疑问:

能不能去掉阻塞,使系统可以在commit/abort前回滚(rollback)到决议发起前的初始状态 当次决议中,participant间能不能相互知道对方的状态,又或者participant间根本不依赖对方的状态

图片截取自wikipediacoordinator 接收完participant的反馈(vote)之后,进入阶段2,给各个participant发送准备提交(prepare to commit)指令。participant接到准备提交指令后可以锁资源,但要求相关操作必须可回滚。coordinator接收完确认(ACK)后进入阶段3、进行commit/abort,3PC的阶段3与2PC的阶段2无异。协调者备份(coordinator watchdog)、状态记录(logging)同样应用在3PC。   participant如果在不同阶段宕机,我们来看看3PC如何应对:

  • 阶段1: coordinator或watchdog未收到宕机participant的vote,直接中止事务;宕机的participant恢复后,读取logging发现未发出赞成vote,自行中止该次事务
  • 阶段2: coordinator未收到宕机participant的precommit ACK,但因为之前已经收到了宕机participant的赞成反馈(不然也不会进入到阶段2),coordinator进行commit;watchdog可以通过问询其他participant获得这些信息,过程同理;宕机的participant恢复后发现收到precommit或已经发出赞成vote,则自行commit该次事务
  • 阶段3: 即便coordinator或watchdog未收到宕机participant的commit ACK,也结束该次事务;宕机的participant恢复后发现收到commit或者precommit,也将自行commit该次事务

物理时钟 vs 逻辑时钟 

      现实生活中时间是很重要的概念,时间可以记录事情发生的时刻、比较事情发生的先后顺序。分布式系统的一些场景也需要记录和比较不同节点间事件发生的顺序,但不同于日常生活使用物理时钟记录时间,分布式系统使用逻辑时钟记录事件顺序关系,下面我们来看分布式系统中几种常见的逻辑时钟。

     可能有人会问,为什么分布式系统不使用物理时钟(physical clock)记录事件?每个事件对应打上一个时间戳,当需要比较顺序的时候比较相应时间戳就好了。  

     这是因为现实生活中物理时间有统一的标准,而分布式系统中每个节点记录的时间并不一样,即使设置了 NTP 时间同步节点间也存在毫秒级别的偏差。因而分布式系统需要有另外的方法记录事件顺序关系,这就是逻辑时钟(logical clock)。

Lamport timestamps

Leslie Lamport 在1978年提出逻辑时钟的概念,并描述了一种逻辑时钟的表示方法,这个方法被称为Lamport时间戳(Lamport timestamps)。

分布式系统中按是否存在节点交互可分为三类事件,一类发生于节点内部,二是发送事件,三是接收事件。Lamport时间戳原理如下:

每个事件对应一个Lamport时间戳,初始值为0 如果事件在节点内发生,时间戳加1 如果事件属于发送事件,时间戳加1并在消息中带上该时间戳 如果事件属于接收事件,时间戳 = Max(本地时间戳,消息中的时间戳) + 1

假设有事件a、b,C(a)、C(b)分别表示事件a、b对应的Lamport时间戳,如果C(a) < C(b),则有a发生在b之前(happened before),记作 a -> b,例如图1中有 C1 -> B1。通过该定义,事件集中Lamport时间戳不等的事件可进行比较,我们获得事件的偏序关系(partial order)。

如果C(a) = C(b),那a、b事件的顺序又是怎样的?假设a、b分别在节点P、Q上发生,Pi、Qj分别表示我们给P、Q的编号,如果 C(a) = C(b) 并且 Pi < Qj,同样定义为a发生在b之前,记作 a => b。假如我们对图1的A、B、C分别编号Ai = 1、Bj = 2、Ck = 3,因 C(B4) = C(C3) 并且 Bj < Ck,则 B4 => C3。

通过以上定义,我们可以对所有事件排序、获得事件的全序关系(total order)。上图例子,我们可以从C1到A4进行排序。

Vector clock

Lamport时间戳帮助我们得到事件顺序关系,但还有一种顺序关系不能用Lamport时间戳很好地表示出来,那就是同时发生关系(concurrent)。例如图1中事件B4和事件C3没有因果关系,属于同时发生事件,但Lamport时间戳定义两者有先后顺序。

Vector clock是在Lamport时间戳基础上演进的另一种逻辑时钟方法,它通过vector结构不但记录本节点的Lamport时间戳,同时也记录了其他节点的Lamport时间戳。Vector clock的原理与Lamport时间戳类似,使用图例如下:

假设有事件a、b分别在节点P、Q上发生,Vector clock分别为Ta、Tb,如果 Tb[Q] > Ta[Q] 并且 Tb[P] >= Ta[P],则a发生于b之前,记作 a -> b。到目前为止还和Lamport时间戳差别不大,那Vector clock怎么判别同时发生关系呢?

如果 Tb[Q] > Ta[Q] 并且 Tb[P] < Ta[P],则认为a、b同时发生,记作 a <-> b。例如图2中节点B上的第4个事件 (A:2,B:4,C:1) 与节点C上的第2个事件 (B:3,C:2) 没有因果关系、属于同时发生事件。

Version vector

基于Vector clock我们可以获得任意两个事件的顺序关系,结果或为先后顺序或为同时发生,识别事件顺序在工程实践中有很重要的引申应用,最常见的应用是发现数据冲突(detect conflict)。

分布式系统中数据一般存在多个副本(replication),多个副本可能被同时更新,这会引起副本间数据不一致,Version vector的实现与Vector clock非常类似[8],目的用于发现数据冲突。下面通过一个例子说明Version vector的用法:

  • client端写入数据,该请求被Sx处理并创建相应的vector ([Sx, 1]),记为数据D1
  • 第2次请求也被Sx处理,数据修改为D2,vector修改为([Sx, 2])
  • 第3、第4次请求分别被Sy、Sz处理,client端先读取到D2,然后D3、D4被写入Sy、Sz
  • 第5次更新时client端读取到D2、D3和D4 3个数据版本,通过类似Vector clock判断同时发生关系的方法可判断D3、D4存在数据冲突,最终通过一定方法解决数据冲突并写入D5

Vector clock只用于发现数据冲突,不能解决数据冲突。如何解决数据冲突因场景而异,具体方法有以最后更新为准(last write win),或将冲突的数据交给client由client端决定如何处理,或通过quorum决议事先避免数据冲突的情况发生。

由于记录了所有数据在所有节点上的逻辑时钟信息,Vector clock和Version vector在实际应用中可能面临的一个问题是vector过大,用于数据管理的元数据(meta data)甚至大于数据本身。

解决该问题的方法是使用server id取代client id创建vector (因为server的数量相对client稳定),或设定最大的size、如果超过该size值则淘汰最旧的vector信息。

以上介绍了分布式系统里逻辑时钟的表示方法,通过Lamport timestamps可以建立事件的全序关系,通过Vector clock可以比较任意两个事件的顺序关系并且能表示无因果关系的事件,将Vector clock的方法用于发现数据版本冲突,于是有了Version vector。

Paxos协议 

算法过程

少数服从多数

首先要认识到,这是一个分布式系统下的共识算法,要解决的问题,简化一点,就是一堆机器,每一台都可能会收到客户端的一条消息,那需要将自己收到的消息,告诉其他的机器,让所有分布式系统中的机器,达到最终的一致,这就是达到共识。

Paxos采取了一个我们非常熟悉的达成共识的方法:少数服从多数。只要有超过一半的机器认可某一个消息,那么最终就所有机器都接受这条消息并将它作为本次的结论。而竞选失败的少数派消息,就会被拒绝,并由第一个从客户端处接收到该消息的机器,向客户端发送失败结果,由客户端进行重试,去尝试在下一轮竞选中胜出。

少数服从多数,说来简单,如果是一群人的话,大家碰个头一起举手表决就好了。但是放到一个分布式系统中就变复杂了。机器之间怎么传递提议,怎么表决,怎么统计多数,网络传输需要时间,在表决过程中,其他机器收到了新的消息怎么办,都需要一整套机制来解决。

下面就来逐步讲解Paxos的过程,但在讲解过程之前,先说Paxos中最常见的两种角色:

Proposer:提案者。也就是在选举中提出提案的人,放到分布式系统里,就是接收到客户端写操作的人。一切行为都由Proposer提出提案开始,Paxos会将提案想要进行的操作,抽象为一个“value”,去在多台机器中传递,最后被所有机器接受。

Acceptor:批准者。Acceptor从含义上来说就是除了当前Proposer以外的其他机器,他们之间完全平等和独立,Proposer需要争取超过半数(N/2+1)的Acceptor批准后,其提案才能通过,它倡导的“value”操作才能被所有机器所接受。

除了以上两种角色,实际上Paxos还会提到Learner,即学习者这个角色,该角色是在达成决议时,对结论的学习者,也即是从其他节点“学习”最终提案内容,比较简单。需要注意,这些角色只是在不同时间下,逻辑上的划分,实际上任何一台机器都可以充当这三个角色之一。

一个简单的提案

先描述最简单的情况,假设现在有四台机器,其中一台收到了来自客户端的写操作请求,需要同步给其他机器。

此时这台收到请求的机器,我们称它为Proposer,因为它将要开始将收到的请求,作为一个提案,提给其他的机器。这里为了方便,我们假设这个请求是要将一个地址设置为“深圳”,那么如下图所示:

此时,其他的Acceptor都闲着呢,也没其他人找,所以当它们收到Proposer的提案时,就直接投票了,说可以可以,我是空的,赞成提案(同意提议):

 

到这里,就还是一个简单的同步的故事,但需要注意的是,这里Proposer实际上是经历了两步的。 

在这个简单的提案过程中,Proposer其实也经历了两个阶段:

Prepare阶段:Proposer告诉所有其他机器,我这里有一个提案(操作),想要你们投投票支持一下,想听听大家的意见。Acceptor看自己是NULL,也就是目前还没有接受过其他的提案,就说我肯定支持。

Accept阶段:Proposer收到其他机器的回复,说他们都是空的,也就是都可以支持接受Proposer的提案(操作),于是正式通知大家这个提案被集体通过了,可以生效了,操作就会被同步到所有机器正式生效。

两个提案并发进行

现在考虑一个更复杂的场景,因为我们处于一个分布式的场景,每台机器都可能会收到请求,那如果有两台机器同时收到了两个客户端的不同请求,该怎么处理呢?大家听谁的呢?最后的共识以谁的为准呢?如下图所示:

在这种情况下,由于网络传输的时间问题,两个Proposer的提案到达各个机器,是会存在先后顺序的。假设Proposer 1的提案先达到了Acceptor 1和 Acceptor 2,而Proposer 2的提案先达到了Acceptor 3,其达到Acceptor 1和Acceptor 2时,由于机器已经投票给Proposer 1了,所以Proposer 2的提案遭到拒绝,Proposer 1达到Acceptor 3的时候同样被拒。

Acceptor们迷了,Proposer们也迷了,到底应该接受谁?此时,还是遵循自由民主的法则——少数服从多数。

Proposer 1发现超过半数的Acceptor都接受了自己,所以放心大胆地发起要求,让所有Acceptor都按照自己的值来操作。而Proposer 2发现只有不到半数的Acceptor支持自己,而有超过半数是支持Proposer 1的值的,因此只能拒绝Client 2,并将自己也改为Proposer 1的操作:

 

到此为止,看起来没有问题,但是,这是因为恰好Acceptor的数量是单数,可以选出“大多数”,但是因为同时成为Proposer的机器数量是不确定的,因此是无法保证Acceptor的数量一定是单数的,如下面这种情况就无法选出“大多数”了:

 

这时,两个Proposer有可能总是先抢到一个Acceptor的支持,然后在另一个Acceptor处折戟沉沙,算法就一直循环死锁下去了。为了解决这种情况,Paxos给提案加了一个编号。

之前我们Proposer的提案都是只有操作内容的,现在我们给他加一个编号,即:

Proposer 1的提案为:[n1,v1]

Proposer 2的提案为:[n2,v2]

假设Proposer 1接到Clint 1的消息稍微早一点,那么它的编号就是1,Proposer 2的编号就是2,那么他们的提案实际就是:

Proposer 1的提案为:[1,{ Set Addr =“深圳”}]

Proposer 2的提案为:[2,{ Set Addr =“北京”}]

此时,Paxos加上一条规则:

Acceptor如果还没有正式通过提案(即还没有Accept使操作生效),就可以接受编号更大的Prepare请求。

所以,回到上面的困境

当Proposer 1想要向Acceptor 2寻求支持时,Acceptor 2一看你的编号(1)比我已经支持的编号(2)要小,拒绝拒绝。此时Proposer 1由于没有得到过半数的支持,会重新寻求支持。

而当Proposer 2想要向Acceptor 1寻求支持时,Acceptor 1一看你的编号(2)比我已经支持的编号(1)要大,好的你是老大我听你的。此时Proposer 2已经得到了超过半数的支持,可以进入正式生效的Accept阶段了。

这里需要补充一下,Proposer 1这里支持提案失败,他是怎么让自己也接受Proposer 2的提案的呢?

所以这里的后续会发生的事情是:

Proposer 2发现得到了过半数的支持,开始向所有Acceptor发送Accept请求。

所有Acceptor接收到Accept请求后,按照之前Prepare时收到的信息与承诺,去生效Proposer 2的提案内容(即Set Addr=“北京”的操作)。

Proposer 1之前已经收到了所有Acceptor的回复,发现没有得到过半数的支持,直接回复Client 1请求失败,并变成一个Acceptor(或者说Learner),接受Proposer 2的Accept请求。

Proposer 1之前已经收到了所有Acceptor的回复,发现没有得到过半数的支持,直接回复Client 1请求失败,并变成一个Acceptor(或者说Learner),接受Proposer 2的Accept请求。

这里再想多一点,考虑另一种场景:假设Proposer 2的Accept请求先达到了Acceptor 2,然后Proposer 1向Acceptor 2发送的Prepare请求才到达 Acceptor 2,会发生什么呢?

最直观的处理是,Acceptor 2直接拒绝,然后Proposer 1走上面的流程,但Paxos为了效率,又增加了另一条规则:

如果一个Prepare请求,到达Acceptor时,发现该Acceptor已经接受生效了另一个提案,那么它除了回复提案被拒绝外,还会带上Acceptor已经通过的编号最大的那个提案的内容回到Proposer。Proposer收到带内容的拒绝后,需要修改自己的提案为返回的内容。

此时会发生的事情就变成了:

此时Acceptor 2除了会拒绝它的请求,还会告诉Proposer 1,说我已经通过并生效了另一个编号为2的提案,内容是Set Addr=“北京”。

然后Proposer 1查看回复时,发现已经有Acceptor生效提案了,于是就修改自己的提案,也改为Set Addr=“北京”,并告知Client 1你的请求失败了。

接着Proposer 1开始充当Proposer 2的小帮手,帮他一起传播 Proposer 2的提案,加快达成共识的过程。

PS:这里需要注意, 编号是需要保证全局唯一的,而且是全局递增的 ,否则在比较编号大小的时候就会出现问题,怎么保证编号唯一且递增有很多方法,比如都向一个统一的编号生成器请求新编号;又比如每个机器的编号用机器ID拼接一个数字,该数字按一个比总机器数更大的数字间隔递增。

一些异常情况

上面的规则是不是就能保证整个算法解决所有问题了呢?恐怕不是,这里再看看一些异常情况。

异常情况一:假设现在有三个Proposer同时收到客户端的请求,那么他们会生成全局唯一的不同编号,带着各自接收到的请求提案,去寻求Acceptor的支持。但假设他们都分别争取到了一个Acceptor的支持,此时由于Prepare阶段只会接受编号更大的提案,所以正常情况下只有Proposer 3的提案会得到所有Acceptor的支持。但假设这时候Proposer 3机器挂了,无法进行下一步的Accept了,怎么办呢?那么所有Acceptor就会陷入持续的等待,而其他的Proposer也会一直重试然后一直失败。

为了解决这个问题,Paxos决定,允许Proposer在提案遭到过半数的拒绝时,更新自己的提案编号,用新的更大的提案编号,去发起新的Prepare请求。

那么此时Proposer 1和Proposer 2就会更新自己的编号,从【1】与【2】,改为比如【4】和【5】,重新尝试提案。这时即使Proposer 3机器挂了,没有完成Accept,Acceptor也会由于接收到了编号更大的提案,从而覆盖掉Proposer 3的提案,进入新的投票支持阶段。

异常情况二:虽然更新编号是解决了上面的问题,但却又引入了活锁的问题。由于可以更新编号,那么有概率出现这种情况,即每个Proposer都在被拒绝时,增大自己的编号,然后每个Proposer在新的编号下又争取到了小于半数的Acceptor,都无法进入Accept,又重新加大编号发起提案,一直这样往复循环,就成了活锁(和死锁的区别是,他们的状态一直在变化,尝试解锁,但还是被锁住了)。

要解决活锁的问题,有几种常见的方法:

当Proposer接收到回复,发现支持它的Acceptor小于半数时,可以不立即更新编号重试,而是随机延迟一小段时间,来错开彼此的冲突。

可以设置一个Proposer的Leader,全部由它来进行提案,这即使共识算法的常见套路,选择一个Leader。这需要进行Leader的选举,以及解决存活性检查以及换届的问题。实际上就已经演变成Multi-Paxos了。

异常情况三:由于在提案时,Proposer都是根据是否得到超过半数的Acceptor的支持,来作为是否进入Accept阶段的依据,那如果在算法进行中新增或下线了机器呢?如果此时一些Proposer知道机器数变了,一些Proposer不知道,那么大家对半数的判断就会不一致,导致算法出错。

因此在实际运行中,机器节点数的变动,也需要作为一条要达成共识的请求提案,通过Paxos算法本身,传达到所有机器节点上。

为了使Paxos运行得更稳定,不需要时刻担心是否有节点数变化,可以固定一个周期,要求只有在达到固定周期时才允许变更节点数,比如只有在经过十次客户端请求的提案与接受后,才处理一次机器节点数变化的提案。

那如果这个间隔设置地相对过久,导致现在想要修改节点数时,一直要苦等提案数,怎么办呢?毕竟有时候机器坏了是等不了的。那么可以支持主动填充空的提案数,来让节点变更的提案尽早生效。

Paxos协议的两阶段

抽象和完善一下这个过程,就是:

Prepare准备阶段:

在该阶段,Proposer会尝试告诉所有的其他机器,我现在有一个提案(操作),请告诉我你们是否支持(是否能接受)。其他机器会看看自己是否已经支持其他提案了(是否接受过其他操作请求),并回复给Proposer(如果曾经接受过其他值,就告诉Proposer接受过什么值/操作);

Acceptor如果已经支持了编号N的提案,那么不会再支持编号小于N的提案,但可以支持编号更大的提案;

Acceptor如果生效了编号为N的提案,那么不会再接受编号小于N的提案,且会在回复时告知当前已生效的提案编号与内容。

Accept提交阶段:

在该阶段,Proposer根据上一阶段接收到的回复,来决定行为;

如果上一阶段超过半数的机器回复说接受提案,那么Proposer就正式通知所有机器去生效这个操作;

如果上一阶段超过半数的机器回复说他们已经先接受了其他编号更大的提案,那么Proposer会更新一个更大的编号去重试(随机延时);

如果上一阶段的机器回复说他们已经生效了其他编号的提案,那么Proposer就也只能接受这个其他人的提案,并告知所有机器直接接受这个新的提案;

如果上一阶段都没收到半数的机器回复,那么提案取消;

PS:接受其他提案,以及提案取消的情况下,Proposer就要直接告诉客户端该次请求失败了,等待客户端重试即可。

这里可以看到,超过半数以上的机器是个很重要的决定结果走向的条件。至此,已经描述完了针对一次达成共识的过程,这被称为Basic-Paxos。

那如果有多个值需要达成共识呢?

Multi-Paxos

如果有多个值要不断地去针对一次次请求达成共识,使用Basic-Paxos也是可以的,无非就是一遍遍地执行算法取得共识并生效嘛,但在分布式系统下,容易由于多次的通信协程造成响应过慢的问题,何况还有活锁问题存在。因此Lamport给出的解法是:

先选择一个Leader来担当Proposer的角色,取消多Proposer,只有一个Leader来提交提案,这样就没有了竞争(也没有了活锁)。同时,由于无需协商判断,有了Leader后就可以取消Prepare阶段,两阶段变一阶段,提高效率。

对于每一次要确定的值/操作,使用唯一的一个标识来区分,保证其单调递增即可。

对于选择Leader的过程,简单的做法很多,复杂的也只需要进行一次Basic-Paxos即可。选出Leader后,直到Leader挂掉或者到期,都可以保持由它来进行简化的Paxos协议。

如果有多个机器节点都由于某些问题自认为自己是Leader,从而都提交了提案,也没关系,可以令其退化成Basic-Paxos,也可以在发现后再次选择Leader即可。

其他共识算法 ZAB&Raft

这里也顺便对比一下另外两种常见的共识算法:ZAB和Raft。

(一)ZAB

ZAB全称是Zookeeper Atomic Broadcast,也就是Zookeeper的原子广播,顾名思义是用于Zookeeper的。

ZAB理解起来很简单,在协议中有两种角色:

Leader节点:有任期的领导节点,负责作为与客户端的连接点接受读写操作,然后将其广播到其他节点去。

Follower节点:主要是跟随领导节点的广播消息进行同步,并关注领导节点的健康状态,好随时取而代之。

既然有Leader节点,就必然有Leader的选举过程,ZAB的选举,会先看各个节点所记录的消息的时间戳(数据ID),时间戳(数据ID)越大,节点上的数据越新,就会优先被投票,如果数据ID比较不出来,就再看事先定义的节点的优先级(节点ID)。当大家根据上述优先级投票,超过半数去支持一个节点时,该节点就成为Leader节点了。

通过心跳算法可以共同检查Leader节点的健康度,如果出现问题(比如机器下线、网络分区、延迟过高等),就会考虑重新选举。

可以看出,这种选举方式相对Paxos是比较方便高效的,而且选出Leader节点后,就可以直接通过Leader节点接受消息进行广播,而不需要进行两阶段提交。

其实ZAB就很像选出了Leader的Multi-Paxos,两者的差异主要在选Leader的流程上。

(二)Raft

Raft的应用比Paxos要多,有人认为Raft是Multi-Paxos的改进,因为Raft的作者也曾研究过Paxos。既然Paxos是前辈,为什么应用的反而要少呢?这是因为Basic-Paxos相对比较耗时,而Multi-Paxos,作者并没有给出具体的实现细节,这虽然给了开发者发挥的空间,但同样可能会在实现的过程中由于开发者不同的实现方式带来不同的问题,对于一个分布式共识算法,谁也不知道潜在的问题会不会就影响到一致性了。而Raft算法给出了大量实现细节,简单说就是,实现起来更不容易出错。

Raft协议同样是需要选举出Leader的,从这里也能看到,共识算法大都会走向选举出一个Leader的方向,来提升效率和稳定性。不同之处可能只在于选举的方式,以及消息同步的方式。

Raft的选举,会在上一任Leader失去联系时发起,每个Follower便有机会成为Candidate,参与选举。之所以说有机会,是因为每个Follower都会先等一会,看是否有其他候选人过来拉票,避免人人都跑去凑热闹参与选举浪费通信,这个等待的时间是在一个范围内随机的。

候选者参与选举时会产生一个term概念,每个候选者会先投自己一票,然后带着自己的term和自己的日志信息(代表着数据的新旧)去拉票,其他的Follower先看候选者的term是否大于等于当前自己的term,再看其日志信息是否比自己新,如果都满足就会投票。候选者收到超过半数的投票的话,就会成为新的Leader了。

在这个过程中投票的Follower也会更新自己的term为自己投票的候选者的term,这样就可以拒绝低于它的term的候选者了。而候选者如果被拒绝,也会回去更新自己的term以获得支持。

选出Leader后,Leader会把自己的日志发给大家做同步,以保持大家和自己的日志是一样的,然后就进行后续的接收客户端请求的环节。

可以看到Raft和Multi-Paxos也都要选举出一个Leader节点来,不同之处在于,Raft选举的Leader节点上的日志信息是最新最全的,这一方面可以不丢失日志信息的顺序,另一方面也可以让选举过程简化(日志信息的顺序总是好比较的),而Multi-Paxos选Leader的过程偏随机,就是看谁先拉拢更多节点的支持并快速落定,这一方面会使其日志不连续,另一方面也会使得其实现变得复杂和相对不可控。

但实际上不连续也不完全是缺点,它也可以提高写入的并发性能,所以虽然Raft实现相对更简单,但微信的PaxosStore还是选择了Paxos,甚至它都没有选择Multi-Paxos,而是Basic-Paxos,就是为了进一步避免单点依赖和切换Leader时的拒绝服务,来提高可用性。

可以看到,共识算法基本都需要解决两个基本问题:

如何提出一个需要达成共识的提案(选举Leader、随机投票...)

如何让多个节点对提案达成共识(广播、复制、投票...)

在这两个问题的处理方案上选择不同,就会导致性能、可用性等指标的不同,所以其实,兵器各有利弊,还是要看使用场景和使用的人。

Ralt算法动画演示地址

Raft

参考资料:

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

工作变成艺术

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值