zookeeper分布式过程协同技术详解

一.简介

1.1 zookeeper的使命

1.1.1 zookeeper改变了什么

zookeeper简化了开发流程,提供了更加敏捷健壮的方案。zookeeper可以让开发人员于其应用本身而不是神秘的分布式概率。

1.1.2 zookeeper不适用的场景

zookeeper不适合作海量数据存储,zookeeper中实现了一组核心操作,通过这些可以实现很多常见分布式应用的任务。

1.1.3 通过zookeeper构建分布式系统

分布式系统的定义为:分布式系统是同时跨越多个物理主机,独立运行的多个软件组件所组成的系统。

分布式系统中的进程通信有两种选择:直接通过网络进行信息交换,或读写某些共享存储。zookeeper使用共享存储模型来实现应用间的协作和同步原语。对于共享存储本身,又需要在进程和存储间进行网络通信。所以网络通信是分布式并发系统中并发设计的基础。在真实系统中我们需要特别注意一下问题:

消息延迟

消息传递可能会发生任意延迟,比如网络堵塞。不同进程A,B消息先后发送,但是B先到等一些不可预期的问题。

消息延迟的时间约等于发送端消耗的时间,传输时间,接收端处理时间的总和。

处理器性能

操作系统的调度和超载也可能导致消息处理的任意延迟。

时钟偏移

是指同一个时钟域内的时钟信号到达数字电路各个部分(一般是指寄存器)所用时间的差异。

 

1.2 主从应用

分布式系统中一个很广泛的架构是:主从架构

1.2.1 常见问题

这种架构中,一般是主节点进程负责跟踪从节点状态和任务的有效性,并分配任务到从节点。要实现主-从模式的系统,我们必须解决以下三个关键的问题。

主节点崩溃

如果主节点发送错误并失效,系统将无法分配新的任务或重新分配已失败的任务。

主节点失效时,我们需要一个备份主节点来接管主节点的角色进行故障转移。新的主节点需要恢复到旧的主节点崩溃时的状态,由于不能直接从以及崩溃的主节点中获取,因此需要通过zookeeper来获取。

除了状态恢复的问题,还可能出现”脑裂“的问题。比如主节点是有效的,但是备份主节点认为主节点已经崩溃(列:主节点负载很高导致消息延迟严重,备份主节点会认为主节点已经崩溃),备份主节点将会接管成为主节点的角色。甚至还会出现一些从节点无法与主节点进行通信(比如网络分区错误导致),就会和备份主节点进行建立连接。这就是所谓的脑裂

从节点崩溃

如果从节点崩溃,已分配的任务将无法完成

客户端向主节点提交任务,主节点将任务派发给有效的从节点。从节点接受到派发的任务,执行完这些任务会向主节点报告执行状态。主节点会将执行结果返回给客户端。

 如果从节点崩溃了,所有已派发给这个从节点且尚未完成的任务需要重新派发。所以主节点需要有能检测从节点崩溃的能力,并确定那些从节点是否有效的派发崩溃节点的任务。考虑到节点崩溃时,某些任务可能已经执行了,但是没有来得及报告结果,所以需要恢复之前的执行的状态。

通信故障

如果主节点和从节点之间无法进行信息交换,从节点将无法得知新任务分配给他。

如果一个从节点与主节点网络断开(比如网络分区导致),重新分配一个任务可能会导致两个从节点执行相同的任务。如果任务允许多次执行还好,如过不允许,那么很出现很严重的数据不一致问题。

处理类似情况就需要”仅一次“和”最多一次“的语义学,而这需要特定的处理机制。例如如果应用数据使用了时间戳数据,而假定任务会修改应用数据,那么该任务的执行成功取决于这个任务所取得的这个时间戳的值,如果改变应用状态的操作不是原子操作,那么应用还需要局部变更的能力,否则最终将导致应用的非一致性。

通信故障导致的另外一个问题锁对同步原语的影响。节点可能崩溃,系统可能会网络分区,锁机制会组织任务的继续执行。因此zookeeper需要实现处理这些情况的机制。客户端可以告诉zookeeper某些数据的状态是临时状态,zookeeper需要客户端定时发送是否存活的通知,如果一个客户端没有及时发送通知,那么所有从属于这个客户端的临时状态的数据都将被删除。

1.2.2 任务总结

主从架构的需求:

(1).主节点选举

(2).崩溃检测

(3).组成员关系管理:主节点必须具体知道那些从节点有执行任务的能力

(4).元数据管理:主节点和从节点必须具有通过某种可靠的方式来保存分配状态和执行状态的能力

 

1.3 分布式协作的难点

在独立主机上运行的应用与分布式应用发生的故障存在显著的区别:在分布式应用中,可能会发生局部故障,当独立主机崩溃,这个主机上运行的所有进程都会失败。如果是独立主机上运行多个进程,一个进程失败,其他进程可以通过操作系统获取这个故障。在分布式环境中,如果一个主机或进程发生故障,其他主机继续运行,并会接管发生故障的进程,为了能够处理故障进程,这些正在运行的进程必须能够检测到这个故障,无论是发生了消息丢失或是发生了时间偏移。

分布式 领域存在一个著名的定律(FLP):在异步通信的分布式系统中,进程崩溃,所有的进程都无法在这个比特位(指修改内容最小单位)上达成一致。

同样的,CAP:表示一致性,可用性和分区容错性。设计一个分布式系统时时没办法同时满足这三种特性的。因此zookeeper的设计尽可能满足一致性和可用性,在发生网络分区的时候zookeeper提供了只读能力。

 

二.了解zookeeper

2.1 zookeeper基础

zookeeper并不直接暴露自己源语,而是像菜谱一样暴露自己的一小部分api。菜谱包括zookeeper操作和维护的一个小型的数据节点,这些节点被称为znode,采用类似文件系统的层级结构。

如果一个znode没有数据信息,在主从模式中表示为还没有选择出主节点

2.1.1 znode的不同类型

创建节点的时候需要制定节点类型,不同的节点类型决定了znode的行为方式。

持久节点和临时节点

持久znode只能通过delete命令删除,而临时znode是在当创建该节点的客户端崩溃或者关闭了与zookeeper的连接时,这个节点就会被删除。

持久znode可以为应用保存一些数据,即使znode的创建者不再属于系统,这些数据也可以保存下来不丢失。

