ZooKeeper是⼀个典型的发布/订阅模式的分布式数据管理与协调框架,我们可以使用它来进行分布式数据的发布与订阅。另⼀方⾯,通过对ZooKeeper中丰富的数据节点类型进行交叉使用,配合Watcher事件通知机制,可以非常方便地构建⼀系列分布式应用中都会涉及的核心功能,如数据发布/订阅、命名服务、集群管理、Master选举、分布式锁和分布式队列等。那接下来就针对这些典型的分布式应用场景来做下介绍。
数据发布/订阅
数据发布/订阅(Publish/Subscribe)系统,即所谓的配置中心。顾名思义就是发布者将数据发布到ZooKeeper的⼀个或⼀系列节点上,供订阅者进行数据订阅,进而达到动态获取数据的目的,实现配置信息的集中式管理和数据的动态更新。
发布/订阅系统⼀般有两种设计模式,分别是推(Push)模式和拉(Pull)模式。在推模式中,服务端主动将数据更新发送给所有订阅的客户端;而拉模式则是由客户端主动发起请求来获取最新数据,通常客户端都采用定时进行轮询拉取的方式。
ZooKeeper 采用的是推拉相结合的方式:客户端向服务端注册自己需要关注的节点,⼀旦该节点的数据发生变更,那么服务端就会向相应的客户端发送Watcher事件通知,客户端接收到这个消息通知之后,需要主动到服务端获取最新的数据。
如果将配置信息存放到ZooKeeper上进行集中管理,那么通常情况下,应用在启动的时候都会主动到ZooKeeper服务端上进行⼀次配置信息的获取。同时,在指定节点上注册⼀个Watcher监听。这样⼀来,但凡配置信息发生变更,服务端都会实时通知到所有订阅的客户端,从而达到实时获取最新配置信息的目的。
下面我们通过⼀个“配置管理”的实际案例来展示ZooKeeper在“数据发布/订阅”场景下的使用方式。
在我们平常的应用系统开发中,经常会碰到这样的需求:系统中需要使用⼀些通用的配置信息,例如机器列表信息、运⾏时的开关配置、数据库配置信息等。这些全局配置信息通常具备以下3个特性:
- 数据量通常比较小
- 数据内容在运行时会发生动态变化
- 集群中各机器共享,配置⼀致
对于这类配置信息,⼀般的做法通常可以选择将其存储在本地配置⽂件或是内存变量中。⽆论采用哪种方式,其实都可以简单地实现配置管理,在集群机器规模不大、配置变更不是特别频繁的情况下,无论刚刚提到的哪种方式,都能够非常方便地解决配置管理的问题。但是,⼀旦机器规模变大,且配置信息变更越来越频繁后,我们发现依靠现有的这两种方式解决配置管理就变得越来越困难了。我们既希望能够快速地做到全局配置信息的变更,同时希望变更成本足够小,因此我们必须寻求⼀种更为分布式化的解决方案。
接下来我们就以⼀个“数据库切换”的应用场景展开,看看如何使用ZooKeeper来实现配置管理:
在进行配置管理之前,我们需要将初始化配置信息存储到ZooKeeper上去.⼀般情况下,我们可以在ZooKeeper上选取⼀个数据节点用于配置信息的存储,例如:/app1/database_config:
我们将需要管理的配置信息写入到该数据节点中去,例如:
#数据库配置信息
dbcp.driverClassName=com.mysql.jdbc.Driver
dbcp.dbJDBCUrl=jdbc:mysql://127.0.0.1:3306/rubin-test
dbcp.username=root
dbcp.password=root
dbcp.maxActive=30
dbcp.maxIdle=10
集群中每台机器在启动初始化阶段,⾸先会从上面提到的ZooKeeper配置节点上读取数据库信息。同时,客户端还需要在该配置节点上注册⼀个数据变更的 Watcher监听。⼀旦发生节点数据变更,所有订阅的客户端都能够获取到数据变更通知。
在系统运行过程中,可能会出现需要进行数据库切换的情况,这个时候就需要进行配置变更。借助ZooKeeper,我们只需要对ZooKeeper上配置节点的内容进行更新,ZooKeeper就能够帮我们将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进行最新数据的获取。
分布式唯一ID
所谓ID,就是⼀个能够唯⼀标识某个对象的标识符。在我们熟悉的关系型数据库中,各个表都需要⼀个主键来唯⼀标识每条数据库记录,这个主键就是这样的唯⼀ID。在过去的单库单表型系统中,通常可以使用数据库字段自带的auto_increment属性来自动为每条数据库记录⽣成⼀个唯⼀的ID,数据库会保证生成的这个ID在全局唯⼀。但是随着数据库数据规模的不断增大,分库分表随之出现,而auto_increment属性仅能针对单⼀表中的记录自动生成ID,因此在这种情况下,就无法再依靠数据库的auto_increment属性来唯⼀标识⼀条记录了。于是,我们必须寻求⼀种能够在分布式环境下生成全局唯⼀ID的方法。
所以接下来,我们结合⼀个分布式任务调度系统来看看如何使用ZooKeepe来实现这类全局唯⼀ID的⽣成。 之前我们已经提到,通过调用ZooKeeper节点创建的API接口可以创建⼀个顺序节点,并且在API返回值中会返回这个节点的完整名字。利用这个特性,我们就可以借助ZooKeeper来生成全局唯⼀的ID了,如下图:
对于⼀个任务列表的主键,使用ZooKeeper生成唯⼀ID的基本步骤:
- 所有客户端都会根据根据的任务类型,在指定类型的任务下⾯通过调用create()接口来创建⼀个顺序节点,例如创建“job-”节点
- 节点创建完毕后,create()接口会返回⼀个完整的节点名,例如“job-0000000003”
- 客户端拿到这个返回值后,拼接上 type 类型,例如“type2-job-0000000003”,这就可以作为⼀个全局唯⼀的ID了
分布式锁
分布式锁是控制分布式系统之间同步访问共享资源的⼀种⽅式。如果不同的系统或是同⼀个系统的不同主机之间共享了⼀个或⼀组资源,那么访问这些资源的时候,往往需要通过⼀些互斥⼿段来防止彼此之间的干扰,以保证⼀致性,在这种情况下,就需要使用分布式锁了。
在平时的实际项目开发中,我们往往很少会去在意分布式锁,而是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥。这确实是⼀种非常简便且被⼴泛使用的分布式锁实现方式。然而有⼀个不争的事实是:目前绝大多数大型分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给数据库添加⼀些额外的锁,例如行锁、表锁甚⾄是繁重的事务处理,那么就会让数据库更加不堪重负。
下面我们来看看使用ZooKeeper如何实现分布式锁,这⾥主要讲解排他锁和共享锁两类分布式锁。
排他锁
排他锁(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的节点。
读写顺序的判断流程如下:
- 客户端调用create()接口创建类似于/shared_lock/[Hostname]-请求类型-序号的临时顺序节点
- 客户端调用getChildren接口获取所有已经创建的子节点列表(不注册任何Watcher)
- 如果无法获取共享锁,就调用exist接口来对比自己小的节点注册Watcher。对于读请求:向比自己序号小的最后⼀个写请求节点注册Watcher监听。对于写请求:向比自己序号小的最后⼀个节点注册Watcher监听
- 等待Watcher通知,继续进入步骤2
释放锁
其释放锁的流程与独占锁⼀致。
分布式队列
分布式队列可以简单分为两大类:⼀种是常规的FIFO先入先出队列模型,还有⼀种是等待队列元素聚集后统⼀安排处理执行的Barrier模型。
FIFO先入先出
FIFO(First Input First Output,先⼊先出), 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
以上就是我们业界对于ZooKeeper的常用场景。基本上都是里用了ZooKeeper的节点类型以及Watcher机制的组合来实现现有的需求。我们也可以在了解这些机制的基础上自行扩展其应用场景。欢迎大家留言交流~~~
文章评论