ZooKeeper技术内幕

文章目录

从系统模型、序列化与协议、客户端工作原理、会话、服务端工作原理以及数据存储等方面来向揭示ZooKeeper的技术内幕,更深入地了解ZooKeeper这一分布式协调框架。

1、系统模型

将从数据模型、节点特性、版本、Watcher和ACL五方面来讲述ZooKeeper的系统模型。

1.1、数据模型

ZooKeeper的视图结构和标准的Unix文件系统非常类似,但没有引入传统文件系统中目录和文件等相关概念,而是使用了其特有的"数据节点"概念,我们称之为ZNode。ZNode是ZooKeeper中数据的最小单元,每个ZNode上都可以保存数据,同时还可以挂载子节点,因此构成了一个层次化的命名空间,我们称之为树。

首先我们来看下图所示的ZooKeeper数据节点示意图,从而对ZooKeeper上的数据节点有一个大体上的认识。在ZooKeeper中,每一个数据节点都被称为一个ZNode,所有ZNode按层次化结构进行组织,形成一棵树。ZNode的节点路径标识方式和Unix文件系统路径非常相似,都是由一系列使用斜杠(/)进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。

事务ID

在《事务处理:概念与技术》一书中提到,事务是对物理和抽象的应用状态上的操作集合。在现在的计算机科学中,狭义上的事务通常指的是数据库事务,一般包含了一系列对数据库有序的读写操作,这些数据库事务具有所谓的ACID特性,即原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

在ZooKeeper中,事务是指能够改变ZooKeeper服务器状态的操作,我们也称之为事务操作或更新操作,一般包括数据节点创建与删除、数据节点内容更新和客户端会话创建与失效等操作。对于每一个事务请求,ZooKeeper都会为其分配一个全局唯一的事务ID,用ZXID来表示,通常是一个64位的数字。每一个ZXID对应一次更新操作,从这些ZXID中可以间接地识别出ZooKeeper处理这些更新操作请求的全局顺序。

1.2、节点特性

ZooKeeper的命名空间是由一系列数据节点组成的,在本节中,将对数据节点做详细讲解。

1.2.1、节点类型

在ZooKeeper中,每个数据节点都是有生命周期的,其生命周期的长短取决于数据节点的节点类型。在ZooKeeper中,节点类型可以分为持久节点(PERSISTENT)、临时节点(EPHEMERAL)和顺序节点(SEQUENTIAL)三大类,具体在节点创建过程中,通过组合使用,可以生成以下四种组合型节点类型:

持久节点(PERSISTENT)

持久节点是ZooKeeper中最常见的一种节点类型。所谓持久节点,是指该数据节点被创建后,就会一直存在于ZooKeeper服务器上,直到有删除操作来主动清除这个节点。

持久顺序节点(PERSISTENT_SEQUENTIAL)

持久顺序节点的基本特性和持久节点是一致的,额外的特性表现在顺序性上。在ZooKeeper中,每个父节点都会为它的第一级子节点维护一份顺序,用于记录下每个子节点创建的先后顺序。基于这个顺序特性,在创建子节点的时候,可以设置这个标记,那么在创建节点过程中,ZooKeeper会自动为给定节点名加上一个数字后缀,作为一个新的、完整的节点名。另外需要注意的是,这个数字后缀的上限是整型的最大值。

临时节点(EPHEMERAL)

和持久节点不同的是,临时节点的生命周期和客户端的会话绑定在一起,也就是说,如果客户端会话失效,那么这个节点就会被自动清理掉。注意,这里提到的是客户端会话失效,而非TCP连接断开。另外,ZooKeeper规定了不能基于临时节点来创建子节点,即临时节点只能作为叶子节点。

临时顺序节点(EPHEMERAL_SEQUENTIAL)

临时顺序节点的基本特性和临时节点也是一致的,同样是在临时节点的基础上,添加了顺序的特性。

状态信息

可以针对ZooKeeper上的数据节点进行数据的写入和子节点的创建。事实上,每个数据节点除了存储了数据内容之外,还存储了数据节点本身的一些状态信息。

1.3、版本------保证分布式数据原子性操作

ZooKeeper中为数据节点引入了版本的概念,每个数据节点都具有三种类型的版本信息,对数据节点的任何更新操作都会引起版本号的变化,下表中对这三类版本信息分别进行了说明。

ZooKeeper中的版本概念和传统意义上的软件版本有很大的区别,它表示的是对数据节点的数据内容、子节点列表,或是节点ACL信息的修改次数,我们以其中的version这种版本类型为例来说明。在一个数据节点/zk-book被创建完毕之后,节点的version值是0,表示的含义是"当前节点自从创建之后,被更新过0次"。如果现在对该节点的数据内容进行更新操作,那么随后,version的值就会变成1。同时需要注意的是,在上文中提到的关于version的说明,其表示的是对数据节点数据内容的变更次数,强调的是变更次数,因此即使前后两次变更并没有使得数据内容的值发生变化,version的值依然会变更。

在上面的介绍中,我们基本了解了ZooKeeper中的版本概念。那么版本究竟用来干嘛呢?在讲解版本的作用之前,我们首先来看下分布式领域中最常见的一个概念------锁。

一个多线程应用,尤其是分布式系统,在运行过程中往往需要保证数据访问的排他性。

悲观锁,又被称作悲观并发控制(Pessimistic Concurrency Control,PCC),是数据库中一种非常典型且非常严格的并发控制策略。悲观锁具有强烈的独占和排他特性,能够有效地避免不同事务对同一数据并发更新而造成的数据一致性问题。在悲观锁的实现原理中,如果一个事务(假定事务A)正在对数据进行处理,那么在整个处理过程中,都会将数据处于锁定状态,在这期间,其他事务将无法对这个数据进行更新操作,直到事务A完成对该数据的处理,释放了对应的锁之后,其他事务才能够重新竞争来对数据进行更新操作。也就是说,对于一份独立的数据,系统只分配了一把唯一的钥匙,谁获得了这把钥匙,谁就有权力更新这份数据。一般我们认为,在实际生产应用中,悲观锁策略适合解决那些对于数据更新竞争十分激烈的场景------在这类场景中,通常采用简单粗暴的悲观锁机制来解决并发控制问题。

乐观锁,又被称作乐观并发控制(Optimistic Concurrency Control,OCC),也是一种常见的并发控制策略。相对于悲观锁而言,乐观锁机制显得更加宽松与友好。从上面对悲观锁的讲解中我们可以看到,悲观锁假定不同事务之间的处理一定会出现互相干扰,从而需要在一个事务从头到尾的过程中都对数据进行加锁处理。而乐观锁则正好相反,它假定多个事务在处理过程中不会彼此影响,因此在事务处理的绝大部分时间里不需要进行加锁处理。当然,既然有并发,就一定会存在数据更新冲突的可能。在乐观锁机制中,在更新请求提交之前,每个事务都会首先检查当前事务读取数据后,是否有其他事务对该数据进行了修改。如果其他事务有更新的话,那么正在提交的事务就需要回滚。乐观锁通常适合使用在数据并发竞争不大、事务冲突较少的应用场景中。

从上面的讲解中,我们其实可以把一个乐观锁控制的事务分成如下三个阶段:数据读取、写入校验和数据写入,其中写入校验阶段是整个乐观锁控制的关键所在。在写入校验阶段,事务会检查数据在读取阶段后是否有其他事务对数据进行过更新,以确保数据更新的一致性。那么,如何来进行写入校验呢?我们首先可以来看下JDK中最典型的乐观锁实现------CAS。简单地讲就是"对于值V,每次更新前都会比对其值是否是预期值A,只有符合预期,才会将V原子化地更新到新值B",其中是否符合预期便是乐观锁中的"写入校验"阶段。

好了,现在我们再回过头来看看ZooKeeper中版本的作用。事实上,在ZooKeeper中,version属性正是用来实现乐观锁机制中的"写入校验"的。在ZooKeeper服务器的PrepRequestProcessor处理器类中,在处理每一个数据更新(setDataRequest)请求时,会进行如下清单所示的版本检查。

从上面的执行逻辑中,我们可以看出,在进行一次setDataRequest请求处理时,首先进行了版本检查:ZooKeeper会从setDataRequest请求中获取到当前请求的版本version,同时从数据记录nodeRecord中获取到当前服务器上该数据的最新版本currentVersion。如果version为"-1",那么说明客户端并不要求使用乐观锁,可以忽略版本比对;如果version不是"-1",那么就比对version和currentVersion,如果两个版本不匹配,那么将会抛出BadVersionException异常。

1.4、 Watcher------数据变更的通知

ZooKeeper提供了分布式数据的发布/订阅功能。一个典型的发布/订阅模型系统定义了一种一对多的订阅关系,能够让多个订阅者同时监听某一个主题对象,当这个主题对象自身状态变化时,会通知所有订阅者,使它们能够做出相应的处理。在ZooKeeper中,引入了Watcher机制来实现这种分布式的通知功能。ZooKeeper允许客户端向服务端注册一个Watcher监听,当服务端的一些指定事件触发了这个Watcher,那么就会向指定客户端发送一个事件通知来实现分布式的通知功能。整个Watcher注册与通知过程如下图所示:

从下图中,我们可以看到,ZooKeeper的Watcher机制主要包括客户端线程、客户端WatchManager和ZooKeeper服务器三部分。在具体工作流程上,简单地讲,客户端在向ZooKeeper服务器注册Watcher的同时,会将Watcher对象存储在客户端的WatchManager中。当ZooKeeper服务器端触发Watcher事件后,会向客户端发送通知,客户端线程从WatchManager中取出对应的Watcher对象来执行回调逻辑。

Watcher接口

在ZooKeeper中,接口类Watcher用于表示一个标准的事件处理器,其定义了事件通知相关的逻辑,包含KeeperState和EventType两个枚举类,分别代表了通知状态和事件类型,同时定义了事件的回调方法:process(WatchedEvent event)。

Watcher事件

同一个事件类型在不同的通知状态中代表的含义有所不同,下表列举了常见的通知状态和事件类型。

上表中列举了ZooKeeper中最常见的几个通知状态和事件类型。其中,针对NodeDataChanged事件,此处说的变更包括节点的数据内容和数据的版本号dataVersion。因此,即使使用相同的数据内容来更新,还是会触发这个事件通知,因为对于ZooKeeper来说,无论数据内容是否变更,一旦有客户端调用了数据更新的接口,且更新成功,就会更新dataVersion值。

NodeChildrenChanged事件会在数据节点的子节点列表发生变更的时候被触发,这里说的子节点列表变化特指子节点个数和组成情况的变更,即新增子节点或删除子节点,而子节点内容的变化是不会触发这个事件的。

对于AuthFailed这个事件,需要注意的地方是,它的触发条件并不是简简单单因为当前客户端会话没有权限,而是授权失败。我们首先通过下面两个清单所示的两个例子来看看AuthFailed这个事件。

上面两个示例程序都创建了一个受到权限控制的数据节点,然后使用了不同的权限Scheme进行权限检查。在第一个示例程序中,使用了正确的权限Scheme:digest;而第二个示例程序中使用了错误的Scheme:digest2。另外,无论哪个程序,都使用了错误的Auth:taokeeper:error,因此在运行第一个程序的时候,会抛出NoAuthException异常,而第二个程序运行后,抛出的是AuthFailedException异常,同时,会收到对应的Watcher事件通知:(AuthFailed,None)。
回调方法process()

process方法是Watcher接口中的一个回调方法,当ZooKeeper向客户端发送一个Watcher事件通知时,客户端就会对相应的process方法进行回调,从而实现对事件的处理。process方法的定义如下:

这个回调方法的定义非常简单,我们重点看下方法的参数定义:WatchedEvent。WatchedEvent包含了每一个事件的三个基本属性:通知状态(keeperState)、事件类型(eventType)和节点路径(path),其数据结构如下图所示。ZooKeeper使用WatchedEvent对象来封装服务端事件并传递给Watcher,从而方便回调方法process对服务端事件进行处理。

提到WatchedEvent,不得不讲下WatcherEvent实体。笼统地讲,两者表示的是同一个事物,都是对一个服务端事件的封装。不同的是,WatchedEvent是一个逻辑事件,用于服务端和客户端程序执行过程中所需的逻辑对象,而WatcherEvent因为实现了序列化接口,因此可以用于网络传输,其数据结构如下图所示:

服务端在生成WatchedEvent事件之后,会调用getWrapper方法将自己包装成一个可序列化的WatcherEvent事件,以便通过网络传输到客户端。客户端在接收到服务端的这个事件对象后,首先会将WatcherEvent事件还原成一个WatchedEvent事件,并传递给process方法处理,回调方法process根据入参就能够解析出完整的服务端事件了。

需要注意的一点是,无论是WatchedEvent还是WatcherEvent,其对ZooKeeper服务端事件的封装都是极其简单的。举个例子来说,当/zk-book这个节点的数据发生变更时,服务端会发送给客户端一个"ZNode数据内容变更"事件,客户端只能够接收到如下信息:

从上面展示的信息中,我们可以看到,客户端无法直接从该事件中获取到对应数据节点的原始数据内容以及变更后的新数据内容,而是需要客户端再次主动去重新获取数据------这也是ZooKeeper Watcher机制的一个非常重要的特性。

工作机制

ZooKeeper的Watcher机制,总的来说可以概括为以下三个过程:客户端注册Watcher、服务端处理Watcher和客户端回调Watcher,其内部各组件之间的关系如下图所示:

客户端注册Watcher

在创建一个ZooKeeper客户端对象实例时,可以向构造方法中传入一个默认的Watcher:

这个Watcher将作为整个ZooKeeper会话期间的默认Watcher,会一直被保存在客户端ZKWatchManager的defaultWatcher中。另外,ZooKeeper客户端也可以通过getData、getChildren和exist三个接口来向ZooKeeper服务器注册Watcher,无论使用哪种方式,注册Watcher的工作原理都是一致的,这里我们以getData这个接口为例来说明。getData接口用于获取指定节点的数据内容,主要有两个方法:

在这两个接口上都可以进行Watcher的注册,第一个接口通过一个boolean参数来标识是否使用上文中提到的默认Watcher来进行注册,具体的注册逻辑和第二个接口是一致的。

在向getData接口注册Watcher后,客户端首先会对当前客户端请求request进行标记,将其设置为"使用Watcher监听",同时会封装一个Watcher的注册信息WatchRegistration对象,用于暂时保存数据节点的路径和Watcher的对应关系,具体的逻辑代码如下:

在ZooKeeper中,Packet可以被看作一个最小的通信协议单元,用于进行客户端与服务端之间的网络传输,任何需要传输的对象都需要包装成一个Packet对象。因此,在ClientCnxn中WatchRegistration又会被封装到Packet中去,然后放入发送队列中等待客户端发送:

随后,ZooKeeper客户端就会向服务端发送这个请求,同时等待请求的返回。完成请求发送后,会由客户端SendThread线程的readResponse方法负责接收来自服务端的响应,finishPacket方法会从Packet中取出对应的Watcher并注册到ZKWatchManager中去:

从上面的内容中,我们已经了解到客户端已经将Watcher暂时封装在了WatchRegistration对象中,现在就需要从这个封装对象中再次提取出Watcher来:

在register方法中,客户端会将之前暂时保存的Watcher对象转交给ZKWatchManager,并最终保存到dataWatches中去。ZKWatchManager.dataWatches是一个Map<String,Set<Watcher>>类型的数据结构,用于将数据节点的路径和Watcher对象进行一一映射后管理起来。整个客户端Watcher的注册流程如下图所示:

通过上面的讲解,相信读者已经对客户端的Watcher注册流程有了一个大概的了解。但同时我们也可以发现,极端情况下,客户端每调用一次getData()接口,就会注册上一个Watcher,那么这些Watcher实体都会随着客户端请求被发送到服务端去吗?

答案是否定的。如果客户端注册的所有Watcher都被传递到服务端的话,那么服务端肯定会出现内存紧张或其他性能问题了,幸运的是,在ZooKeeper的设计中充分考虑到了这个问题。在上面的流程中,我们提到把WatchRegistration封装到了Packet对象中去,但事实上,在底层实际的网络传输序列化过程中,并没有将WatchRegistration对象完全地序列化到底层字节数组中去。为了证实这一点,我们可以看下Packet内部的序列化过程:

从上面的代码片段中,我们可以看到,在Packet.createBB()方法中,ZooKeeper只会将requestHeader和request两个属性进行序列化,也就是说,尽管WatchRegistration被封装在了Packet中,但是并没有被序列化到底层字节数组中去,因此也就不会进行网络传输了。

服务端处理Watcher

上面主要讲解了客户端注册Watcher的过程,并且已经了解了最终客户端并不会将Watcher对象真正传递到服务端。那么,服务端究竟是如何完成客户端的Watcher注册,又是如何来处理这个Watcher的呢?本节将主要围绕这两个问题展开进行讲解。

ServerCnxn存储

我们首先来看下服务端接收Watcher并将其存储起来的过程,如下图所示是ZooKeeper服务端处理Watcher的序列图。

从下图中我们可以看到,服务端收到来自客户端的请求之后,在FinalRequest Processor.processRequest()中会判断当前请求是否需要注册Watcher:

从getData请求的处理逻辑中,我们可以看到,当getDataRequest.getWatch()为true的时候,ZooKeeper就认为当前客户端请求需要进行Watcher注册,于是就会将当前的ServerCnxn对象和数据节点路径传入getData方法中去。那么为什么要传入ServerCnxn呢?ServerCnxn是一个ZooKeeper客户端和服务器之间的连接接口,代表了一个客户端和服务器的连接。ServerCnxn接口的默认实现是NIOServerCnxn,同时从3.4.0版本开始,引入了基于Netty的实现:NettyServerCnxn。无论采用哪种实现方式,都实现了Watcher的process接口,因此我们可以把ServerCnxn看作是一个Watcher对象。数据节点的节点路径和ServerCnxn最终会被存储在WatchManager的watchTable和watch2Paths中。