临时znode传达了应用某些方面的信息,在创建者的会话有效时这些信息必须有效保存。列如,主从模式中,主节点创建了一个临时的znode,如果主znode消失了,该临时的znode节点仍然存在,那么系统将无法检测到主节点崩溃,因此这个临时的znode需要和主节点一起消失。

一个znode,在以下两种情况会被删除:

1.创建该node的客户端的会话因为超时或者主动关闭而终止时。

2.当某个 客户端主动删除该节点时。

另外,临时节点是不允许拥有子节点的。

有序节点

一个有序节点在创建的时候会在路径后面追加一个单调递增的整数,比如task/task_1

所以节点类型被分为持久的有序,临时的有序,持久的,临时的。

 

2.1.2 监视与通知

如果客户端基于轮询的获取任务,那么这样资源的开销会很大。所以zookeeper选择了基于通知的机制,客户端向zookeeper注册需要接受通知的znode,通过对znode设置监视点来接受通知。监视点只会触发一次通知,收到通知后需要继续设置新的监视点。为了避免在设置新的监视点前有新的任务添加造成任务监听遗失,所以每次添加监视点前需要进行一次任务获取操作。

通知机制的一个重要保障是,对同一个node操作,先向客户端传送通知,然后再对该节点进行变更。如果出现两次连续的变更,并且客户端收到了第二次变更前的通知,然后读取znode,那么就会出现问题。后面会讨论状态的问题。

2.1.3版本

每一个znode都有一个版本号,它随着每次数据变化而自增。他可以是数据的修改和删除有条件的执行,从而阻止并行操作造成的不一致问题。

2.2 zookeeper架构

zookeeper服务器端运行与两种模式之下:独立模式和仲裁模式。

独立模式:一个单独的服务器,zookeeper状态无法复制

仲裁模式:一组(多个)zookeeper服务器,也就是zookeeper集群,他们之间可以进行状态复制。

 

2.2.1 zookeeper仲裁

仲裁模式下,zookeeper复制集群中所有服务器的数据树。但是如果一个客户端等待每个服务器完成数据保存后再继续,延迟问题将无法接收。为此zookeeper设置了一个保证zookeeper集群有效运行的最小服务器数量。这个数字也是服务器告知客户端安全保存数据前,需要保存客户端数据服务的最小个数。比如5个服务器组成的集群,那么最小个数为3。为什么最小个数是3?

假设最小个数为2,服务名称分别为s1,s2。s1,s2完成了数据保存,这个时候s1,s2与其他服务器和客户端发生长时间的分区隔离,整个服务状态仍然正常,但是现在还有三个服务器,这三个服务将无法发现新的znode。因此创建节点的请求将是非持久化的。这将会出现上面的脑裂现象。要保证集合可以正常工作,对于任何更新操作的成功完成,我们至少要有一个有效的服务器来保存更新的副本。

为了更好的选择最小个数,服务器的个数最好为奇数。

 

2.2.2 会话

在对zookeeper集合执行任何请求前,一个客户端必须先与服务建立会话。客户端提交给zookeeper的所有操作均关联在一个会话上。当一个会话因为某种原因终止时,这个会话期间创建的节点将会消失。

客户端初始通过tcp协议连接到集群中某一个服务器上进行通信,当会话无法与当前服务器进行通信时,会话就可能转移到另外一个服务器上。

会话提供了顺序保障,这意味着同一会话的请求会以FIFO的顺序执行,但是如果客户端拥有多个并发的会话,这个顺序未必能保持。

 

2.2.3 会话的状态和声明周期

会话的生命周期是指会话从创建到结束的时期,无论是正常关闭还是因为超时导致的过期。

一个会话从not_connected开始,当zookeeper客户端初始化后转换到,成功与zookeeper服务器建立连接后,会话转到connected状态,如果服务器断开或者无法收到服务器响应会变为connecticonnectingng状态并尝试与其他服务器连接或者与当前服务器重连,有效后变成connected状态,否则变成closed状态

值得注意的是,如果因为网络分区导致客户端与zookeeper集合被隔离而发生连接断开,那么就会一直保持connected状态,直到显示的关闭这个会话,或者分区修复后,客户端能获取zookeeper服务器发送的会话已过期。

所以创建一个会话时,需要设置会话超时这个参数,如果经过实践t之后,服务器接收不到这个会话的任何消息,服务就会声明会话过期。在客户端侧,如果经过t/3的时间未收到任何消息,客户端将向服务器发送心跳消息。在经过t/3的时间开始寻找其他服务器,因此他还剩t/3去寻找服务器。

独立模式下回进行重连,冲裁模式下需要传递可用服务器列表给客户端,告知客户端可以连接的服务信息,并选择一个进行连接。这个服务器的zookeeper状态要与最后连接的服务器的zookeeper状态保持最新。所以客户端不能连接重连前状态相同或者低的服务器,因为这样可能会出现重复操作。

2.2.4 通过zookeeper实现锁

为了获得一个锁,每个进程p尝试创建一个znode,名为lock。如果进程p成功创建了znode,那么他就可以继续执行临界区域的代码。不过一个潜在问题就是进程p可能崩溃,导致这个锁永远无法释放。从而出现死锁,所以得在创建这个节点时指定lock为临时节点。如果线程p崩溃,lock也会消失。这样其他进程就可以进行进程上述操作。

 

三.处理状态变化

3.1  单次触发器

一个监视点表示一个与之关联的znode节点和事件类型组成的单次触发器,当一个监视点被一个事件触发时,就会产生一个通知。通知是注册了监视点的应用客户端收到的事件报告的消息。一个监视点只会触发一次通知,要想获得下次通知必须重新设置监视点。

客户端设置的监视点与会话关联,如果会话过期,等待中的监视点就会被删除。监视点可以跨越不同服务器连接来保持(比如断线重连期间找到其他服务器),这个时候客户端会向服务器发送未被触发的监视点,服务端会检查已监视的节点在之前注册监视点之后是否有发生变化,如果znode已经发生变化,一个监视点的事件就会被发送给客户端,否则在新的服务端上注册监视点。

3.2 主从模式下管理权的变化

前面提到过,应用客户端通过创建/master节点来推选自己为主节点,如果znode节点已经存在,应用客户端确认自己不是主要节点并返回,然而这种不能容忍主节点崩溃。

