ZooKeeper是⼀个典型的发布/订阅模式的分布式数据管理与协调框架,我们可以使⽤它来进⾏分布式数据的发布与订阅。
另⼀⽅⾯,通过对ZooKeeper中丰富的数据节点类型进⾏交叉使⽤,配合Watcher事件通知机制,可以⾮常⽅便地构建⼀系列分布式应⽤中都会涉及的核⼼功能,如
- 数据发布/订阅
- 命名服务
- 集群管理
- Master选举
- 分布式锁和分布式队列
数据发布/订阅(Publish/Subscribe)
数据发布/订阅系统,即所谓的配置中⼼,就是发布者将数据发布到ZooKeeper的⼀个或⼀系列节点上,供订阅者进⾏数据订阅,进⽽达到动态获取数据的⽬的,实现配置信息的集中式管理和数据的动态更新。
发布/订阅系统⼀般有两种设计模式,分别是推(Push)模式和拉(Pull)模式。推模式中,服务端主动将数据更新发送给所有订阅的客户端拉模式,则是由客户端主动发起请求来获取最新数据,通常客户端都采⽤定时进⾏轮询拉取的⽅式。
Zookeeper通过监听的机制
如果将配置信息存放到ZooKeeper上进⾏集中管理,那么通常情况下,应⽤在启动的时候都会主动到ZooKeeper服务端上进⾏⼀次配置信息的获取,同时,在指定节点上注册⼀个Watcher监听,这样⼀来,但凡配置信息发⽣变更,服务端都会实时通知到所有订阅的客户端,从⽽达到实时获取最新配置信息的⽬的。
配置管理:
在进⾏配置管理之前,⾸先我们需要将初始化配置信息存储到Zookeeper上去,⼀般情况下,我们可以在Zookeeper上选取⼀个数据节点⽤于配置信息的存储,例如:/app1/database_config
我们将需要管理的配置信息写⼊到该数据节点中去,例如:
#数据库配置信息
#DBCP
dbcp.driverClassName=com.mysql.jdbc.Driver
dbcp.dbJDBCUrl=jdbc:mysql://127.0.0.1:3306/lagou-test
dbcp.username=zm
dbcp.password=1234
dbcp.maxActive=30
dbcp.maxIdle=10
配置获取
集群中每台机器在启动初始化阶段,⾸先会从上⾯提到的ZooKeeper配置节点上读取数据库信息,同时,客户端还需要在该配置节点上注册⼀个数据变更的 Watcher监听,⼀旦发⽣节点数据变更,所有订阅的客户端都能够获取到数据变更通知。
配置变更
在系统运⾏过程中,可能会出现需要进⾏数据库切换的情况,这个时候就需要进⾏配置变更。借助ZooKeeper,我们只需要对ZooKeeper上配置节点的内容进⾏更新,ZooKeeper就能够帮我们将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进⾏最新数据的获取。
命名服务
命名服务(Name Service)也是分布式系统中⽐较常⻅的⼀类场景,是分布式系统最基本的公共服务之⼀。在分布式系统中,被命名的实体通常可以是集群中的机器、提供的服务地址或远程对象等——这些我们都可以统称它们为名字(Name),其中较为常⻅的就是⼀些分布式服务框架(如RPC、RMI)中的服务地址列表,通过使⽤命名服务,客户端应⽤能够根据指定名字来获取资源的实体、服务地址和提供者的信息等。
ZooKeeper 提供的命名服务功能能够帮助应⽤系统通过⼀个资源引⽤的⽅式来实现对资源的定位与使⽤。另外,⼴义上命名服务的资源定位都不是真正意义的实体资源——在分布式环境中,上层应⽤仅仅需要⼀个全局唯⼀的名字,类似于数据库中的唯⼀主键。
使⽤ZooKeeper来实现⼀套分布式全局唯⼀ID的分配机制
通过调⽤ZooKeeper节点创建的API接⼝可以创建⼀个顺序节点,并且在API返回值中会返回这个节点的完整名字。利⽤这个特性,我们就可以借助ZooKeeper来⽣成全局唯⼀的ID了,如下图:
说明,对于⼀个任务列表的主键,使⽤ZooKeeper⽣成唯⼀ID的基本步骤:
- 所有客户端都会根据⾃⼰的任务类型,在指定类型的任务下⾯通过调⽤create()接⼝来创建⼀个顺序节点,例如创建“job-”节点。
- 节点创建完毕后,create()接⼝会返回⼀个完整的节点名,例如“job-0000000003”。
- 客户端拿到这个返回值后,拼接上 type 类型,例如“type2-job-0000000003”,这就可以作为⼀个全局唯⼀的ID了。
在ZooKeeper中,每⼀个数据节点都能够维护⼀份⼦节点的顺序顺列,当客户端对其创建⼀个顺序⼦节点的时候 ZooKeeper 会⾃动以后缀的形式在其⼦节点上添加⼀个序号,在这个场景中就是利⽤了ZooKeeper的这个特性
集群管理
集群管理,包括集群监控与集群控制两⼤块,前者侧重对集群运⾏时状态的收集,后者则是对集群进⾏操作与控制。
Zookeeper的两⼤特性:
- 客户端如果对Zookeeper的数据节点注册Watcher监听,那么当该数据节点的内容或是其⼦节点列表发⽣变更时,Zookeeper服务器就会向订阅的客户端发送变更通知。
- 对在Zookeeper上创建的临时节点,⼀旦客户端与服务器之间的会话失效,那么临时节点也会被⾃动删除
利⽤其两⼤特性,可以实现集群机器存活监控系统,若监控系统在/clusterServers节点上注册⼀个Watcher监听,那么但凡进⾏动态添加机器的操作,就会在/clusterServers节点下创建⼀个临时节点:/clusterServers/[Hostname],这样,监控系统就能够实时监测机器的变动情况。
分布式⽇志收集系统
分布式⽇志收集系统的核⼼⼯作就是收集分布在不同机器上的系统⽇志,在这⾥我们重点来看分布式⽇志系统(以下简称“⽇志系统”)的收集器模块。
在⼀个典型的⽇志系统的架构设计中,整个⽇志系统会把所有需要收集的⽇志机器(我们以“⽇志源机器”代表此类机器)分为多个组别,每个组别对应⼀个收集器,这个收集器其实就是⼀个后台机器(我们以“收集器机器”代表此类机器),⽤于收集⽇志
使⽤Zookeeper的场景步骤如下
① 注册收集器机器
使⽤ZooKeeper来进⾏⽇志系统收集器的注册,典型做法是在ZooKeeper上创建⼀个节点作为收集器的根节点,例如/logs/collector(下⽂我们以“收集器节点”代表该数据节点),每个收集器机器在启动的时候,都会在收集器节点下创建⾃⼰的节点,例如/logs/collector/[Hostname]
② 任务分发
待所有收集器机器都创建好⾃⼰对应的节点后,系统根据收集器节点下⼦节点的个数,将所有⽇志源机器分成对应的若⼲组,然后将分组后的机器列表分别写到这些收集器机器创建的⼦节点(例如/logs/collector/host1)上去。这样⼀来,每个收集器机器都能够从⾃⼰对应的收集器节点上获取⽇志源机器列表,进⽽开始进⾏⽇志收集⼯作。
③ 状态汇报
完成收集器机器的注册以及任务分发后,我们还要考虑到这些机器随时都有挂掉的可能。因此,针对这个问题,我们需要有⼀个收集器的状态汇报机制:每个收集器机器在创建完⾃⼰的专属节点后,还需在对应的⼦节点上创建⼀个状态⼦节点,例如/logs/collector/host1/status,每个收集器机器都需要定期向该节点写⼊⾃⼰的状态信息。我们可以把这种策略看作是⼀种⼼跳检测机制,通常收集器机器都会在这个节点中写⼊⽇志收集进度信息。⽇志系统根据该状态⼦节点的最后更新时间来判断对应的收集器机器是否存活。
④ 动态分配
如果收集器机器挂掉或是扩容了,就需要动态地进⾏收集任务的分配。在运⾏过程中,⽇志系统始终关注着/logs/collector这个节点下所有⼦节点的变更,⼀旦检测到有收集器机器停⽌汇报或是有新的收集器机器加⼊,就要开始进⾏任务的重新分配。⽆论是针对收集器机器停⽌汇报还是新机器加⼊的情况,⽇志系统都需要将之前分配给该收集器的所有任务进⾏转移。为了解决这个问题,通常有两种做法:
- 全局动态分配
这是⼀种简单粗暴的做法,在出现收集器机器挂掉或是新机器加⼊的时候,⽇志系统需要根据新的收集器机器列表,⽴即对所有的⽇志源机器重新进⾏⼀次分组,然后将其分配给剩下的收集器机器。 - 局部动态分配
全局动态分配⽅式虽然策略简单,但是存在⼀个问题:**⼀个或部分收集器机器的变更,就会导致全局动态任务的分配,影响⾯⽐较⼤,因此⻛险也就⽐较⼤。**所谓局部动态分配,顾名思义就是在⼩范围内进⾏任务的动态分配。在这种策略中,每个收集器机器在汇报⾃⼰⽇志收集状态的同时,也会把⾃⼰的负载汇报上去。请注意,这⾥提到的负载并不仅仅只是简单地指机器CPU负载(Load),⽽是⼀个对当前收集器任务执⾏的综合评估,这个评估算法和ZooKeeper本身并没有太⼤的关系,这⾥不再赘述。
在这种策略中,如果⼀个收集器机器挂了,那么⽇志系统就会把之前分配给这个机器的任务重新分配到那些负载较低的机器上去。同样,如果有新的收集器机器加⼊,会从那些负载⾼的机器上转移部分任务给这个新加⼊的机器。
注意
- 在/logs/collector节点下创建临时节点可以很好的判断机器是否存活,但是,若机器挂了,其节点会被删除,记录在节点上的⽇志源机器列表也被清除,所以需要选择持久节点来标识每⼀台机器,同时在节点下分别创建/logs/collector/[Hostname]/status节点来表征每⼀个收集器机器的状态,这样,既能实现对所有机器的监控,同时机器挂掉后,依然能够将分配任务还原。
- ⽇志系统节点监听
若采⽤Watcher机制,那么通知的消息量的⽹络开销⾮常⼤,需要采⽤⽇志系统主动轮询收集器节点的策略,这样可以节省⽹络流量,但是存在⼀定的延时。
Master选举
整个系统⼤体上可以分成客户端集群、分布式缓存系统、海量数据处理总线和 ZooKeeper四个部分
图中的Client集群每天定时会通过ZooKeeper来实现Master选举
- 选举产⽣Master客户端之后,这个Master就会负责进⾏⼀系列的海量数据处理,最终计算得到⼀个数据结果,并将其放置在⼀个内存/数据库中。
- Master还需要通知集群中其他所有的客户端从这个内存/数据库中共享计算结果
通常情况下,我们可以选择常⻅的关系型数据库中的主键特性来实现:集群中的所有机器都向数据库中插⼊⼀条相同主键 ID 的记录,数据库会帮助我们⾃动进⾏主键冲突检查,也就是说,所有进⾏插⼊操作的客户端机器中,只有⼀台机器能够成功——那么,我们就认为向数据库中成功插⼊数据的客户端机器成为Master。
借助数据库的这种⽅案确实可⾏,依靠关系型数据库的主键特性能够很好地保证在集群中选举出唯⼀的
⼀个Master。但是我们需要考虑的另⼀个问题是,如果当前选举出的Master挂了,那么该如何处理?谁来告诉我Master挂了呢?利⽤ZooKeeper的强⼀致性,能够很好保证在分布式⾼并发情况下节点的创建⼀定能够保证全局唯⼀性,即ZooKeeper将会保证客户端⽆法重复创建⼀个已经存在的数据节点。也就是说,如果同时有多个客户端请求创建同⼀个节点,那么最终⼀定只有⼀个客户端请求能够创建成功。
- 在这个系统中,⾸先会在 ZooKeeper 上创建⼀个⽇期节点,例如“2020-11-11
- 客户端集群每天都会定时往ZooKeeper 上创建⼀个临时节点,例如/master_election/2020-11-11/binding。在这个过程中,只有⼀个客户端能够成功创建这个节点,那么这个客户端所在的机器就成为了Master
- 同时,其他没有在ZooKeeper上成功创建节点的客户端,都会在节点/master_election/2020-11-11 上注册⼀个⼦节点变更的 Watcher,⽤于监控当前的 Master 机器是否存活,⼀旦发现当前的 Master 挂了,那么其余的客户端将会重新进⾏Master选举。
分布式锁
排他锁
排他锁(Exclusive Locks,简称 X 锁),⼜称为写锁或ᇿ占锁,是⼀种基本的锁类型。如果事务 T1对数据对象 O1加上了排他锁,那么在整个加锁期间,只允许事务 T1对 O1进⾏读取和更新操作,其他任何事务都不能再对这个数据对象进⾏任何类型的操作——直到T1释放了排他锁
核心是如何保证当前有且仅有⼀个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。
借助ZooKeeper实现排他锁:
① 定义锁
在通常的Java开发编程中,有两种常⻅的⽅式可以⽤来定义锁,分别是synchronized机制和JDK5提供的ReentrantLock。然⽽,在ZooKeeper中,没有类似于这样的API可以直接使⽤,⽽是通过 ZooKeeper上的数据节点来表示⼀个锁,例如/exclusive_lock/lock节点就可以被定义为⼀个锁,如图:
② 获取锁
在需要获取排他锁时,所有的客户端都会试图通过调⽤ create()接⼝,在/exclusive_lock节点下创建临时⼦节点/exclusive_lock/lock。在前⾯,我们也介绍了,ZooKeeper 会保证在所有的客户端中,最终只有⼀个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户端就需要到/exclusive_lock 节点上注册⼀个⼦节点变更的Watcher监听,以便实时监听到lock节点的变更情况
③释放锁
在“定义锁”部分,我们已经提到,/exclusive_lock/lock 是⼀个临时节点,因此在以下两种情况下,都有可能释放锁。当前获取锁的客户端机器发⽣宕机,那么ZooKeeper上的这个临时节点就会被移除。 ·
正常执⾏完业务逻辑后,客户端就会主动将⾃⼰创建的临时节点删除。 ⽆论在什么情况下移除了lock节点,ZooKeeper都会通知所有在/exclusive_lock节点上注册了⼦节点变更Watcher监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。
整个排他锁的获取和释放流程,如下图
共享锁
共享锁(Shared Locks,简称S锁),⼜称为读锁,同样是⼀种基本的锁类型。
如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进⾏读取操作,其他事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对⼀个事务可⻅,⽽加上共享锁后,数据对所有事务都可⻅。
如何借助ZooKeeper来实现共享锁
①定义锁 和排他锁⼀样,同样是通过 ZooKeeper 上的数据节点来表示⼀个锁,是⼀个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点,例如/shared_lock/host1-R-0000000001,那么,这个节点就代表了⼀个共享锁,如图所示:
② 获取锁
在需要获取共享锁时,所有客户端都会到/shared_lock 这个节点下⾯创建⼀个临时顺序节点,如果当前是读请求,那么就创建例如/shared_lock/host1-R-0000000001的节点;如果是写请求,那么就创建例如/shared_lock/host2-W-0000000002的节点。
判断读写顺序
通过Zookeeper来确定分布式读写顺序,⼤致分为四步
- 创建完节点后,获取/shared_lock节点下所有⼦节点,并对该节点变更注册监听。
- 确定⾃⼰的节点序号在所有⼦节点中的顺序。
- 对于读请求:若没有⽐⾃⼰序号⼩的⼦节点或所有⽐⾃⼰序号⼩的⼦节点都是读请求,那么表明⾃⼰已经成功获取到共享锁,同时开始执⾏读取逻辑,若有写请求,则需要等待。对于写请求:若⾃⼰不是序号最⼩的⼦节点,那么需要等待。
- 接收到Watcher通知后,重复步骤1
③ 释放锁,其释放锁的流程与ᇿ占锁⼀致。
⽺群效应
针对如上图所示的情况进⾏分析
- host1⾸先进⾏读操作,完成后将节点/shared_lock/host1-R-00000001删除。
- 余下4台机器均收到这个节点移除的通知,然后重新从/shared_lock节点上获取⼀份新的⼦节点列表。
- 每台机器判断⾃⼰的读写顺序,其中host2检测到⾃⼰序号最⼩,于是进⾏写操作,余下的机器则继续等待。
- 继续。。。
分析
host1客户端在移除⾃⼰的共享锁后,Zookeeper发送了⼦节点更变Watcher通知给所有机器,然⽽除了给host2产⽣影响外,对其他机器没有任何作⽤。
⼤量的Watcher通知和⼦节点列表获取两个操作会重复运⾏,这样不仅会对zookeeper服务器造成巨⼤的性能影响影响和⽹络开销,更为严重的是,如果同⼀时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper服务器就会在短时间内向其余客户端发送⼤量的事件通知,这就是所谓的⽺群效应。
解决方法
每个节点对应的客户端只需要关注⽐⾃⼰序号⼩的那个相关节点的变更情况就可以了——⽽不需要关注全局的⼦列表变更情况。
改进后的分布式锁实现
⾸先,我们需要肯定的⼀点是,上⾯提到的共享锁实现,从整体思路上来说完全正确。这⾥主要的改动在于:每个锁竞争者,只需要关注/shared_lock节点下序号⽐⾃⼰⼩的那个节点是否存在即可,具体实现如下。
- 客户端调⽤create接⼝常⻅类似于/shared_lock/[Hostname]-请求类型-序号的临时顺序节点。
- 客户端调⽤getChildren接⼝获取所有已经创建的⼦节点列表(不注册任何Watcher)。
- 如果⽆法获取共享锁,就调⽤exist接⼝来对⽐⾃⼰⼩的节点注册Watcher。对于读请求:向⽐⾃⼰序号⼩的最后⼀个写请求节点注册Watcher监听。对于写请求:向⽐⾃⼰序号⼩的最后⼀个节点注册Watcher监听。
- 等待Watcher通知,继续进⼊步骤2。
此⽅案改动主要在于:每个锁竞争者,只需要关注/shared_lock节点下序号⽐⾃⼰⼩的那个节点是否存在即可。
分布式队列
分布式队列可以简单分为两⼤类:⼀种是常规的FIFO先⼊先出队列模型,还有⼀种是等待队列元素聚集后统⼀安排处理执⾏的Barrier模型。
① FIFO先⼊先出
使⽤ZooKeeper实现FIFO队列,和之前提到的共享锁的实现⾮常类似。FIFO队列就类似于⼀个全写的共享锁模型,⼤体的设计思路其实⾮常简单:所有客户端都会到/queue_fifo 这个节点下⾯创建⼀个临时顺序节点,例如如/queue_fifo/host1-00000001。
创建完节点后,根据如下4个步骤来确定执⾏顺序。
- 通过调⽤getChildren接⼝来获取/queue_fifo节点的所有⼦节点,即获取队列中所有的元素。
- 确定⾃⼰的节点序号在所有⼦节点中的顺序。
- 如果⾃⼰的序号不是最⼩,那么需要等待,同时向⽐⾃⼰序号⼩的最后⼀个节点注册Watcher监听。
- 接收到Watcher通知后,重复步骤1。
② Barrier:分布式屏障
Barrier原意是指障碍物、屏障,⽽在分布式系统中,特指系统之间的⼀个协调条件,规定了⼀个队列的元素必须都集聚后才能统⼀进⾏安排,否则⼀直等待。
这往往出现在那些⼤规模分布式并⾏计算的应⽤场景上:最终的合并计算需要基于很多并⾏计算的⼦结果来进⾏。这些队列其实是在 FIFO 队列的基础上进⾏了增强,⼤致的设计思想如下:开始时,/queue_barrier 节点是⼀个已经存在的默认节点,并且将其节点的数据内容赋值为⼀个数字n来代表Barrier值,例如n=10表示只有当/queue_barrier节点下的⼦节点个数达到10后,才会打开Barrier。之后,所有的客户端都会到/queue_barrie节点下创建⼀个临时节点,例如/queue_barrier/host1,如图所示。
创建完节点后,按照如下步骤执⾏。
- 通过调⽤getData接⼝获取/queue_barrier节点的数据内容:10。
- 通过调⽤getChildren接⼝获取/queue_barrier节点下的所有⼦节点,同时注册对⼦节点变更的Watcher监听。
- 统计⼦节点的个数。
- 如果⼦节点个数还不⾜10个,那么需要等待。
- 接受到Wacher通知后,重复步骤2