ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,它包含一个简单的原语集,分布式应用程序可以基于它实现同步服务,配置维护和命名服务等。 Zookeeper是hadoop的一个子项目,其发展历程无需赘述。在分布式应用中,由于工程师不能很好地使用锁机制,以及基于消息的协调机制不适合在 某些应用中使用,因此需要有一种可靠的、可扩展的、分布式的、可配置的协调机制来统一系统的状态。Zookeeper的目的就在于此。
(1)最终一致性:client不论连接到哪个Server,展示给它都是同一个视图,这是zookeeper最重要的性能。
(2)可靠性:具有简单、健壮、良好的性能,如果消息m被到一台服务器接受,那么它将被所有的服务器接受。
(3)实时性:Zookeeper保证客户端将在一个时间间隔范围内获得服务器的更新信息,或者服务器失效的信息。但由于网络延时等原因,Zookeeper不能保证两个客户端能同时得到刚更新的数据,如果需要最新数据,应该在读数据之前调用sync()接口。
(4)等待无关(wait-free):慢的或者失效的client不得干预快速的client的请求,使得每个client都能有效的等待。
(5)原子性:更新只能成功或者失败,没有中间状态。
(6)顺序性:包括全局有序和偏序两种:全局有序是指如果在一台服务器上消息a在消息b前发布,则在所有Server上消息a都将在消息b前被发布;偏序是指如果一个消息b在消息a后被同一个发送者发布,a必将排在b前面。
Zookeeper 的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式(选主)和 广播模式(同步)。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以 后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。
为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加上了 zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新 的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。
每个Server在工作过程中有三种状态:
(1)LOOKING:当前Server不知道leader是谁,正在搜寻
(2)LEADING:当前Server即为选举出来的leader
(3)FOLLOWING:leader已经选举出来,当前Server与之同步
如上图,Zookeeper中的角色主要有以下三类:
ZNode数据存储的最小单元。在Zookeeper中,类似文件系统的存储结构,被Zookeeper抽象成了树,树中的每一个节点(Node)被叫做ZNode。ZNode中维护了一个数据结构,用于记录ZNode中数据更改的版本号以及ACL(Access Control List)的变更。
有了这些数据的版本号以及其更新的Timestamp,Zookeeper就可以验证客户端请求的缓存是否合法,并协调更新。
而且,当Zookeeper的客户端执行更新或者删除操作时,都必须要带上要修改的对应数据的版本号。如果Zookeeper检测到对应的版本号不存在,则不会执行这次更新。如果合法,在ZNode中数据更新之后,其对应的版本号也会一起更新。
备注:这套版本号的逻辑,其实很多框架都在用,例如RocketMQ中,Broker向NameServer注册的时候,也会带上这样一个版本号,叫DateVersion。
接下来我们来详细看一下这个维护版本号相关数据的数据结构,它叫Stat Structure,其字段有:
举个例子,通过stat命令,我们可以查看某个ZNode中Stat Structure具体的值。
关于这里的epoch、zxid是Zookeeper集群相关的东西,后面会详细的对其进行介绍。
Zookeeper是一个树状的文件目录结构,与 Unix 文件系统很类似。树中每个节点可以称作为一个ZNode,每一个ZNode都可以通过其路径唯一标识,最重要的是我们可以对每个 ZNode 进行增删改查。
客户端与Zookeeper服务端断开连接后,节点仍然存在不会被删除,这样的节点就叫做持久节点。
持久有序节点是在上面持久节点的特性上加上了有序性,有序性的意思是服务向Zookeeper注册信息时,Zookeeper 根据注册顺序给每个节点编号。
客户端与Zookeeper服务端断开连接后,该节点被删除。它的生命周期和客户端会话绑定,一旦客户端会话失效,那么这个客户端创建的所有临时节点都会被移除。
注意:临时节点下不存在子节点;持久节点下可以存在临时节点。
临时有序节点是在临时节点的基础上再加上有序性,跟持久有序节点类似。
节点监听是Zookeeper最重要的特性之一,客户端可以监听任意节点,节点有任何变化 Zookeeper 可以通过回调的方式通知给客户端,这样客户端不用轮询就可以及时感知节点变化。
如下图所示,客户端(client)开始监听临时节点 1,因某种原因临时节点 1 被删除了,Zookeeper 通过回调将变化通知给 client 了。
ACL(Access Control List)用于控制ZNode的相关权限,其权限控制和Linux中的类似。Linux中权限种类分为了三种,分别是读、写、执行,分别对应的字母是r、w、x。其权限粒度也分为三种,分别是拥有者权限、群组权限、其他组权限,举个例子:
drwxr-xr-x3USERNAMEGROUP1.0K31518:19dir_name
什么叫粒度?粒度是对权限所作用的对象的分类,把上面三种粒度换个说法描述就是拥有者用户(Owner)、用户所属的组(Group)、其他组(Other)用户的权限划分,这应该算是一种权限控制的标准了,典型的三段式。
Zookeeper中虽然也是三段式,但是两者对粒度的划分存在区别。Zookeeper中的三段式为Scheme、ID、Permissions,含义分别为权限机制、允许访问的用户和具体的权限。
Scheme代表了一种权限模式,有以下5种类型:
同时权限种类也有五种:
这个权限也有缩写,举个例子:
getAcl方法用户查看对应的ZNode的权限,如图,我们可以输出的结果呈三段式。分别是:
了解了Zookeeper的Version机制,我们可以继续探索Zookeeper的Session机制了。
我们知道,Zookeeper中有4种类型的节点,分别是持久节点、持久顺序节点、临时节点和临时顺序节点。
在之前的文章我们聊到过,客户端如果创建了临时节点,并在之后断开了连接,那么所有的临时节点就都会被删除。实际上断开连接的说话不是很精确,应该是说客户端建立连接时的Session过期之后,其创建的所有临时节点就会被全部删除。
那么Zookeeper是怎么知道哪些临时节点是由当前客户端创建的呢?
答案是Stat Structure中的ephemeralOwner(临时节点的Owner)字段
上面说过,如果当前是临时顺序节点,那么ephemeralOwner则存储了创建该节点的Owner的SessionID,有了SessionID,自然就能和对应的客户端匹配上,当Session失效之后,才能将该客户端创建的所有临时节点全部删除。
对应的服务在创建连接的时候,必须要提供一个带有所有服务器、端口的字符串,单个之间逗号相隔,举个例子。
127.0.0.1:3000:2181,127.0.0.1:2888,127.0.0.1:3888
Zookeeper的客户端收到这个字符串之后,会从中随机选一个服务、端口来建立连接。如果连接在之后断开,客户端会从字符串中选择下一个服务器,继续尝试连接,直到连接成功。
除了这种最基本的IP+端口,在Zookeeper的3.2.0之后的版本中还支持连接串中带上路径,举个例子。
127.0.0.1:3000:2181,127.0.0.1:2888,127.0.0.1:3888/app/a
这样一来,/app/a就会被当成当前服务的根目录,在其下创建的所有的节点路经都会带上前缀/app/a。举个例子,我创建了一个节点/node_name,那其完整的路径就会为/app/a/node_name。这个特性特别适用于多租户的环境,对于每个租户来说,都认为自己是最顶层的根目录/。
当Zookeeper的客户端和服务器都建立了连接之后,客户端会拿到一个64位的SessionID和密码。这个密码是干什么用的呢?我们知道Zookeeper可以部署多个实例,如果客户端断开了连接又和另外的Zookeeper服务器建立了连接,那么在建立连接使就会带上这个密码。该密码是Zookeeper的一种安全措施,所有的Zookeeper节点都可以对其进行验证。这样一来,即使连接到了其他Zookeeper节点,Session同样有效。
Session过期有两种情况,分别是:
过了指定的失效时间指定时间内客户端没有发送心跳对于第一种情况,过期时间会在Zookeeper客户端建立连接的时候传给服务器,这个过期时间的范围目前只能在2倍tickTime和20倍tickTime之间。
ticktime是Zookeeper服务器的配置项,用于指定客户端向服务器发送心跳的间隔,其默认值为tickTime=2000,单位为毫秒
而这套Session的过期逻辑由Zookeeper的服务器维护,一旦Session过期,服务器会立即删除由Client创建的所有临时节点,然后通知所有正在监听这些节点的客户端相关变更。
对于第二种情况,Zookeeper中的心跳是通过PING请求来实现的,每隔一段时间,客户端都会发送PING请求到服务器,这就是心跳的本质。心跳使服务器感知到客户端还活着,同样的让客户端也感知到和服务器的连接仍然是有效的,这个间隔就是tickTime,默认为2秒。
会话的生命周期(lifetime)是指会话从创建到结束的时期,无论会话正常关闭还是因超时而导致过期。一个会话的主要可能状态大多是简单明了的:CONNECTING、CONNECTED、CLOSED和NOT_CONNECTED。状态的转换依赖于发生在客户端与服务之间的各种事件(见下图)。
状态及状态的转换:
注意:发生网络分区时等待CONNECTING
如果一个客户端与服务器因超时而断开连接,客户端仍然保持CONNECTING状态。如果因网络分区问题导致客户端与ZooKeeper集合被隔离而发生连接断开,那么其状态将会一直保持,直到显式地关闭这个会话,或者分区问题修复后,客户端能够获悉ZooKeeper服务器发送的会话已经过期。发生这种行为是因为ZooKeeper集合对声明会话超时负责,而不是客户端负责。直到客户端获悉ZooKeeper会话过期,否则客户端不能声明自己的会话过期。然而,客户端可以选择关闭会话。
创建一个会话时,你需要设置会话超时这个重要的参数,这个参数设置了ZooKeeper服务允许会话被声明为超时之前存在的时间。如果经过时间t之后服务接收不到这个会话的任何消息,服务就会声明会话过期。而在客户端侧,如果经过t/3的时间未收到任何消息,客户端将向服务器发送心跳消息。在经过2t/3时间后,ZooKeeper客户端开始寻找其他的服务器,而此时它还有t/3时间去寻找。
注意:客户端会尝试连接哪一个服务器?
在仲裁模式下,客户端有多个服务器可以连接,而在独立模式下,客户端只能尝试重新连接单个服务器。在仲裁模式中,应用需要传递可用的服务器列表给客户端,告知客户端可以连接的服务器信息并选择一个进行连接。
当尝试连接到一个不同的服务器时,非常重要的是,这个服务器的ZooKeeper状态要与最后连接的服务器的ZooKeeper状态保持最新。ZooKeeper通过在服务中排序更新操作来决定状态是否最新。ZooKeeper确保每一个变化相对于所有其他已执行的更新是完全有序的。因此,如果一个客户端在位置i观察到一个更新,它就不能连接到只观察到i'在ZooKeeper实现中,系统根据每一个更新建立的顺序来分配给事务标识符。下图描述了在重连情况下事务标识符(zkid)的使用。当客户端因超时与s1 断开连接后,客户端开始尝试连接s2 ,但s2 延迟于客户端所知的变化。然而,s3 对这个变化的情况与客户端保持一致,所以s3 可以安全连接。
客户端重连的例子
了解完ZNode和Session,我们终于可以来继续下一个关键功能Watch了,在上面的内容中也不止一次的提到监听(Watch)这个词。首先用一句话来概括其作用
给某个节点注册监听器,该节点一旦发生变更(例如更新或者删除),监听者就会收到一个Watch Event。
和ZNode中有多种类型一样,Watch也有多种类型,分别是一次性Watch和永久性Watch。
一次性的Watch可以在调用getData()、getChildren()和exists()等方法时在参数中进行设置,永久性的Watch则需要调用addWatch()来实现。
并且一次性的Watch会存在问题,因为在Watch触发的事件到达客户端、再到客户端设立新的Watch,是有一个时间间隔的。而如果在这个时间间隔中发生的变更,客户端则无法感知。
利用 ZooKeeper 可以非常方便构建一系列分布式应用中都会涉及到的核心功能。
多个开源项目中都应用到了 ZooKeeper,例如 HBase, Spark, Flink, Storm, Kafka, Dubbo 等等。
数据发布/订阅的一个常见的场景是配置中心,发布者把数据发布到 ZooKeeper 的一个或一系列的节点上,供订阅者进行数据订阅,达到动态获取数据的目的。
配置信息一般有几个特点:
ZooKeeper 采用的是推拉结合的方式。
实现的思路可以如下。
负载均衡是一种手段,用来把对某种资源的访问分摊给不同的设备,从而减轻单点的压力。
实现的思路:
命名服务就是提供名称的服务。ZooKeeper 的命名服务有两个应用方面。
利用 ZooKeeper 顺序节点的特性,制作分布式的序列号生成器,或者叫 id 生成器。(分布式环境下使用作为数据库 id,另外一种是 UUID(缺点:没有规律)),ZooKeeper 可以生成有顺序的容易理解的同时支持分布式环境的编号。
在创建节点时,如果设置节点是有序的,则 ZooKeeper 会自动在你的节点名后面加上序号,上面说容易理解,是比如说这样,你要获得订单的 id,你可以在创建节点时指定节点名为 order_[日期]_xxxxxx,这样一看就大概知道是什么时候的订单。
/ └── /order ├── /order-date1-000000000000001 ├── /order-date2-000000000000002 ├── /order-date3-000000000000003 ├── /order-date4-000000000000004 └── /order-date5-000000000000005
一种典型的分布式系统机器间的通信方式是心跳。心跳检测是指分布式环境中,不同机器之间需要检测彼此是否正常运行。传统的方法是通过主机之间相互 PING 来实现,又或者是建立长连接,通过 TCP 连接固有的心跳检测机制来实现上层机器的心跳检测。
如果使用 ZooKeeper,可以基于其临时节点的特性,不同机器在 ZooKeeper 的一个指定节点下创建临时子节点,不同机器之间可以根据这个临时节点来判断客户端机器是否存活。
好处就是检测系统和被检系统不需要直接相关联,而是通过 ZooKeeper 节点来关联,大大减少系统的耦合。
集群管理主要指集群监控和集群控制两个方面。前者侧重于集群运行时的状态的收集,后者则是对集群进行操作与控制。开发和运维中,面对集群,经常有如下需求:
分布式集群管理体系中有一种传统的基于 Agent 的方式,就是在集群每台机器部署 Agent 来收集机器的 CPU、内存等指标。但是如果需要深入到业务状态进行监控,比如一个分布式消息中间件中,希望监控每个消费者对消息的消费状态,或者一个分布式任务调度系统中,需要对每个机器删的任务执行情况进行监控。对于这些业务紧密耦合的监控需求,统一的 Agent 是不太合适的。
利用 ZooKeeper 实现集群管理监控组件的思路:
在管理机器上线/下线的场景中,为了实现自动化的线上运维,我们必须对机器的上/下线情况有一个全局的监控。通常在新增机器的时候,需要首先将指定的 Agent 部署到这些机器上去。Agent 部署启动之后,会首先向 ZooKeeper 的指定节点进行注册,具体的做法就是在机器列表节点下面创建一个临时子节点,例如 /machine/[Hostname](下文我们以“主机节点”代表这个节点),如下图所示。
当 Agent 在 ZooKeeper 上创建完这个临时子节点后,对 /machines 节点关注的监控中心就会接收到“子节点变更”事件,即上线通知,于是就可以对这个新加入的机器开启相应的后台管理逻辑。另一方面,监控中心同样可以获取到机器下线的通知,这样便实现了对机器上/下线的检测,同时能够很容易的获取到在线的机器列表,对于大规模的扩容和容量评估都有很大的帮助。
分布式系统中 Master 是用来协调集群中其他系统单元,具有对分布式系统状态更改的决定权。比如一些读写分离的应用场景,客户端写请求往往是 Master 来处理的。
利用常见关系型数据库中的主键特性来实现也是可以的,集群中所有机器都向数据库中插入一条相同主键 ID 的记录,数据库会帮助我们自动进行主键冲突检查,可以保证只有一台机器能够成功。
但是有一个问题,如果插入成功的和护短机器成为 Master 后挂了的话,如何通知集群重新选举 Master?
利用 ZooKeeper 创建节点 API 接口,提供了强一致性,能够很好保证在分布式高并发情况下节点的创建一定是全局唯一性。
集群机器都尝试创建节点,创建成功的客户端机器就会成为 Master,失败的客户端机器就在该节点上注册一个 Watcher 用于监控当前 Master 机器是否存活,一旦发现 Master 挂了,其余客户端就可以进行选举了。
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。如果不同系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,一般需要通过一些互斥的手段来防止彼此之间的干扰,以保证一致性。
如果事务 T1 对数据对象 O1 加上了排他锁,那么加锁期间,只允许事务 T1 对 O1 进行读取和更新操作。核心是保证当前有且仅有一个事务获得锁,并且锁释放后,所有正在等待获取锁的事务都能够被通知到。
通过 ZooKeeper 上的 Znode 可以表示一个锁,/x_lock/lock。
如果事务 T1 对数据对象 O1 加上了共享锁,那么当前事务 T1 只能对 O1 进行读取操作,其他事务也只能对这个数据对象加共享锁,直到数据对象上的所有共享锁都被释放。
通过 ZooKeeper 上的 Znode 表示一个锁,/s_lock/[HOSTNAME]-请求类型-序号。
/ ├── /host1-R-000000001 ├── /host2-R-000000002 ├── /host3-W-000000003 ├── /host4-R-000000004 ├── /host5-R-000000005 ├── /host6-R-000000006 └── /host7-W-000000007
在 1.3.7.2 介绍的共享锁中,在判断读写顺序的时候会出现一个问题,假如 host4 在移除自己的节点的时候,后面 host5-7 都需要接收 Watcher 事件通知,但是实际上,只有 host5 接收到事件就可以了。因此以上的实现方式会产生大量的 Watcher 通知。这样会对 ZooKeeper 服务器造成了巨大的性能影响和网络冲击,这就是羊群效应。
改进的一步在于,调用 getChildren 接口的时候获取到所有已经创建的子节点列表,但是这个时候不要注册任何的 Watcher。当无法获取共享锁的时候,调用 exist() 来对比自己小的那个节点注册 Wathcer。而对于读写请求,会有不同的定义:
使用 ZooKeeper 实现 FIFO 队列,入队操作就是在 queue_fifo 下创建自增序的子节点,并把数据(队列大小)放入节点内。出队操作就是先找到 queue_fifo 下序号最下的那个节点,取出数据,然后删除此节点。
/queue_fifo | ├── /host1-000000001 ├── /host2-000000002 ├── /host3-000000003 └── /host4-000000004
创建完节点后,根据以下步骤确定执行顺序:
Barrier就是栅栏或者屏障,适用于这样的业务场景:当有些操作需要并行执行,但后续操作又需要串行执行,此时必须等待所有并行执行的线程全部结束,才开始串行,于是就需要一个屏障,来控制所有线程同时开始,并等待所有线程全部结束。
利用 ZooKeeper 的实现,开始时 queue_barrier 节点是一个已经存在的默认节点,并且将其节点的数据内容赋值为一个数字 n 来代表 Barrier 值,比如 n=10 代表只有当 /queue_barrier节点下的子节点个数达到10才会打开 Barrier。之后所有客户端都会在 queue_barrier 节点下创建一个临时节点,如 queue_barrier/host1。
如何控制所有线程同时开始? 所有的线程启动时在 ZooKeeper 节点 /queue_barrier 下插入顺序临时节点,然后检查 /queue/barrier 下所有 children 节点的数量是否为所有的线程数,如果不是,则等待,如果是,则开始执行。具体的步骤如下:
如何等待所有线程结束? 所有线程在执行完毕后,都检查 /queue/barrier 下所有 children 节点数量是否为0,若不为0,则继续等待。
用什么类型的节点? 根节点使用持久节点,子节点使用临时节点,根节点为什么要用持久节点?首先因为临时节点不能有子节点,所以根节点要用持久节点,并且在程序中要判断根节点是否存在。 子节点为什么要用临时节点?临时节点随着连接的断开而消失,在程序中,虽然会删除临时节点,但可能会出现程序在节点被删除之前就 crash了,如果是持久节点,节点不会被删除。
手绘了11张图,帮你看明白 Zookeeper 如何实现服务注册发现
深入了解Zookeeper核心原理
Zookeeper架构详解
ZooKeeper入门教程(一) - 简书
zookeeper 介绍(2)(*)_Firm陈的博客-CSDN博客
zookeeper详解_、风筝的博客-CSDN博客
Zookeeper架构详解