WatchManager是ZooKeeper服务端Watcher的管理者,其内部管理的watchTable和watch2Paths两个存储结构,分别从两个维度对Watcher进行存储。

  • watchTable是从数据节点路径的粒度来托管Watcher。
  • watch2Paths是从Watcher的粒度来控制事件触发需要触发的数据节点。

同时,WatchManager还负责Watcher事件的触发,并移除那些已经被触发的Watcher。注意,WatchManager只是一个统称,在服务端,DataTree中会托管两个WatchManager,分别是dataWatches和childWatches,分别对应数据变更Watcher和子节点变更Watcher。在本例中,因为是getData接口,因此最终会被存储在dataWatches中,其数据结构如下图所示:

Watcher触发

在上面的讲解中,我们了解了对于标记了Watcher注册的请求,ZooKeeper会将其对应的ServerCnxn存储到WatchManager中,下面我们来看看服务端是如何触发Watcher的。在下表中我们提到,NodeDataChanged事件的触发条件是"Watcher监听的对应数据节点的数据内容发生变更",其具体实现如下:

在对指定节点进行数据更新后,通过调用WatchManager的triggerWatch方法来触发相关的事件:

...

1.5、ACL------保障数据的安全

ZooKeeper作为一个分布式协调框架,其内部存储的都是一些关乎分布式系统运行时状态的元数据,尤其是一些涉及分布式锁、Master选举和分布式协调等应用场景的数据,会直接影响基于ZooKeeper进行构建的分布式系统的运行状态。因此,如何有效地保障ZooKeeper中数据的安全,从而避免因误操作而带来的数据随意变更导致的分布式系统异常就显得格外重要了。所幸的是,ZooKeeper提供了一套完善的ACL(Access Control List)权限控制机制来保障数据的安全。

提到权限控制,我们首先来看看大家都熟悉的、在Unix/Linux文件系统中使用的,也是目前应用最广泛的权限控制方式------UGO(User、Group和Others)权限控制机制。简单地讲,UGO就是针对一个文件或目录,对创建者(User)、创建者所在的组(Group)和其他用户(Other)分别配置不同的权限。从这里可以看出,UGO其实是一种粗粒度的文件系统权限控制模式,利用UGO只能对三类用户进行权限控制,即文件的创建者、创建者所在的组以及其他所有用户,很显然,UGO无法解决下面这个场景:

用户U1创建了文件F1,希望U1所在的用户组G1拥有对F1读写和执行的权限,另一个用户组G2拥有读权限,而另外一个用户U3则没有任何权限。

接下去我们来看另外一种典型的权限控制方式:ACL。ACL,即访问控制列表,是一种相对来说比较新颖且更细粒度的权限管理方式,可以针对任意用户和组进行细粒度的权限控制。目前绝大部分Unix系统都已经支持了ACL方式的权限控制,Linux也从2.6版本的内核开始支持这个特性。

ACL介绍

ZooKeeper的ACL权限控制和Unix/Linux操作系统中的ACL有一些区别,读者可以从三个方面来理解ACL机制,分别是:权限模式(Scheme)、授权对象(ID)和权限(Permission),通常使用"scheme:id:permission"来标识一个有效的ACL信息。

1.5.1、权限模式:Scheme

权限模式用来确定权限验证过程中使用的检验策略。在ZooKeeper中,开发人员使用最多的就是以下四种权限模式。

IP

IP模式通过IP地址粒度来进行权限控制,例如配置了"ip:192.168.0.110",即表示权限控制都是针对这个IP地址的。同时,IP模式也支持按照网段的方式进行配置,例如"ip:192.168.0.1/24"表示针对192.168.0.*这个IP段进行权限控制。

Digest

Digest是最常用的权限控制模式,也更符合我们对于权限控制的认识,其以类似于"username:password"形式的权限标识来进行权限配置,便于区分不同应用来进行权限控制。

当我们通过"username:password"形式配置了权限标识后,ZooKeeper会对其先后进行两次编码处理,分别是SHA-1算法加密和BASE64编码,其具体实现由DigestAuthenticationProvider.generateDigest(String idPassword)函数进行封装,清单如下所示为使用该函数进行"username:password"编码的一个实例:

运行程序,输出结果如下:

从上面的运行结果中可以看出,"username:password"最终会被混淆为一个无法辨识的字符串。

World

World是一种最开放的权限控制模式,从其名字中也可以看出,事实上这种权限控制方式几乎没有任何作用,数据节点的访问权限对所有用户开放,即所有用户都可以在不进行任何权限校验的情况下操作ZooKeeper上的数据。另外,World模式也可以看作是一种特殊的Digest模式,它只有一个权限标识,即"world:anyone"。

Super

Super模式,顾名思义就是超级用户的意思,也是一种特殊的Digest模式。在Super模式下,超级用户可以对任意ZooKeeper上的数据节点进行任何操作。关于Super模式的用法,本节后面会进行详细的讲解。

1.5.2、授权对象:ID

授权对象指的是权限赋予的用户或一个指定实体,例如IP地址或是机器等。在不同的权限模式下,授权对象是不同的,下表中列出了各个权限模式和授权对象之间的对应关系。

权限:Permission

权限就是指那些通过权限检查后可以被允许执行的操作。在ZooKeeper中,所有对数据的操作权限分为以下五大类:

  • CREATE©:数据节点的创建权限,允许授权对象在该数据节点下创建子节点。
  • DELETE(D):子节点的删除权限,允许授权对象删除该数据节点的子节点。
  • READ®:数据节点的读取权限,允许授权对象访问该数据节点并读取其数据内容或子节点列表等。
  • WRITE(W):数据节点的更新权限,允许授权对象对该数据节点进行更新操作。
  • ADMIN(A):数据节点的管理权限,允许授权对象对该数据节点进行ACL相关的设置操作。

1.5.3、权限扩展体系

ZooKeeper默认提供的IP、Digest、World和Super这四种权限模式,在绝大部分的场景下,这四种权限模式已经能够很好地实现权限控制的目的。同时,ZooKeeper提供了特殊的权限控制插件体系,允许开发人员通过指定方式对ZooKeeper的权限进行扩展。这些扩展的权限控制方式就像插件一样插入到ZooKeeper的权限体系中去,因此在ZooKeeper的官方文档中,也称该机制为"Pluggable ZooKeeper Authentication"。

。。。。。。。。。。。。

2、序列化与协议

ZooKeeper的客户端和服务端之间会进行一系列的网络通信以实现数据的传输。对于一个网络通信,首先需要解决的就是对数据的序列化和反序列化处理,在ZooKeeper中,使用了Jute这一序列化组件来进行数据的序列化和反序列化操作。同时,为了实现一个高效的网络通信程序,良好的通信协议设计也是至关重要的。

2.1、Jute介绍

Jute是ZooKeeper中的序列化组件,最初也是Hadoop中的默认序列化组件,其前身是Hadoop Record IO中的序列化组件,后来由于Apache Avro[插图]具有出众的跨语言特性、丰富的数据结构和对MapReduce的天生支持,并且能非常方便地用于RPC调用,从而深深吸引了Hadoop。因此Hadoop从0.21.0版本开始,废弃了Record IO,使用了Avro这个序列化框架,同时Jute也从Hadoop工程中被剥离出来,成为了独立的序列化组件。

ZooKeeper则从第一个正式对外发布的版本(0.0.1版本)开始,就一直使用Jute组件来进行网络数据传输和本地磁盘数据存储的序列化和反序列化工作,一直使用至今。其实在前些年,ZooKeeper官方也一直在寻求一种高性能的跨语言序列化组件,期间也多次提出要替换ZooKeeper的序列化组件。关于序列化组件的改造还需要追溯到2008年左右,那时候ZooKeeper官方就提出要使用类似于Apache Avro、Thrift或是Google的protobuf这样的组件来替换Jute,但是考虑到新老版本序列化组件的兼容性,官方团队一直对替换序列化组件工作的推进持保守和观望态度。值得一提的是,在替换序列化组件这件事上,ZooKeeper官方团队曾经也有过类似于下面这样的方案:服务器开启两个客户端服务端口,让包含新序列化组件的新版客户端连接单独的服务器端口,老版本的客户端则连接另一个端口。但考虑到其实施的复杂性,这个想法设计一直没有落地。更为有趣的是,ZooKeeper开发团队曾经甚至考虑将"如何让依赖Jute组件的老版本客户端/服务器和依赖Avro组件的新版本客户端/服务器进行无缝通信"这个问题作为Google Summer of Code的题目。当然,另一个重要原因是针对Avro早期的发布版本,ZooKeeper官方做了一个Jute和Avro的性能测试,但是测试结果并不理想,因此也并没有决定使用Avro------时至今日,Jute的序列化能力都不曾是ZooKeeper的性能瓶颈。

总之,因为种种原因以及2009年以后ZooKeeper快速地被越来越多的系统使用,开发团队需要将更多的精力放在解决更多优先级更高的需求和Bug修复上,以致于替换Jute序列化组件的工作一度被搁置------于是我们现在看到,在最新版本的ZooKeeper中,底层依然使用了Jute这个古老的,并且似乎没有更多其他系统在使用的序列化组件。

2.2、使用Jute进行序列化