如果主节点崩溃,而备份主节点不知道,因此我们需要在master上设置监视点,在节点删除时zookeeper会通知客户端。

1.在连接丢失事件发生的情况下,客户端检查/master节点是否存在,因为客户端并不知道是否能创建这个节点。

2.如果返回ok,那么就行驶主导权

3.其他客户端已经创建了这个znode节点,客户端需要监视该节点。(就是过程5了)

4.如果发生了某些错误情况,就会记录日志,不做任何操作

5.通过exists调用在master节点上设置监视点

6.如果/master节点删除了,就再次竞选主节点

在发生连接丢失的事件时,需要在master节点上设置监视点。设置监视点返回的结果可能是改znode已经被删除了,因为无法知道znode节点是在设置监视点之前删除,所以客户端需要再次竞选主节点,如果竞选失败,就可以知道有其他客户端成功了,之后客户端需要再次为主节点添加监视点,如果收到的是主节点创建成功的通知,那么就不在竞争主节点,否则重复上述操作。

3.3 主节点等待从节点的变化

系统中任何时候都可能发生新的从节点加入进来,或旧的从节点退役情况,从节点执行分配给他的任务前也许会崩溃。为了确认某个时间点可用的从节点信息,需要在zookeeper中的/workers下添加子节点来注册新的从节点。

3.4 主节点等待新任务进行分配

主要主节点等待添加到/tasks节点中的新任务。主节点首先获取当前的任务集,并设置变化情况的监视点。在zookeeper中。/task的子节点表示任务集,每个子节点对应一个任务,一旦主节点获得还未分配的任务信息,主节点会随机选择一个从节点,将这个任务分配给这个从节点。

对于新任务,主节点选择一个从节点分配任务之后,主节点就会在/assign/word_id节点下创建一个新的znode节点,其中id为从节点标识符,之后主节点从任务列表中删除该节点任务。这种方式简化了主节点角色接收新任务并分配的设计,如果任务列表中混合的已分配和未分配的任务,主节点还需要区分这些任务。

3.4 从节点等待分配新任务

首先从节点需要在zookeeper中注册自己,即在/workers节点下创建一个子节点,添加该znode节点会通知主节点这个节点的状态是活跃的,且已准备好处理任务,同时主节点也会创建一个,这样主节点为这个assign/work_id节点从节点分配任务。但是如果在创建assign/work_id节点之前创建了workers/work_id,可能会出现因为分配任务的父节点还没创建,导致主节点分配失败。所以要先创建assign/work_id节点,而且从节点需要在assign/work_id节点上设置监视点来接收新任务分配的通知。

一旦有任务列表分配给从节点,从节点就会从/assign/worker_id获取任务信息并执行任务。从节点从本地列表中获取每个任务的信息并验证任务是否还在执行的队列中,从节点保存一个本地待执行任务的列表就是为了这个目的。为了释放回调方法的线程,在单独的线程对已经分配任务进行循环,保证不会阻塞其他回调方法的执行,将正在执行的任务添加到执行中列表,防止多次执行。

3.5 客户端等待任务的执行结果

假设应用客户端已经提交了一个任务,现在客户端需要知道该任务何时被执行,以及任务状态。从节点执行任务时,会在/status下创建一个znode节点。我们检查状态节点是否已经存在,并设置监视点。然后提供一个收到znode节点创建的通知时进行处理监视点的实现和一个exists方法的回调,客户端获取到该节点的上下文信息,从而知道任务的执行状态。

3.6 另一种调用方式:multiop

multiop可以原子性的执行多个操作,比如删除一个父节点和一个子节点。

简化了主从模式的实现:比如主节点会创建任务分配节点,然后删除/task下对应的任务节点。如果在删除/task下的任务节点时,主节点崩溃,就会导致一个已分配的 任务还在/task下。使用multiop的原子性就不会出现这种状况。

multiop的另外一个功能就是检查这个znode节点的版本,主节点要求客户端向/task-mid的子节点添加任务节点,其中mid为主节点的标识符,主节点会在master-path路径下保存数据,客户端再添加新任务前,需要读取master-path下的数据,获取这个节点的版本信息然后通过multiop的部分调用方式在/task-mid节点下添加任务,同时会检查/master-path下的版本号与之前是否匹配,不匹配就会调用失败。

3.7 通过监视点代替显示缓存管理

从应用的角度来坎,客户端每次都是通过访问zookeeper来获取给定znode节点,子节点列表或者其他相关状态,这种方式并不可取。因此就有了客户端本地缓存数据,客户端通过向zookeeper注册监视点,如果发生数据变动,zookeeper就通知客户端更新缓存数据。

3.8 顺序的保障

3.8.1 写操作的顺序

zookeeper的状态会在所有的服务中进行复制,同时使用相同顺序。比如一个zookeeper服务器创建一个A节点,然后删除这个节点。其他服务器也会执行相同顺序的操作,但是所有的服务端不需要同时执行这些操作,事实上也很少。因为地域,网络等不同情况都可能造成服务器不同时的问题。

3.8.2 读操作顺序

zookeeper客户端总是会观察到相同的更新顺序,即使他们连接到不同的服务端上,但是客户端可能在不同时间上观察到更新。

隐藏通道:c1客户端更新了节点的数据并收到了应答,同时c1通过tcp告知c2节点状态发生了变化,但是c2读取到这个状态可能是在c1更新前的那个状态。

为了避免读取到过去的数据,我们建议应用程序使用zookeeper进行所有涉及状态的通信。比如设置监视点。

3.8.3 通知的顺序

zookeeper对通知的排序及涉及到其他通知和异步响应,以及对系统状态更新的顺序。比如zookeeper对a和b节点先后进行了修改操作,客户端c1也会顺序的读到a和b节点的修改通知

这种顺序可以使应用安全的实现参数配置。假设一个znode节点被创建或者删除表示zookeeper中的一些配置信息变为无效,那么在配置进行某些实际更新前创建和删除的通知发给客户端,客户端给节点添加监听,当那些配置信息都更新完成后触发监听事件,这样客户端就不会读到任何无效的配置。

3.9 监视点的羊群效应和可扩展性