下面我们通过一个例子来看看如何使用Jute来完成Java对象的序列化和反序列化。假设我们有一个实体类MockReqHeader(代表了一个简单的请求头),其定义如下清单所示:

上面即为一个非常简单的请求头定义,包含了两个成员变量:sessionId和type。接下来我们看看如何使用Jute来进行序列化和反序列化。

上面这个代码片段演示了如何使用Jute来对MockReqHeader对象进行序列化和反序列化,总的来说,大体可以分为4步:

  1. 实体类需要实现Record接口的serialize和deserialize方法。
  2. 构建一个序列化器BinaryOutputArchive。
  3. 序列化:调用实体类的serialize方法,将对象序列化到指定tag中去。例如在本例中就将MockReqHeader对象序列化到header中去。
  4. 反序列化:调用实体类的deserialize,从指定的tag中反序列化出数据内容。

以上就是Jute进行序列化和反序列化的基本过程。

。。。。。。。。。。。。。。。。。。。。

3、客户端

客户端是开发人员使用ZooKeeper最主要的途径,因此我们有必要对ZooKeeper客户端的内部原理进行详细讲解。ZooKeeper的客户端主要由以下几个核心组件组成:

  • ZooKeeper实例:客户端的入口。
  • ClientWatchManager:客户端Watcher管理器。
  • HostProvider:客户端地址列表管理器。
  • ClientCnxn:客户端核心线程,其内部又包含两个线程,即SendThread和EventThread。前者是一个I/O线程,主要负责ZooKeeper客户端和服务端之间的网络I/O通信;后者是一个事件线程,主要负责对服务端事件进行处理。

客户端整体结构如下图所示:

ZooKeeper客户端的初始化与启动环节,实际上就是ZooKeeper对象的实例化过程,因此我们首先来看下ZooKeeper客户端的构造方法:

客户端的整个初始化和启动过程大体可以分为以下3个步骤:

  1. 设置默认Watcher。
  2. 设置ZooKeeper服务器地址列表。
  3. 创建ClientCnxn。

如果在ZooKeeper的构造方法中传入一个Watcher对象的话,那么ZooKeeper就会将这个Watcher对象保存在ZKWatchManager的defaultWatcher中,作为整个客户端会话期间的默认Watcher。

3.1、 一次会话的创建过程

为了更好地了解ZooKeeper客户端的工作原理,我们首先从一次客户端会话的创建过程讲起,从而先对ZooKeeper的客户端及其几个重要组件之间的协作关系有一个宏观上的了解,如下图所示是客户端一次会话创建的基本过程。在这个流程图中,所有以白色作为底色的框图流程可以看作是第一阶段,我们称之为初始化阶段;以斜线底纹表示的流程是第二阶段,称之为会话创建阶段;以点状底纹表示的则是客户端在接收到服务端响应后的对应处理,称之为响应处理阶段。

初始化阶段

  1. 初始化ZooKeeper对象:通过调用ZooKeeper的构造方法来实例化一个ZooKeeper对象,在初始化过程中,会创建一个客户端的Watcher管理器:ClientWatchManager。
  2. 设置会话默认Watcher:如果在构造方法中传入了一个Watcher对象,那么客户端会将这个对象作为默认Watcher保存在ClientWatchManager中。
  3. 构造ZooKeeper服务器地址列表管理器:HostProvider。
  4. 创建并初始化客户端网络连接器:ClientCnxn。

ZooKeeper客户端首先会创建一个网络连接器ClientCnxn,用来管理客户端与服务器的网络交互。另外,客户端在创建ClientCnxn的同时,还会初始化客户端两个核心队列outgoingQueue和pendingQueue,分别作为客户端的请求发送队列和服务端响应的等待队列。

在后面的我们也会讲到,ClientCnxn连接器的底层I/O处理器是ClientCnxnSocket,因此在这一步中,客户端还会同时创建ClientCnxnSocket处理器。

  1. 初始化SendThread和EventThread:客户端会创建两个核心网络线程SendThread和EventThread,前者用于管理客户端和服务端之间的所有网络I/O,后者则用于进行客户端的事件处理。同时,客户端还会将ClientCnxnSocket分配给SendThread作为底层网络I/O处理器,并初始化EventThread的待处理事件队列waitingEvents,用于存放所有等待被客户端处理的事件。

。。。。。。。。。。。。。。。。。。。。。。。。。

4、会话

会话(Session)是ZooKeeper中最重要的概念之一,客户端与服务端之间的任何交互操作都与会话息息相关,这其中就包括临时节点的生命周期、客户端请求的顺序执行以及Watcher通知机制等。

ZooKeeper客户端与服务端之间一次会话创建的大体过程。以Java语言为例,简单地说,ZooKeeper的连接与会话就是客户端通过实例化ZooKeeper对象来实现客户端与服务器创建并保持TCP连接的过程。在本节中,我们将从会话状态、会话创建和会话管理等方面来讲解ZooKeeper连接与会话的技术内幕。

4.1、会话状态

在ZooKeeper客户端与服务端成功完成连接创建后,就建立了一个会话。ZooKeeper会话在整个运行期间的生命周期中,会在不同的会话状态之间进行切换,这些状态一般可以分为CONNECTING、CONNECTED、RECONNECTING、RECONNECTED和CLOSE等。

如果客户端需要与服务端创建一个会话,那么客户端必须提供一个使用字符串表示的服务器地址列表:"host1:port,host2:port,host3:port"。例如,"192.168.0.1:2181"或是"192.168.0.1:2181,192.168.0.2:2181,192.168.0.3:2181"。一旦客户端开始创建ZooKeeper对象,那么客户端状态就会变成CONNECTING,同时客户端开始从上述服务器地址列表中逐个选取IP地址来尝试进行网络连接,直到成功连接上服务器,然后将客户端状态变更为CONNECTED。

通常情况下,伴随着网络闪断或是其他原因,客户端与服务器之间的连接会出现断开情况。一旦碰到这种情况,ZooKeeper客户端会自动进行重连操作,同时客户端的状态再次变为CONNECTING,直到重新连接上ZooKeeper服务器后,客户端状态又会再次转变成CONNECTED。因此,通常情况下,在ZooKeeper运行期间,客户端的状态总是介于CONNECTING和CONNECTED两者之一。

另外,如果出现诸如会话超时、权限检查失败或是客户端主动退出程序等情况,那么客户端的状态就会直接变更为CLOSE。

下图展示了ZooKeeper客户端会话状态的变更情况。

4.2、会话创建

我们曾经介绍了会话创建过程中ZooKeeper客户端的大体工作流程。在本节中,我们再一起来看看会话创建过程中ZooKeeper服务端的工作原理。

4.2.1、Session

Session是ZooKeeper中的会话实体,代表了一个客户端会话。其包含以下4个基本属性:

  • sessionID:会话ID,用来唯一标识一个会话,每次客户端创建新会话的时候,ZooKeeper都会为其分配一个全局唯一的sessionID。
  • TimeOut:会话超时时间。客户端在构造ZooKeeper实例的时候,会配置一个sessionTimeout参数用于指定会话的超时时间。ZooKeeper客户端向服务器发送这个超时时间后,服务器会根据自己的超时时间限制最终确定会话的超时时间。
  • TickTime:下次会话超时时间点。为了便于ZooKeeper对会话实行"分桶策略"管理,同时也是为了高效低耗地实现会话的超时检查与清理,ZooKeeper会为每个会话标记一个下次会话超时时间点。TickTime是一个13位的long型数据,其值接近于当前时间加上TimeOut,但不完全相等。
  • isClosing:该属性用于标记一个会话是否已经被关闭。通常当服务端检测到一个会话已经超时失效的时候,会将该会话的isClosing属性标记为"已关闭",这样就能确保不再处理来自该会话的新请求了。

。。。。。。。。。。。。。。。。。。。。

5、服务器启动

从本节开始,我们将真正进入ZooKeeper服务端相关的技术内幕介绍。首先我们来看看ZooKeeper服务端的整体架构,如下图所示:

5.1、单机版服务器启动

ZooKeeper服务器的启动,大体可以分为以下五个主要步骤:配置文件解析、初始化数据管理器、初始化网络I/O管理器、数据恢复和对外服务。下图所示是单机版ZooKeeper服务器的启动流程图。

5.1.1、预启动

预启动的步骤如下:

1.统一由QuorumPeerMain作为启动类。无论是单机版还是集群模式启动ZooKeeper服务器,在zkServer.cmd和zkServer.sh两个脚本中,都配置了使用org.apache.zookeeper.server.quorum.QuorumPeerMain作为启动入口类。

2.解析配置文件zoo.cfg。ZooKeeper首先会进行配置文件的解析,配置文件的解析其实就是对zoo.cfg文件的解析。我们曾经提到在部署ZooKeeer服务器时,需要使用到zoo.cfg这个文件。该文件配置了ZooKeeper运行时的基本参数,包括tickTime、dataDir和clientPort等参数。

3.创建并启动历史文件清理器DatadirCleanupManager。从3.4.0版本开始,ZooKeeper增加了自动清理历史数据文件的机制,包括对事务日志和快照数据文件进行定时清理。