当变化发生时,zookeeper会触发一个特定的znode节点的变化导致的所有监视点的集合。如有有1000个监视点,那么就可能会造成性能问题,比如尖峰时刻提交的操作异常。所以可能的话,在一个节点上最多设置一个监视点。

上述问题,以下解决方式可以借鉴。让客户端创建一个有序的节点(就是上面提到过的有序节点)。比如/lock/lock_xx.我们可以通着这个序号来确定那个客户端来获取锁,然后根据序号确定序列,并在前一个节点上设置监视点。

 

4.故障处理

故障发生的主要点有三个:zookeeper服务,网络,应用程序。

4.1 可恢复的故障

当一个客户端从zookeeper获得相应时,客户端可以非常肯定这个响应信息与其他响应信息或其他客户端所接受的响应均保持一致。但是有时候,zookeeper服务端与客户端连接会丢失,这个时候就无法提供一致性保障信息。当客户端发现自己处于这种情况时,就会使用disconnected事件和connectionLossException异常来表示自己无法了解当前系统状态。

上述异常是必将常见从情况。比如有两个zookeeper服务器s1,s2。其中一个客户端连接到s2,如果s2发生发生故障。那么客户单就会收到disconnected事件,所有进行中的同步请求都会返回connectionLossException异常,异步请求会返回connectionLoss返回码。但是这个是有zookeeper服务依然正常,因为大多数服务器仍然处于活跃状态,所以客户端会迅速重写建立连接。

在发生故障的过程中,如果没有正在进行的请求,那么重连之后客户端并不会注意到变化。如果这个过程中有正在进行的请求,那么连接丢失就会产生很大的影响。因为所有正在请求的虽然会返回异常和状态码,但是客户端并不知道请求是否已经被处理。如果用程序判读请求是否完成将会使代码更加复杂,因为应用程序代码必须判断请求是否已经完成。所以最简单的方法就是在收到connectionLossException异常和connectionLoss返回码的时候,客户端停止所有的工作,并重新启动。(没明白重启有什么用)(重启后,如果重连成功,那么之前所有的请求会重新请求)

但是这样可能会因为一个小影响造成重大的系统时间。比如现有90个zookeeper客户端连接三个zookeeper服务器,如果其中有一个zookeeper服务器挂了,那么就意味着大约有30个zookeeper客户端要面临重启。甚至还有可能在客户端进程还没与zookeeper连接时就关闭可这些会话,造成这些会话无法被显示的关闭,只有通过zookeeper的会话超时机制才能关闭。然后由于新的进程必须等待之前旧的会话过期才能获得锁,这样进而会造成大量应用进程的重启被延迟。所以如果正确的处理连接丢失的话,这种情况只会产生很小的系统损坏。

当一个进程失去连接后就无法收到zookeeper更新通知,这个进程可能会在会话丢失时错过某些重要的状态变化。比如群首选举,假设客户端c1为群首,在t2时刻失去了连接,会话在t2时刻过期,但是客户端并没有发现这个情况,直到t4时刻才声明终止状态,t3时刻另外一个服务器通过选举成为群首,从t2到t4旧的群首并不知道它自己被声明为终止状态,此时旧的群首可能会进行操作与新的群首产生冲突。因此在一个进程收到disconnected事件时,在重新连接之前,进程需要挂起群首的操作。

已存在的监视点与disconnected事件

为了使连接断开与重现建立会话之间更加平滑,zookeeper客户端会在新的服务器上重新建立所有已经存在的监视点。当客户端连接zookeeper服务器,客户端会发送监视点列表和最后已知的zxid(最终状态事件戳),服务器会接受这些监视点并检查znode节点的修改时间戳这与些监视点是否对应,如果任何已经监视的znode节点的修改时间戳与这些监视点是否对应,如果任何已经监视的znode节点的修改时间戳晚于最后已知的zxid,服务器就会触发这个监视点。

但是exists可以在一个不存在的节点上设置监视点,这样就会存在一种错过监视点的情况。比如客户端监视了/event节点的创建事件,然而就在/event被另外一个客户端创建时,设置了监视节点的客户端与zookeeper间失去了连接。在这端时间,其他客户端删除了/event,因此当设置了监视点的客户端重新连接并注册监视点,此时/znode节点已经不存在了,当已经注册了监视点判断/event监视时,发现没有/event这个节点,所以就只注册了这个监视点,从而错过了/event的创建事件。所以要尽量避免监视一个znode节点的创建事件,或者存活周期较长的znode节点。

4.2 不可恢复故障

当会话过期或者已认证的会话无法再次与zookeeper完成认证就会发生不可恢复故障。这两种情况下,zookeeper都会丢弃会话的状态。处理不可恢复故障最简单的方法就是终止进程并重启,这样可以使进程恢复原状,通过一个新的会话重新初始化自己的状态。如果该进程继续工作,首先必须要清楚与旧会话关联的应用内部的进程状态信息,然后初始化新的状态。

4.3 群首选举和外部资源

当运行的客户端进程的主机发生过载,就会开始发生交换,系统颠簸或因已经超负荷的主机资源的竞争而导致的进程延迟,这些都会影响与zookeeper交互的及时性。下面例子中,应用进程通过使用zookeeper来确保每次只有一个主节点可以访问一个外部资源,这是一个很普遍的资源中心化管理方法,用来确保一致性。进程的验证可能会导致zookeeper服务端认为客户端c1会话已经超时,zookeeper服务端会删除与超时客户端会话关联的所有节点,包括成为主节点而创建的临时节点,这时其他客户端参与群首的选举,成为主节点改变外部资源的状态,然而之后当c1负载下降,并发送已经队列化的更新到外部资源上。从而导致了系统状态破坏。

解决这个问题的一种方法就是扩展对外部设备协作的数据,使用一种名外隔离的技巧,分布式系统中常常使用这种方法用于确保资源的独占访问。

在创建代表群首的节点时,我们可以获得state结构的信息,其中该结构中的成员之一,czxid,表示创建该节点的zxid,zxid是唯一的单调递增的序列号,因此用czxid使用一个隔离符号。当我们对外部资源进行请求时需要提供这个隔离符号,如果外部资源已经接受到更高版本的隔离符号请求,我们的请求就会被拒绝。

5.zookeeper注意事项

5.1 使用ACL(访问控制表)

5.2 恢复会话

假如zookeeper客户端崩溃,之后恢复运行,应用程序在回复运行后需要处理一系列问题。首先,应用程序的zookeeper状态还处于客户端崩溃时的状态,其他客户端进程还在继续运行,也许已经修改了zookeeper的状态,因此,建议客户端不要使用之前从zookeeper获取的缓存状态,而是使用zookeeper作为协议状态的可靠来源。

还有就是客户端崩溃时,已经提交给zookeeper的待处理操作也许已经完成了,由于客户单崩溃导致无法收到确认消息,zookeeper无法保证这些操作肯定会执行成功,因此客户端在恢复时需要进行一些zookeeper状态的清理操作,以便完成某些未完成的任务。列如主节点在崩溃前进行了一个已分配任务的列表的删除操作,在恢复并再次成为主要节点时,就需要再次删除任务。

5.3 当znode节点重新创建时,重置版本号

znode节点被删除并重建后,其版本号将会被重置。如果应用程序在一个znode节点重建后,进行版本号检查会导致错误的发生。比如在一个znode节点的删除,重建的过程中,进行了多次更新操作,setData最终修改数据是徒劳的。这种情况下检查版本号并不能提供znode节点的变化情况,znode节点也许会被更新任意次,但其版本号任然为0.


5.4 sync方法

如果应用客户端只对zookeeper的读写来通信,应用程序就不考虑sync方法。sync方法的设计初衷,是因为zookeeper的带外通信可能导致某些问题,这种通信常常称为隐蔽通道。主题主要是源于一个客户端c通过某些直接通道与另外一个客户单c2进行zookeeper的状态变化,但是c2读取zookeeper的状态时,并未发现变化情况。

这一场景发生的原因,可能因为这个客户端所连接的服务器还没来得及处理变化情况,而sync方法可以用于处理这种情况。当服务端处理sync调用时,服务端会刷新群首与调用sync操作的客户端c锁连接的服务端之间的通道,也就是在调用getdata的返回数据的时候,服务端确保返回所有客户端c调用sync方法时所有可能的变化情况。

5.5 顺序性保障

5.5.1 连接丢失的顺序性

对于连接丢失事件,zookeeper会取消等待中的请求,对于同步方法调用客户端库会抛出异常,对于异步请求调用,客户端调用的回调函数会返回结果码来标识丢失。在应用程序连接丢失后,客户端库不会再次重新提交请求,因此就需要程序对已经取消掉的请求进行重新提交的操作。

应用程序按顺序提交了op1和op2请求,由于op1提交请求过程中,客户端检测到与服务端的连接丢失,取消op1的操作,客户端后来在会话过期重新前重新连接,应用程序提交请求,执行op2操作,op2执行成功,op1返回connectionloss事件,应用程序重新提交op1请求。(假设客户端还是没重新连接成功,那么请求会再次重新提交,陷入死循环,为了跳出循环,可以设置重试次数)最终op2确先于op1成功执行,但是某些情况下op1先于op2执行成功有重要意义。所以可以在代码中设置op1请求成功后才能请求op2,但是这可能造成性能损失。

如果我们摆脱connectionloss,那么客户端可以询问服务端来确认请求是否成功执行。服务端可以通过内存或日志缓存的信息记录,知道自己处理了那些请求。

5.5.2 同步api和多线程的顺序性

如果在多线程环境中使用同步api,你需要特别注意顺序性问题。一个同步zookeeper调用会阻塞运行,直到收到相应信息,如果两个或更多线程同时提交了同步操作,这些线程中将会被阻塞,直到收到相应信息,zookeeper会按顺序返回响应信息,但操作结果可能因线程调度等原因导致后提交的操作而先被执行。

如果不同线程同时提交了多个操作请求,且这些操作具有相关性,客户端应用程序在处理结果时需要注意这些操作的提交顺序。

5.5.3 同步和异步混合调用的顺序性

还有另外一种混乱的情况。假如你通过异步操作提交了两个请求,aop1和aop2,在aop1的回调函数中,进行了一个同步调用,sop1,该调用阻塞了zookeeper客户端的分发线程,这样就导致了客户端应用程序接收了sop1的结果之后才能接着收到aop2的操作结果。因此操作结果顺序就是aop1,sop1,aop2,然而实际的提交顺序并未如此。所以混合调用并不是好方法,如果必要的话可以使用java锁或其他某些机制处理,采用一个或多个不同调用可以完成这项任务。

5.6 数据字段和子节点的限制

zookeeper;默认情况下对数据字段的传输限制为1MB,该限制为任何节点数据字段的最大可存储字节数,同时也限制了任何父节点可以拥有的子节点数。设置限制值可以保障高性能,如果一个znode节点可以存储很大的数据,就会在处理时消耗更多时间,甚至在处理请求时导致管道的停滞。如果一个客户端在一个拥有大量子节点的znode节点上执行getchildren操作,也会导致同样的问题。

zookeeper对数据字段的大小和子节点的数量的默认的限制值已经足够大,因此需要避免接近该限制值的使用。

 

6. zookeeper内部原理

6.1请求,事物,标识符

zookeeper会在本地处理只读请求(exist,getdata和getchildren),zookeeper在处理以只读请求为主要负载是,性能会很高。可以增加更多的服务器到zookeeper集群中,这样流可以处理更多请求,大幅提高整体处理能力。

那些会改变zookeeper状态的客户端请求将会被转发给群首,群首执行相应的请求,并形成状态的更新,我们称之为事物。

假如客户端提交了一个对/z节点的setdata请求,setData将会改变znode节点数据信息,并会增加该节点的版本号。当处理该事务时,服务端将会用事务中的数据信息来替换/z节点中原来的数据信息,并会用事务总的版本号更新该节点,而不是增加版本号的值。

一个事务为一个单位,也就是说所有的变更处理需要以原子的方式执行。zookeeper集群以事物的方式运行,并确保所有的变更操作以原子方式被执行,同时不被其它事物干扰。在zookeeper中并不存在传统的的关系数据库中所涉及的回归机制,而是确保事物的每一步操作都互不干扰。

同一个事务也具有幂等性,也就是我们对同一个事物执行多次,我们会得到同样的结果。我们甚至可以对多个事务执行多次,结果也会是一样的,前提是确保多个事务的执行顺序是一样的。事物的幂等性可以让我们进行恢复处理时变得更加简单。