4.判断当前是集群模式还是单机模式的启动。ZooKeeper根据步骤2中解析出的集群服务器地址列表来判断当前是集群模式还是单机模式,如果是单机模式,那么就委托给ZooKeeperServerMain进行启动处理。

5.再次进行配置文件zoo.cfg的解析。

6.创建服务器实例ZooKeeperServer。org.apache.zookeeper.server.ZooKeeperServer是单机版ZooKeeper服务端最为核心的实体类。ZooKeeper服务器首先会进行服务器实例的创建,接下去的步骤则都是对该服务器实例的初始化工作,包括连接器、内存数据库和请求处理器等组件的初始化。

。。。。。。。。。。。。。。。。

5.2、集群版服务器启动

我们已经讲解了单机版ZooKeeper服务器的启动过程,在本节中,我们将对集群版ZooKeeper服务器的启动过程做详细讲解。集群版和单机版ZooKeeper服务器的启动过程在很多地方都是一致的,因此本节只会对有差异的地方展开进行讲解。下图所示是集群版ZooKeeper服务器的启动流程图。

5.2.1、预启动

预启动的步骤如下:

  1. 统一由QuorumPeerMain作为启动类。
  2. 解析配置文件zoo.cfg。
  3. 创建并启动历史文件清理器DatadirCleanupManager。
  4. 判断当前是集群模式还是单机模式的启动。

在集群模式中,由于已经在zoo.cfg中配置了多个服务器地址,因此此处选择集群模式启动ZooKeeper。

5.2.2、初始化

初始化的步骤如下:

  1. 创建ServerCnxnFactory。
  2. 初始化ServerCnxnFactory。
  3. 创建ZooKeeper数据管理器FileTxnSnapLog。
  4. 创建QuorumPeer实例。

Quorum是集群模式下特有的对象,是ZooKeeper服务器实例(ZooKeeperServer)的托管者,从集群层面看,QuorumPeer代表了ZooKeeper集群中的一台机器。在运行期间,QuorumPeer会不断检测当前服务器实例的运行状态,同时根据情况发起Leader选举。

  1. 创建内存数据库ZKDatabase:ZKDatabase是ZooKeeper的内存数据库,负责管理ZooKeeper的所有会话记录以及DataTree和事务日志的存储。
  2. 初始化QuorumPeer:在步骤5中我们已经提到,QuorumPeer是ZooKeeperServer的托管者,因此需要将一些核心组件注册到QuorumPeer中去,包括FileTxnSnapLog、ServerCnxnFactory和ZKDatabase。同时ZooKeeper还会对QuorumPeer配置一些参数,包括服务器地址列表、Leader选举算法和会话超时时间限制等。
  3. 恢复本地数据。
  4. 启动ServerCnxnFactory主线程。

5.2.3、Leader选举

Leader选举的步骤如下。

  1. 初始化Leader选举:Leader选举可以说是集群和单机模式启动ZooKeeper最大的不同点。ZooKeeper首先会根据自身的SID(服务器ID)、lastLoggedZxid(最新的ZXID)和当前的服务器epoch(currentEpoch)来生成一个初始化的投票------简单地讲,在初始化过程中,每个服务器都会给自己投票。然后,ZooKeeper会根据zoo.cfg中的配置,创建相应的Leader选举算法实现。在ZooKeeper中,默认提供了三种Leader选举算法的实现,分别是LeaderElection、AuthFastLeaderElection和FastLeaderElection,可以通过在配置文件(zoo.cfg)中使用electionAlg属性来指定,分别使用数字0~3来表示。从3.4.0版本开始,ZooKeeper废弃了前两种Leader选举算法,只支持FastLeaderElection选举算法了。在初始化阶段,ZooKeeper会首先创建Leader选举所需的网络I/O层QuorumCnxManager,同时启动对Leader选举端口的监听,等待集群中其他服务器创建连接。
  2. 注册JMX服务。
  3. 检测当前服务器状态。在上文中,我们已经提到QuorumPeer是ZooKeeper服务器实例的托管者,在运行期间,QuorumPeer的核心工作就是不断地检测当前服务器的状态,并做出相应的处理。在正常情况下,ZooKeeper服务器的状态在LOOKING、LEADING和FOLLOWING/OBSERVING之间进行切换。而在启动阶段,QuorumPeer的初始状态是LOOKING,因此开始进行Leader选举。
  4. Leader选举:ZooKeeper的Leader选举过程,简单地讲,就是一个集群中所有的机器相互之间进行一系列投票,选举产生最合适的机器成为Leader,同时其余机器成为Follower或是Observer的集群机器角色初始化过程。关于Leader选举算法,简而言之,就是集群中哪个机器处理的数据越新(通常我们根据每个服务器处理过的最大ZXID来比较确定其数据是否更新),其越有可能成为Leader。当然,如果集群中的所有机器处理的ZXID一致的话,那么SID最大的服务器成为Leader。

5.2.4、Leader和Follower启动期交互过程

到这里为止,ZooKeeper已经完成了Leader选举,并且集群中每个服务器都已经确定了自己的角色------通常情况下就分为Leader和Follower两种角色。下面我们来对Leader和Follower在启动期间的工作原理进行讲解,其大致交互流程如下图所示:

Leader和Follower服务器启动期交互过程包括如下步骤:

  1. 创建Leader服务器和Follower服务器。完成Leader选举之后,每个服务器都会根据自己的服务器角色创建相应的服务器实例,并开始进入各自角色的主流程。
  2. Leader服务器启动Follower接收器LearnerCnxAcceptor。在ZooKeeper集群运行期间,Leader服务器需要和所有其余的服务器(本贴余下部分,我们使用"Learner"来指代这类机器)保持连接以确定集群的机器存活情况。LearnerCnxAcceptor接收器用于负责接收所有非Leader服务器的连接请求。
  3. Learner服务器开始和Leader建立连接。所有的Learner服务器在启动完毕后,会从Leader选举的投票结果中找到当前集群中的Leader服务器,然后与其建立连接。
  4. Leader服务器创建LearnerHandler。Leader接收到来自其他机器的连接创建请求后,会创建一个LearnerHandler实例。每个LearnerHandler实例都对应了一个Leader与Learner服务器之间的连接,其负责Leader和Learner服务器之间几乎所有的消息通信和数据同步。
  5. 向Leader注册。当和Leader建立起连接后,Learner就会开始向Leader进行注册------所谓的注册,其实就是将Learner服务器自己的基本信息发送给Leader服务器,我们称之为LearnerInfo,包括当前服务器的SID和服务器处理的最新的ZXID。
  6. Leader解析Learner信息,计算新的epoch。Leader服务器在接收到Learner的基本信息后,会解析出该Learner的SID和ZXID,然后根据该Learner的ZXID解析出其对应的epoch_of_learner,和当前Leader服务器的epoch_of_leader进行比较,如果该Learner的epoch_of_learner更大的话,那么就更新Leader的epoch:
  7. 发送Leader状态。计算出新的epoch之后,Leader会将该信息以一个LEADERINFO消息的形式发送给Learner,同时等待Learner的响应。
  8. Learner发送ACK消息。Follower在收到来自Leader的LEADERINFO消息后,会解析出epoch和ZXID,然后向Leader反馈一个ACKEPOCH响应。
  9. 数据同步。Leader服务器接收到Learner的这个ACK消息后,就可以开始与其进行数据同步了。
  10. .启动Leader和Learner服务器。当有过半的Learner已经完成了数据同步,那么Leader和Learner服务器实例就可以开始启动了。
    。。。。。。。。。。。。。。

6、Leader选举

了解了ZooKeeper集群中的三种服务器角色:Leader、Follower和Observer。接下来,我们将从Leader选举概述、算法分析和实现细节三方面来看看ZooKeeper是如何进行Leader选举的。

6.1、Leader选举概述

Leader选举是ZooKeeper中最重要的技术之一,也是保证分布式数据一致性的关键所在。在本节中,我们将先从整体上来对ZooKeeper的Leader选举进行介绍。

6.1.1、服务器启动时期的Leader选举

Leader选举的时候,需要注意的一点是,隐式条件便是ZooKeeper的集群规模至少是2台机器,这里我们以3台机器组成的服务器集群为例。在服务器集群初始化阶段,当有一台服务器(我们假设这台机器的myid为1,因此称其为Server1)启动的时候,它是无法完成Leader选举的,是无法进行Leader选举的。当第二台机器(同样,我们假设这台服务器的myid为2,称其为Server2)也启动后,此时这两台机器已经能够进行互相通信,每台机器都试图找到一个Leader,于是便进入了Leader选举流程。

1、每个Server会发出一个投票

由于是初始情况,因此对于Server1和Server2来说,都会将自己作为Leader服务器来进行投票,每次投票包含的最基本的元素包括:所推举的服务器的myid和ZXID,我们以(myid,ZXID)的形式来表示。因为是初始化阶段,因此无论是Server1还是Server2,都会投给自己,即Server1的投票为(1,0),Server2的投票为(2,0),然后各自将这个投票发给集群中其他所有机器。

2、 接收来自各个服务器的投票

每个服务器都会接收来自其他服务器的投票。集群中的每个服务器在接收到投票后,首先会判断该投票的有效性,包括检查是否是本轮投票、是否来自LOOKING状态的服务器。

3、 处理投票

在接收到来自其他服务器的投票后,针对每一个投票,服务器都需要将别人的投票和自己的投票进行PK,PK的规则如下。

  • 优先检查ZXID。ZXID比较大的服务器优先作为Leader。
  • 如果ZXID相同的话,那么就比较myid。myid比较大的服务器作为Leader服务器。

现在我们来看Server1和Server2实际是如何进行投票处理的。对于Server1来说,它自己的投票是(1,0),而接收到的投票为(2,0)。首先会对比两者的ZXID,因为都是0,所以无法决定谁是Leader。接下来会对比两者的myid,很显然,Server1发现接收到的投票中的myid是2,大于自己,于是就会更新自己的投票为(2,0),然后重新将投票发出去。而对于Server2来说,不需要更新自己的投票信息,只是再一次向集群中所有机器发出上一次投票信息即可。

4、 统计投票

每次投票后,服务器都会统计所有投票,判断是否已经有过半的机器接收到相同的投票信息。对于Server1和Server2服务器来说,都统计出集群中已经有两台机器接受了(2,0)这个投票信息。这里我们需要对"过半"的概念做一个简单的介绍。所谓"过半"就是指大于集群机器数量的一半,即大于或等于(n/2+1)。对于这里由3台机器构成的集群,大于等于2台即为达到"过半"要求。

那么,当Server1和Server2都收到相同的投票信息(2,0)的时候,即认为已经选出了Leader。

5、 改变服务器状态

一旦确定了Leader,每个服务器就会更新自己的状态:如果是Follower,那么就变更为FOLLOWING,如果是Leader,那么就变更为LEADING。

6.1.2、服务器运行期间的Leader选举

在ZooKeeper集群正常运行过程中,一旦选出一个Leader,那么所有服务器的集群角色一般不会再发生变化------也就是说,Leader服务器将一直作为集群的Leader,即使集群中有非Leader集群挂了或是有新机器加入集群也不会影响Leader。但是一旦Leader所在的机器挂了,那么整个集群将暂时无法对外服务,而是进入新一轮的Leader选举。服务器运行期间的Leader选举和启动时期的Leader选举基本过程是一致的。

我们假设当前正在运行的ZooKeeper服务器由3台机器组成,分别是Server1、Server2和Server3,当前的Leader是Server2。假设在某一个瞬间,Leader挂了,这个时候便开始了新一轮Leader选举。

  1. 变更状态:当Leader挂了之后,余下的非Observer服务器都会将自己的服务器状态变更为LOOKING,然后开始进入Leader选举流程。
  2. 每个Server会发出一个投票:在这个过程中,需要生成投票信息(myid,ZXID)。因为是运行期间,因此每个服务器上的ZXID可能不同,我们假定Server1的ZXID为123,而Server3的ZXID为122。在第一轮投票中,Server1和Server3都会投自己,即分别产生投票(1,123)和(3,122),然后各自将这个投票发给集群中所有机器。
  3. 接收来自各个服务器的投票。
  4. 处理投票:对于投票的处理,和上面提到的服务器启动期间的处理规则是一致的。在这个例子里面,由于Server1的ZXID为123,Server3的ZXID为122,那么显然,Server1会成为Leader。
  5. 统计投票。
  6. 改变服务器状态。

6.2、Leader选举的算法分析

在ZooKeeper中,提供了三种Leader选举的算法,分别是LeaderElection、UDP版本的FastLeaderElection和TCP版本的FastLeaderElection,可以通过在配置文件zoo.cfg中使用electionAlg属性来指定,分别使用数字0~3来表示。0代表LeaderElection,这是一种纯UDP实现的Leader选举算法;1代表UDP版本的FastLeaderElection,并且是非授权模式;2也代表UDP版本的FastLeaderElection,但使用授权模式;3代表TCP版本的FastLeaderElection。值得一提的是,从3.4.0版本开始,ZooKeeper废弃了0、1和2这三种Leader选举算法,只保留了TCP版本的FastLeaderElection选举算法。下文即仅对此算法进行介绍。

由于在官方文档以及一些外文资料中,对于概念的描述非常的"晦涩",因此在讲解ZooKeeper的Leader选举算法的时候,尽量使用一些外文的专有术语来保持一致性,以便于读者理解相关内容。

6.1.1、术语解释

首先我们对ZooKeeper的Leader选举算法介绍中会出现的一些专有术语进行简单介绍,以便大家更好地理解本贴内容。

  • SID:服务器ID,SID是一个数字,用来唯一标识一台ZooKeeper集群中的机器,每台机器不能重复,和myid的值一致。
  • ZXID:事务ID,ZXID是一个事务ID,用来唯一标识一次服务器状态的变更。在某一个时刻,集群中每台机器的ZXID值不一定全都一致,这和ZooKeeper服务器对于客户端"更新请求"的处理逻辑有关。
  • Vo t e:投票,Leader选举,顾名思义必须通过投票来实现。当集群中的机器发现自己无法检测到Leader机器的时候,就会开始尝试进行投票。
  • Quorum:过半机器数。这是整个Leader选举算法中最重要的一个术语,我们可以把这个术语理解为是一个量词,指的是ZooKeeper集群中过半的机器数,如果集群中总的机器数是n的话,那么可以通过下面这个公式来计算quorum的值:
    例如,如果集群机器总数是3,那么quorum就是2。

6.1.2、算法分析

进入Leader选举

当ZooKeeper集群中的一台服务器出现以下两种情况之一时,就会开始进入Leader选举。

  • 服务器初始化启动。
  • 服务器运行期间无法和Leader保持连接。

而当一台机器进入Leader选举流程时,当前集群也可能会处于以下两种状态。

  • 集群中本来就已经存在一个Leader。
  • 集群中确实不存在Leader。

我们首先来看第一种已经存在Leader的情况。这种情况通常是集群中的某一台机器启动比较晚,在它启动之前,集群已经可以正常工作,即已经存在了一台Leader服务器。针对这种情况,当该机器试图去选举Leader的时候,会被告知当前服务器的Leader信息,对于该机器来说,仅仅需要和Leader机器建立起连接,并进行状态同步即可。

下面我们重点来看在集群中Leader不存在的情况下,如何进行Leader选举。
开始第一次投票

通常有两种情况会导致集群中不存在Leader,一种情况是在整个服务器刚刚初始化启动时,此时尚未产生一台Leader服务器;另一种情况就是在运行期间当前Leader所在的服务器挂了。无论是哪种情况,此时集群中的所有机器都处于一种试图选举出一个Leader的状态,我们把这种状态称为"LOOKING",意思是说正在寻找Leader。当一台服务器处于LOOKING状态的时候,那么它就会向集群中所有其他机器发送消息,我们称这个消息为"投票"。

在这个投票消息中包含了两个最基本的信息:所推举的服务器的SID和ZXID,分别表示了被推举服务器的唯一标识和事务ID。下文中我们将以"(SID,ZXID)"这样的形式来标识一次投票信息。举例来说,如果当前服务器要推举SID为1、ZXID为8的服务器成为Leader,那么它的这次投票信息可以表示为(1,8)。

我们假设ZooKeeper由5台机器组成,SID分别为1、2、3、4和5,ZXID分别为9、9、9、8和8,并且此时SID为2的机器是Leader服务器。某一时刻,1和2所在的机器出现故障,因此集群开始进行Leader选举。

在第一次投票的时候,由于还无法检测到集群中其他机器的状态信息,因此每台机器都是将自己作为被推举的对象来进行投票。于是SID为3、4和5的机器,投票情况分别为:(3,9)、(4,8)和(5,8)。

变更投票

集群中的每台机器发出自己的投票后,也会接收到来自集群中其他机器的投票。每台机器都会根据一定的规则,来处理收到的其他机器的投票,并以此来决定是否需要变更自己的投票。这个规则也成为了整个Leader选举算法的核心所在。为了便于描述,我们首先定义一些术语。

  • vote_sid:接收到的投票中所推举Leader服务器的SID。
  • vote_zxid:接收到的投票中所推举Leader服务器的ZXID。
  • self_sid:当前服务器自己的SID。
  • self_zxid:当前服务器自己的ZXID。

每次对于收到的投票的处理,都是一个对(vote_sid,vote_zxid)和(self_sid,self_zxid)对比的过程。

  • 规则1:如果vote_zxid大于self_zxid,就认可当前收到的投票,并再次将该投票发送出去。
  • 规则2:如果vote_zxid小于self_zxid,那么就坚持自己的投票,不做任何变更。
  • 规则3:如果vote_zxid等于self_zxid,那么就对比两者的SID。如果vote_sid大于self_sid,那么就认可当前接收到的投票,并再次将该投票发送出去。
  • 规则4:如果vote_zxid等于self_zxid,并且vote_sid小于self_sid,那么同样坚持自己的投票,不做变更。

根据上面这个规则,我们结合下图来分析上面提到的5台机器组成的ZooKeeper集群的投票变更过程。