当群首产生一个任务,就会为该事物分配一个标识符,我们称之为zookeeper会话id,通过zxid对事物进行标识,就可以按照群首所指定的顺序在各个服务器中按序执行。服务器之间在进行新的群首选举时也会交换zxid信息,这样就可以知道那个无故障服务器接收了更多的事物,并可以同步他们之间的信息。

zxid为一个long型的整数,分为两部分:时间戳部分和计数器部分。每个部分为32位。

6.2 群首选举

群首为集群中的服务器选择出来的一个服务器,并会一直被集群锁认可。设置群首的目的是为了对客户端锁发起的zookeeper状态变更请求进行排序,包括create,setData和delete操作。群首将每一个请求转换为一个事务,将这些发送给追随者,确保集群按照群首确定的顺序并处理这些事务。

选举并支持一个群首的集群服务器数量必须至少存在一个服务器进程的交叉,我们使用属于仲裁来表示这样一个进程的子集,仲裁模式要求服务器之间两两相交。

每个服务器启动后进入looking状态,开始选举一个新的群首或者查找已经存在的群首,如果群首已经存在,其他服务器就会通知这个新启动的服务器,告知那个服务器时群首,与此同时,新的服务器会与群首建立连接确保自己的状态与群首一致。

如果集群中所有的服务器均处于looking状态,这些服务器之间就会进行通信来选举一个群首,通过信息交换对群首选举达成共识的选择。在本次胜出的服务器将进入leading状态,而集群中其他服务器将会进入following状态。

对于群首选举的消息,我们称之为群首通知消息,或者简称为通知。该协议非常简单,当一个服务器进入looking状态,就会向集群中每一个服务器发送一个通知消息,该消息中包括该服务器的投票信息,投票中包含服务器标识符(sid)和最近执行的事务的zxid信息。

当一个服务器收到一个投票信息,该服务器将会根据以下规则修改自己的投票信息:

1.将接收到的voteid和votezxid作为一个标识符,并获取接收方当前的投票中的zxid,用mysid和myzxid表示接收方服务器自己的值。

2.如果(votezxid>myzxid)或者(votezxid = myzxid 且 votezxid>myzxid),保留当前的投票信息

3.否则,修改自己的投票信息,将votezxid赋值给myzxid,将voteid赋值给mysid。

简而言之,只有最新的服务器将赢得选举,因为其拥有最近一次的zxid。如果多个服务器拥有最新的zxid值,其中的sid值最大的将赢得选举。

值得注意的是,我们并未保证追随者必然会成功连接上被选举的群首服务器,比如,被选举的群首也许在此时崩溃了。一旦连接成功,追随者和群首之间将会进行状态同步,在同步完成后,追随者才可以处理新的请求。

并不是所有执行过程都和上图一样,在下图中,服务器s2做出了错误的判断,选举另一个服务器s3而不是服务器s1,虽然s1的zxid值跟高,但从s1向s2传送消息时发生了网络故障导致长时间延迟,与此同时,服务器s2选择了服务器s3作为群首,最终服务器s1和服务器s3组成了仲裁数量,并将忽略服务器s2

虽然服务器s2选择了另一个群首,但并未导致整个服务器发生错误,因为服务器s3并不会以群首角色相应服务器s2的请求,最终服务器s2将会在等待被选举的群首s3的相应而超时,并开始再次重试。

再次尝试,意味着这段时间内,服务器s2无法处理任何客户单的请求,这样做并不可取。这个例子中可以发现如果让服务器s2在群首选举是多等待一会,它就能做出正确判断。但通过下图的这种情况,我们很难确定服务器需要等待多场时间,现实的实现中默认是200ms,这个值比在当今数据中心所预计的长消息延迟要长的多,但恢复时间相比还不够长。万一此类延迟时间并不是很长,一个或多个服务器最终将错误选举一个群首,从而导致该群首没有足够的追求者,那么服务器将不得不再次进行群首选举。错误的选举一个群首可能导致整个回复时间更长,因为服务器将会连接已经不必要的同步操作,并需要发送更多消息来进行另一轮的群首选举。

6.3 状态更新的广播协议

在接收一个请求操作后,追随者会将请求转发给群首,群首将探索性的执行该请求,并将执行结果以事务的方式对状态进行广播。一个事务中包含服务器需要执行变更的确切操作,当事务提交时,服务器就会将这些变更反馈到数据数上,其中数据树为zookeeper用于保存状态信息的数据结构。

服务器通过原子广播协议(zab)来确认一个事务是否已经提交。该协议提交一个事务非常简单,类似于一个两阶段提交。

1.群首向所有追随者发送一个proposal消息p。

2.当一个追随者接收到消息p后,会响应群首一个ack消息,通知群首已经接受该提案(proposal)

3.当收到仲裁数量的服务器发送的确认消息后(该仲裁数包括群首自己),群首会发送消息通知追随者进行提交操作。

在应答消息之前,追随者还需要执行一些检查操作。追随者将会检查所发送的提案消息是否其所追随的群首,并确认群首所广播的提案消息和提交事务消失的顺序正确。

zab保证了以下几个重要属性:

1.如果群首按顺序广播了事务T1和事务T2,那么每个服务器在提交T2事务前保证事务T1已经提交完成。

2.如果服务器按照事务T1和事务T2的顺序提交事务,所有其他服务器也必然会在提交事务T前提交事务T.。

这两个属性保证了事务在服务器之间传递的顺序一致和服务器不会跳过任何事务执行。如果后一个事务依赖前一个事务的状态,那么跳过执行就会影响一致性。zab在仲裁服务器中记录了事务,集群中仲裁数量的服务器需要在群首提交事务前对事务达成一致,而追随者也会在硬盘中记录事物的确认信息。

只要仲裁条件达成并选举了一个新的群首,zookeeper都可以将所有的服务器状态更新到最新。

zookeeper自始至终并不是总是同一个服务器为获得的群首,因为群首服务器也可能会崩溃,或短时间的失去连接,此时服务器需要短时间的选举一个新的群首来保证系统整体仍然可用。其中时间戳的概念代表了管理权随时间的变化情况,一个时间戳表示了某个服务器行驶管理权的这段时间内,群首会广播提案消息,并根据计数器识别每一个消息。