每台机器都把投票发出后,同时也会接收到来自另外两台机器的投票。

  • 对于Server3来说,它接收到了(4,8)和(5,8)两个投票,对比后,由于自己的ZXID要大于接收到的两个投票,因此不需要做任何变更。
  • 对于Server4来说,它接收到了(3,9)和(5,8)两个投票,对比后,由于(3,9)这个投票的ZXID大于自己,因此需要变更投票为(3,9),然后继续将这个投票发送给另外两台机器。
  • 对于Server4来说,它接收到了(3,9)和(5,8)两个投票,对比后,由于(3,9)这个投票的ZXID大于自己,因此需要变更投票为(3,9),然后继续将这个投票发送给另外两台机器。
  • 同样,对于Server5来说,它接收到了(3,9)和(4,8)两个投票,对比后,由于(3,9)这个投票的ZXID大于自己,因此需要变更投票为(3,9),然后继续将这个投票发送给另外两台机器。

确定Leader

经过这第二次投票后,集群中的每台机器都会再次收到其他机器的投票,然后开始统计投票。如果一台机器收到了超过半数的相同的投票,那么这个投票对应的SID机器即为Leader。

如上图所示的Leader选举例子中,因为ZooKeeper集群的总机器数为5台,那么

也就是说,只要收到3个或3个以上(含当前服务器自身在内)一致的投票即可。在这里,Server3、Server4和Server5都投票(3,9),因此确定了Server3为Leader。

小结

简单地说,通常哪台服务器上的数据越新,那么越有可能成为Leader,原因很简单,数据越新,那么它的ZXID也就越大,也就越能够保证数据的恢复。当然,如果集群中有几个服务器具有相同的ZXID,那么SID较大的那台服务器成为Leader。

6.3、Leader选举的实现细节

介绍了整个Leader选举的算法设计。从算法复杂度来说,FastLeaderElection算法的设计并不复杂,但在真正的实现过程中,对于一个需要应用在生产环境的产品来说,还是有很多实际问题需要解决。在本节中,我们就来看看ZooKeeper中对FastLeaderElection的实现。

6.3.1、服务器状态

为了能够清楚地对ZooKeeper集群中每台机器的状态进行标识,在org.apache.zookeeper.server.quorum.QuorumPeer.ServerState类中列举了4种服务器状态,分别是:LOOKING、FOLLOWING、LEADING和OBSERVING。

  • LOOKING:寻找Leader状态。当服务器处于该状态时,它会认为当前集群中没有Leader,因此需要进入Leader选举流程。
  • FOLLOWING:跟随者状态,表明当前服务器角色是Follower。
  • LEADING:领导者状态,表明当前服务器角色是Leader。
  • OBSERVING:观察者状态,表明当前服务器角色是Observer。

6.3.2、投票数据结构

Leader的选举过程是通过投票来实现的,同时每个投票中包含两个最基本的信息:所推举服务器的SID和ZXID。现在我们来看在ZooKeeper中对Vote数据结构的定义,如下图所示:

我们以在org.apache.zookeeper.server.quorum.Vote类中查看其完整的定义,下表中列举了Vote中的几个属性:

6.3.3、QuorumCnxManager:网络I/O

ClientCnxn是ZooKeeper客户端中用于处理网络I/O的一个管理器。在Leader选举的过程中也有类似的角色,那就是QuorumCnxManager------每台服务器启动的时候,都会启动一个QuorumCnxManager,负责各台服务器之间的底层Leader选举过程中的网络通信。

6.3.4、消息队列

在QuorumCnxManager这个类内部维护了一系列的队列,用于保存接收到的、待发送的消息,以及消息的发送器。除接收队列以外,这里提到的所有队列都有一个共同点------按SID分组形成队列集合,我们以发送队列为例来说明这个分组的概念。假设集群中除自身外还有4台机器,那么当前服务器就会为这4台服务器分别创建一个发送队列,互不干扰。

  • recvQueue:消息接收队列,用于存放那些从其他服务器接收到的消息。
  • queueSendMap:消息发送队列,用于保存那些待发送的消息。queueSendMap是一个Map,按照SID进行分组,分别为集群中的每台机器分配了一个单独队列,从而保证各台机器之间的消息发送互不影响。
  • senderWorkerMap:发送器集合。每个SendWorker消息发送器,都对应一台远程ZooKeeper服务器,负责消息的发送。同样,在senderWorkerMap中,也按照SID进行了分组。
  • lastMessageSent:最近发送过的消息。在这个集合中,为每个SID保留最近发送过的一个消息。

6.3.5、建立连接

为了能够进行互相投票,ZooKeeper集群中的所有机器都需要两两建立起网络连接。QuorumCnxManager在启动的时候,会创建一个ServerSocket来监听Leader选举的通信端口(Leader选举的通信端口默认是3888)。开启端口监听后,ZooKeepr就能够不断地接收到来自其他服务器的"创建连接"请求,在接收到其他服务器的TCP连接请求时,会交由receiveConnection函数来处理。为了避免两台机器之间重复地创建TCP连接,ZooKeeper设计了一种建立TCP连接的规则:只允许SID大的服务器主动和其他服务器建立连接,否则断开连接。在ReceiveConnection函数中,服务器通过对比自己和远程服务器的SID值,来判断是否接受连接请求。如果当前服务器发现自己的SID值更大,那么会断开当前连接,然后自己主动去和远程服务器建立连接。

一旦建立起连接,就会根据远程服务器的SID来创建相应的消息发送器SendWorker和消息接收器RecvWorker,并启动他们。

6.3.6、消息接收与发送

消息的接收过程是由消息接收器RecvWorker来负责的。在上面的讲解中,我们已经提到了ZooKeeper会为每个远程服务器分配一个单独的RecvWorker,因此,每个RecvWorker只需要不断地从这个TCP连接中读取消息,并将其保存到recvQueue队列中。

消息的发送过程也比较简单,由于ZooKeeper同样也已经为每个远程服务器单独分别分配了消息发送器SendWorker,那么每个SendWorker只需要不断地从对应的消息发送队列中获取出一个消息来发送即可,同时将这个消息放入lastMessageSent中来作为最近发送过的消息。在SendWorker的具体实现中,有一个细节需要我们注意一下:一旦ZooKeeper发现针对当前远程服务器的消息发送队列为空,那么这个时候就需要从lastMessageSent中取出一个最近发送过的消息来进行再次发送。这个细节的处理主要是为了解决这样一类分布式问题:接收方在消息接收前,或者是在接收到消息后服务器挂掉了,导致消息尚未被正确处理。那么如此重复发送是否会导致其他问题呢?当然,这里可以放心的一点是,ZooKeeper能够保证接收方在处理消息的时候,会对重复消息进行正确的处理。

6.3.7、FastLeaderElection:选举算法的核心部分

下面我们来看Leader选举的核心算法部分的实现。在讲解之前,我们首先约定几个概念。

  • 外部投票:特指其他服务器发来的投票。
  • 内部投票:服务器自身当前的投票。
  • 选举轮次:ZooKeeper服务器Leader选举的轮次,即logicalclock。
  • PK:指对内部投票和外部投票进行一个对比来确定是否需要变更内部投票。

选票管理

我们已经讲解了,在QuorumCnxManager中,ZooKeeper是如何管理服务器之间的投票发送和接收的,现在我们来看对于选票的管理。下图所示是选票管理过程中相关组件之间的协作关系。

  • sendqueue:选票发送队列,用于保存待发送的选票。
  • recvqueue:选票接收队列,用于保存接收到的外部投票。
  • WorkerReceiver:选票接收器。该接收器会不断地从QuorumCnxManager中获取出其他服务器发来的选举消息,并将其转换成一个选票,然后保存到recvqueue队列中去。在选票的接收过程中,如果发现该外部投票的选举轮次小于当前服务器,那么就直接忽略这个外部投票,同时立即发出自己的内部投票。当然,如果当前服务器并不是LOOKING状态,即已经选举出了Leader,那么也将忽略这个外部投票,同时将Leader信息以投票的形式发送出去。另外,对于选票接收器,还有一个细节需要注意,如果接收到的消息来自Observer服务器,那么就忽略该消息,并将自己当前的投票发送出去。
  • WorkerSender:选票发送器,会不断地从sendqueue队列中获取待发送的选票,并将其传递到底层QuorumCnxManager中去。

算法核心

在上图中,我们可以看到FastLeaderElection模块是如何与底层的网络I/O进行交互的,其中不难发现,在"选举算法"中将会对接收到的选票进行处理。下面我们就来看看这个选举过程的核心算法实现,下图展示了Leader选举算法实现的流程示意图。