时间戳的值会在每次新的群首选举发生的时候便会增加。同一个服务器成为群首后可能持有不同的时间戳信息,但是从协议的角度出发,一个服务器行驶管理权,如果持有不同的时间戳,该服务器会被认为是不同的群首。

在仲裁模式下,记录以及接受的提案消息可以确保某个或者多个服务事务被所有的服务器最终提交,即使群首此时发生了故障。

实现广播协议所遇到最多的困难在于群首并发存在的情况出现,这种情况并不一定是脑裂场景。多个并发的群首可能会导致服务器提交事务的顺序发生错误,或者直接跳过某些事务。为了阻止系统中同时出现两个服务器自认为自己是群首的情况时非常困难的,时间问题或消息丢失都可能导致这种情况。

为了解决上述问题,zab协议提供了以下保障:

1.一个被选举的群首确保在提交完所有之前的时间戳内需要提交的事务,之后才开始广播新的事务。

2.任何时间点,都不会出现两个被仲裁支持的群首。

为了实现第一个保障,群首并不会马上处于活跃状态,直到确保仲裁数量的服务器认可这个群首新的时间戳值。一个时间戳的最初状态必须包含所有的之前已经提交的事务,或者某些已经被其他服务器接收,但尚未提交完成的事务。有了这个信息就能实现第一个保障。

第二个保障有点棘手,因为我们并不能完全阻止两个群首完全的独立运行。加入有一个群首l1管理并广播事务,在此时仲裁数量的服务器q判断群首l1已经退出,比开始选举一个新的群首l2,假设在仲裁服务器放弃群首l1时有一个事务T正则广播,仲裁服务器的严格自己也记录了这个事务T,为事务T形成了一个仲裁数量,在这种情况下,事务T在群首l2被选举后会进行提交。zab协议保证T作为事务的一部分被群首l2提交,确保群首l2的仲裁数量的支持者中至少有一个追随者确认了该事务T,其中的关键点在于群首l2与l1在同一时刻并未获得足够的仲裁数量的支持者。

在新的群首l2生效前,它必须学习旧的仲裁服务器之前接收的所有提议,并且保证这些服务器不会继续接受来自旧群首的提议。如果群首l1还能继续提交提议,那么这条提议必须已经被一个以上的认可了新群首的仲裁数量服务器所接受。因为仲裁数量必须在一台以上的服务器上有所重叠,这样群首l2和群首l1使用的仲裁署数量必定在一台以上的服务器上是一致的。因此l2将加入自身的状态传播给其追随者。

在群首选举时,我们选择zxid最大的服务器作为群首。这使得zookeeper不需要将提议从追随者传到群首,只需要将状态从群首传播到追随者。假设有一个追随者接受了一条群首没有接受的提议,群首必须确保在和其他追随者同步之前已经收到并接受了这条提议。(由追随者将提议交给群首,然后由群首给其他追随者)

如果追随者滞后于群首不多,群首只需要发送缺失的事物点,因为追随者按照严格的顺序接受事物点吗,这些确实的事物点永远是最近的。这种更新在代码中被称为differ。如果追随者滞后很久,zookeeper将发送的代码中被称为snap的完成快照。

6.4 观察者

观察者与追随者之间有一些共同点,他们提交来自群首的提议。不同于追随者的是,观察者不参与选举过程。他们仅仅学习由inform消息提交的提议。

引入观察者的一个主要原因是提高读请求的可扩展性。通过加入多个观察者,我们可以在不牺牲写操作的吞吐率的前提下服务更多的读操作。(写操作的吞吐率取决于仲裁数量的大小)

采用观察者的另外一个原因是跨多个数据中心的部署,由于数据中心之间网络连接延迟,将服务器分散于多个数据中心将明显降低系统的速度。引入观察者后,更新请求能够先以高吞吐率的低延迟的方式在一个数据中心内执行,接下来再传播到异地的其他数据中心得到执行,但是观察者不能消除数据中心之间的网络消息,因为观察者必须转发更新请求给群首并且处理inform消息。当参与的服务器处于同一数据中心的时候,观察者保证提交更新必须的消息在数据中心内部得到交换。

6.5 服务器的构成

群首、追随者和观察者根本上都是服务器。我们在实现服务器时使用的主要抽象概念是请求处理器。请求处理器是对处理流水线上不同阶段的抽象。每一个服务器实现了一个请求处理器的序列。我们可以把一一个处理器想象成添加到请求处理的一个元素。条 请求经过服务器流水线上所有处理器的处理后被称为得到完全处理。

当一个处理器有一条请求需要下一个处理器进行处理时,他将这条请求加入队列。它将处于等待状态,直到下一处理器处理完此消息。

6.5.1 独立服务器

preRequestProcessor接受客户端的请求并执行这个请求,处理结果则生成一个事务。事务信息将会以头部记录和事务记录的方法添加到request对象中。但是,只有改变zookeeper状态的操作才会产生事务,对于读操作并不会产生任何事务。对于读请求的request对象中,事务的成员属性的引用值则为null。

syncReuqestProcessor负责将事务持久化到磁盘上(就是将事务数据按顺序追加到事务日志中),并生成快照数据。

如果request对象包含事务数据,finalReuqestRrocesseor负责接受对zookeeper数据数的修改,否则该处理器会从数据树中读取数据并返回给客户端。

6.5.2 群首服务器

proposalRequestProcessor,该处理器会准备一个提议,并将该提议发送给追随者,并且会把所有commitRequestProcessor,对于写操作的请求,还会将请求转发给syncRequestProcessor处理器。SyncRequestProcessor处理器所执行的操作与独立服务器中的一样,即持久化事务到磁盘上。执行完之后会触发AckRequestProcessor处理器,这个处理器是一一个简单请求处理器,它仅仅生成确认消息并返回给自己。我们之前曾提到过,在仲裁模式下,群首需要收到每个服务器的确认消息,也包括群首自己,而AceqrspPocesor处理器就负责这个。

ComitRequestrocessor会将收到足够多的确认消息的提议进行提交。实际上,确认消息是由Leader类处理的(Leader.processAck ()方法),这个方法会将提交的请求加入到CommitRequestProcessor类中的一个队列中。这个队列会由请求处理器线程
进行处理。
FinalRequestProcessor处理器它的作用与独立服务器一样。在FinalRequestProcessor处理 器之前还有一个简单的请求处理器,这个处理器会从提议列表中删除那些待接受的提议,这个处理器的名字叫ToBeAppliedRequestProcessor。待接受请求列表包括那些已经被仲裁法定人数所确认的请求,并等待被执行。群首使用这个列表与追随者之间进行同步,并将收到确认消息的请求加入到这个列表中。之后ToBeAppliedRequestrocessor处理器就会在FinalRequestProcessor处理器执行后删除这个列表中的元素。注意,只有更新请求才会加入到待接受请求列表中,然后由TopBeApledetuststoressor处理器从该列表移除。ToBeAppliedRequestProcessor处理器并不会对读取请求进行任何额外的处理操作。而是FinalRequestProcessor处理器进行操作。

 

FollowerRequestrocessor处理器接收并处理客户端请求,之后转发请求给CommitRequestProcessor,同时也会转发写请求到群首服务器。CommitRequestProcessor会直接转发读取请求到FinalRequestProcessor处理器,而且对于写请求CommitRequestProcessor 在转发给FinalRequestProcessor处理器之前会等待提交事务。这个时候群首会直接地或通过其他追随者服务器来生成一个提议,之后转发到追随者服务器。当收到一个提议, 追随者服务器会发送这个提议到SyncRequestProcessor处理器,SendRequstProcessor会向群首发送确认消息。当群首服务器接收到足够确认消息来提交这个提议时,群首就会发送提交事务消息给追随者(同时也会发送INFORM消息给观察者服务器)。当接收到提交事务消息时,追随者就通过CommitRequstrocessor处理器进行处理。

 为了保证执行的顺序,CommitRequestProcessor处理 器会在收到一个写请求处理器时暂停后续的请求处理。这就意味着,在一个写请求之后,接收到的任何读取请求都将被阻塞,直到读取请求转给CommitRequstProcessor处理器。通过等待的方式,请求可以被保证按照接收的顺序来被执行。

对于观察者服务器的请求流水线(ObserverZooKeeperServer类) 与追随者服务器的流水线非常相似。但是因为观察者服务器不需要确认提议消息,因此观察者服务器并不需要发送确认消息给群首服务器,也不用持久化事务到硬盘。对于观察者服务器是否需要持久化事务到硬盘,以便加速观察者服务器的恢复速度,这样的讨论正在进行中,因此对于以后的ZooKeeper版本也会会有这一个功能。

6.6 本地存储

SyncRequestProcessor处理器就是用于在处理提议是写入这些日志和快照。

6.6.1 日志和磁盘的使用

服务器通过事务日志来持久化事务。在接受一个提议时,一个服务器(追随者或群首服务器)就会将提议的事务持久化到事物日志中,该事务日志保存在服务器的本地磁盘中,而事务将会按照顺序追加其后。服务器会时不时地滚动日志,即关闭当前文件并打开一个新的文件。

因为写事务日志是写请求操作的关键路径,因此ZooKeeper必须有效处理写日志问题。一般情况下追加文件到磁盘都会有效完成,但还有一一些情况可以使ZooKeeper运行的更快,组提交和补白。组提交(Group Commits)是指在一次磁盘写入时追加多个事务。这将使持久化多个事物只需要一次磁道寻址的开销。

关于持久化事务到磁盘,还有一个重要说明:现代操作系统通常会缓存脏页(Dirty Page),并将它们异步写入磁盘介质。为了确保事务已经被持久化,我们需要冲刷(Flush) 事务到磁盘介质。冲刷在这里就是指我们告诉操作系统将脏页写入磁盘,并在操作完成后返回。因为我们在SyncRequestProcessor处理器中持久化事务,所以这个处理器同时也会负责冲刷。在SyncRequestProcessor处理器冲刷的是所有队列中的事务到磁盘,以实现组提交的优化。如果队列中只有一个事务,这个处理器依然会执行冲刷。该处理器并不会等待更多的事务进入队列,因为这样做会增加执行操作的延时。


补白(padding) 是指在文件中预分配磁盘存储块。这样做,对于涉及存储块分配的文件系统元数据的更新,就不会显著影响文件的顺序写入操作。假如需要高速向日志中追加事务,而文件中并没有原先分配存储块,那么无论何时在写入操作到达文件的结尾,文件系统都需要分配个新存储块。而通过补白至少可以减少两次额外的磁盘寻址开销:一次是更新元数据:另一次是返回文件。为了避免受到系统中其他写操作的干扰,我们强烈推荐你将事务日志写入到一个独立的磁盘,将第二块磁盘用于操作系统文件和快照文件。

9.6.2 快照 

快照是ZooKeeper数据树的拷贝副本,每一个服务器会经常以序列化整个数据树的方式来提取快照,并将这个提取的快照保存到文件中。服务器在进行快照时不需要进行协作,也不需要暂停处理请求。因为服务器在进行快照时还会继续处理请求,所以当快照完成时,数据树可能又发生了变化,我们称这样的快照是模糊的(fuzzy) ,因为它们不能反映出在任意给点的时间点数据树的准确状态。   

事务是幂等的(idempotent) ,所以即使我们按照相同的顺序再次执行相同的事务,也会得到相同的结果,即便其结果已经保存到快照中。

      为了理解这个过程,假设重复执行一个已经被执行过的事务。如上例第一个setData操作跟我们之前描述的一样,而后我们又加上了2个setData操作,以此来展示在重放中第二个操作因为错误的版本号而未能成功的情况。假设这3个操作在提交时被正确执行。此时如果服务器加载最新的快照,即该快照已包含第一一个setData操作。服务器仍然会重放第-一个setData操作,因为快照被一个更早的zxid所标记。因为重新执行了第一一个setData操作。而第二个setData操作的版本号又与期望不符,那么这个操作将无法完成。而第三个setData操作可以正常完成,因为它也是无条件的。

      在加载完快照并重放日志后,此时服务器的状态是不正确的,因为它没有包括第二个setData请求。这个操作违反了持久性和正确性,以及请求的序列应该是无缺口(no gap) 的属性。

      这个重放请求的问题可以通过把事务转换为群首服务器所生成的statedelta来解决。当群首服务器为一个请求产生事务时,作为事务生成的一部分,包括了一些 在这个请求中znode节 点或它的数据变化的值(delta值),并指定一个特定的版本号。最后重新执行一个事务就不会导致不一致的版本号。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值