上图中展示了Leader选举算法的基本流程,其实也就是lookForLeader方法的逻辑。当ZooKeeper服务器检测到当前服务器状态变成LOOKING时,就会触发Leader选举,即调用lookForLeader方法来进行Leader选举。

  1. 自增选举轮次。在FastLeaderElection实现中,有一个logicalclock属性,用于标识当前Leader的选举轮次,ZooKeeper规定了所有有效的投票都必须在同一轮次中。ZooKeeper在开始新一轮的投票时,会首先对logicalclock进行自增操作。
  2. 初始化选票。在开始进行新一轮的投票之前,每个服务器都会首先初始化自己的选票。在初始化阶段,每台服务器都会将自己推举为Leader,下表展示了一个初始化的选票。
  3. 发送初始化选票。在完成选票的初始化后,服务器就会发起第一次投票。ZooKeeper会将刚刚初始化好的选票放入sendqueue队列中,由发送器WorkerSender负责发送出去。
  4. 接收外部投票。每台服务器都会不断地从recvqueue队列中获取外部投票。如果服务器发现无法获取到任何的外部投票,那么就会立即确认自己是否和集群中其他服务器保持着有效连接。如果发现没有建立连接,那么就会马上建立连接。如果已经建立了连接,那么就再次发送自己当前的内部投票。
  5. 判断选举轮次。当发送完初始化选票之后,接下来就要开始处理外部投票了。在处理外部投票的时候,会根据选举轮次来进行不同的处理。a.外部投票的选举轮次大于内部投票:如果服务器发现自己的选举轮次已经落后于该外部投票对应服务器的选举轮次,那么就会立即更新自己的选举轮次(logicalclock),并且清空所有已经收到的投票,然后使用初始化的投票来进行PK以确定是否变更内部投票(关于P K的逻辑会在步骤6中统一讲解),最终再将内部投票发送出去。b.外部投票的选举轮次小于内部投票:如果接收到的选票的选举轮次落后于服务器自身的,那么ZooKeeper就会直接忽略该外部投票,不做任何处理,并返回步骤4。c.外部投票的选举轮次和内部投票一致:这也是绝大多数投票的场景,如果外部投票的选举轮次和内部投票一致的话,那么就开始进行选票PK。总的来说,只有在同一个选举轮次的投票才是有效的投票。
  6. 选票PK。在步骤5中提到,在收到来自其他服务器有效的外部投票后,就要进行选票PK了------也就是FastLeaderElection.totalOrderPredicate方法的核心逻辑。选票PK的目的是为了确定当前服务器是否需要变更投票,主要从选举轮次、ZXID和SID三个因素来考虑,具体条件如下:在选票PK的时候依次判断,符合任意一个条件就需要进行投票变更。a.如果外部投票中被推举的Leader服务器的选举轮次大于内部投票,那么就需要进行投票变更。b.如果选举轮次一致的话,那么就对比两者的ZXID。如果外部投票的ZXID大于内部投票,那么就需要进行投票变更。c.如果两者的ZXID一致,那么就对比两者的SID。如果外部投票的SID大于内部投票,那么就需要进行投票变更。
  7. 变更投票:通过选票PK后,如果确定了外部投票优于内部投票(所谓的"优于",是指外部投票所推举的服务器更适合成为Leader),那么就进行投票变更------使用外部投票的选票信息来覆盖内部投票。变更完成后,再次将这个变更后的内部投票发送出去。
  8. 选票归档:无论是否进行了投票变更,都会将刚刚收到的那份外部投票放入"选票集合"recvset中进行归档。recvset用于记录当前服务器在本轮次的Leader选举中收到的所有外部投票------按照服务器对应的SID来区分,例如,{(1,vote1),(2,vote2),...}。
  9. 完成了选票归档之后,就可以开始统计投票了。统计投票的过程就是为了统计集群中是否已经有过半的服务器认可了当前的内部投票。如果确定已经有过半的服务器认可了该内部投票,则终止投票。否则返回步骤4。
  10. 更新服务器状态。统计投票后,如果已经确定可以终止投票,那么就开始更新服务器状态。服务器会首先判断当前被过半服务器认可的投票所对应的Leader服务器是否是自己,如果是自己的话,那么就会将自己的服务器状态更新为LEADING。如果自己不是被选举产生的Leader的话,那么就会根据具体情况来确定自己是FOLLOWING或是OBSERVING。

以上10个步骤,就是FastLeaderElection选举算法的核心步骤,其中步骤4~9会经过几轮循环,直到Leader选举产生。另外还有一个细节需要注意,就是在完成步骤9之后,如果统计投票发现已经有过半的服务器认可了当前的选票,这个时候,ZooKeeper并不会立即进入步骤10来更新服务器状态,而是会等待一段时间(默认是200毫秒)来确定是否有新的更优的投票。

7、各服务器角色介绍

我们已经了解到,在ZooKeeper集群中,分别有Leader、Follower和Observer三种类型的服务器角色。

7.1、Leader

Leader服务器是整个ZooKeeper集群工作机制中的核心,其主要工作有以下两个:

  • 事务请求的唯一调度和处理者,保证集群事务处理的顺序性。
  • 集群内部各服务器的调度者。

7.1.1、请求处理链

使用责任链模式来处理每一个客户端请求是ZooKeeper的一大特色。服务器启动过程中,我们已经提到,在每一个服务器启动的时候,都会进行请求处理链的初始化,Leader服务器的请求处理链如下图所示:

从上图中可以看到,从PrepRequestProcessor到FinalRequestProcessor,前后一共7个请求处理器组成了Leader服务器的请求处理链。

。。。。。。。。。。。

7.2、Follower

从角色名字上可以看出,Follower服务器是ZooKeeper集群状态的跟随者,其主要工作有以下三个:

  • 处理客户端非事务请求,转发事务请求给Leader服务器。
  • 参与事务请求Proposal的投票。
  • 参与Leader选举投票。

和Leader服务器一样,Follower也同样使用了采用责任链模式组装的请求处理链来处理每一个客户端请求,由于不需要负责对事务请求的投票处理,因此相对来说Follower服务器的请求处理链会简单一些,其请求处理链如下图所示:

。。。。。。。

7.3、Observer

Observer是ZooKeeper自3.3.0版本开始引入的一个全新的服务器角色。从字面意思看,该服务器充当了一个观察者的角色------其观察ZooKeeper集群的最新状态变化并将这些状态变更同步过来。Observer服务器在工作原理上和Follower基本是一致的,对于非事务请求,都可以进行独立的处理,而对于事务请求,则会转发给Leader服务器进行处理。和Follower唯一的区别在于,Observer不参与任何形式的投票,包括事务请求Proposal的投票和Leader选举投票。简单地讲,Observer服务器只提供非事务服务,通常用于在不影响集群事务处理能力的前提下提升集群的非事务处理能力。

另外,Observer的请求处理链路和Follower服务器也非常相近,如下图所示:

另外需要注意的一点是,虽然在上图中,Observer服务器在初始化阶段会将SyncRequestProcessor处理器也组装上去,但是在实际运行过程中,Leader服务器不会将事务请求的投票发送给Observer服务器。

7.4、 集群间消息通信

在整个ZooKeeper集群工作过程中,都是由Leader服务器来负责进行各服务器之间的协调,同时,各服务器之间的网络通信,都是通过不同类型的消息传递来实现的。在本节中,我们将围绕ZooKeeper集群间的消息通信来讲解ZooKeeper集群各服务器之间是如何进行协调的。

ZooKeeper的消息类型大体上可以分为四类,分别是:数据同步型、服务器初始化型、请求处理型和会话管理型。

数据同步型

数据同步型消息是指在Learner和Leader服务器进行数据同步的时候,网络通信所用到的消息,通常有DIFF、TRUNC、SNAP和UPTODATE四种。下表中分别对这四种消息类型进行了详细介绍。

服务器初始化型

服务器初始化型消息是指在整个集群或是某些新机器初始化的时候,Leader和Learner之间相互通信所使用的消息类型,常见的有OBSERVERINFO、FOLLOWERINFO、LEADERINFO、ACKEPOCH和NEWLEADER五种。下表中对这五种消息类型进行了详细介绍。


请求处理型

请求处理型消息是指在进行请求处理的过程中,Leader和Learner服务器之间互相通信所使用的消息,常见的有REQUEST、PROPOSAL、ACK、COMMIT、INFORM和SYNC六种。下表中对这六种消息类型进行了详细介绍。


会话管理型

会话管理型消息是指ZooKeeper在进行会话管理的过程中,和Learner服务器之间互相通信所使用的消息,常见的有PING和REVALIDATE两种。下表中对这两种消息类型进行了详细的介绍。

相关推荐
weisian1512 小时前
Redis篇--常见问题篇7--缓存一致性2(分布式事务框架Seata)
redis·分布式·缓存
不能只会打代码2 小时前
Java并发编程框架之综合案例—— 分布式日志分析系统(七)
java·开发语言·分布式·java并发框架
Elastic 中国社区官方博客3 小时前
如何通过 Kafka 将数据导入 Elasticsearch
大数据·数据库·分布式·elasticsearch·搜索引擎·kafka·全文检索
马剑威(威哥爱编程)3 小时前
分布式Python计算服务MaxFrame使用心得
开发语言·分布式·python·阿里云
学计算机的睿智大学生4 小时前
Hadoop的生态系统所包含的组件
大数据·hadoop·分布式
神秘打工猴6 小时前
Kafka 监控都有哪些?
分布式·kafka
Kobebryant-Manba8 小时前
kafka基本概念
分布式·学习·kafka
rainoway9 小时前
CRDT宝典 - yata算法
前端·分布式·算法
hanbarger9 小时前
分布式通信,微服务协调组件,zookeeper
分布式·zookeeper·中间件