zookeeper实践篇-Zookeeper经典分布式场景实践

上篇我们学习了zk的api基础实践,并且了解了zk原生api与一些开源实现的zk客户端框架的选择使用,
本篇我们开始学习zk作为一个分布式协调工具在分布式场景中一些实现

Zookeeper是一个高可用的分布式数据管理与分布式协调服务框架,并且因为自身的Zab协议实现,使得zk可以做到保证分布式场景中的一致性。因此伴随着分布式的发展,zk用来解决分布式的场景越来越多,例如配置中心、服务治理、分布式锁、分布式队列等,接下来我们来看看这些常见的分布式场景下zk的处理和实现原理

数据发布/订阅(配置中心)

在分布式开发中,我们往往需要实现一个统一的分布式的配置中心,在我们系统较少的场景下,往往是使用的本地配置文件的方式来处理,但是当分布式环境中,每次进行配置文件的变更,都需要将所有的机器的配置修改,甚至需要重新启动服务,这点对于开发和使用来说非常不便,后来也出现了很多配置中心的方案,例如etcd,或者apollo等,而zk作为通用的分布式协调系统,自然也可以实现理想的配置中心,即使用zk的数据发布/订阅

发布/订阅一般来说在很多系统中都有使用,大体的实现方式主要分为两种:推--拉,而所谓的推模式,则是服务端将配置的数据变更信息主动发送给所有的订阅的客户端,而拉模式则是反过来,是由所有的订阅的客户端,主动发起请求来获取服务端数据,一般都是客户端采用定时轮询的机制来实现。但是我们可以思考这两种模式自身的优缺点,例如推模式,就存在很大可能,在服务端网络不佳的场景下,推模式可能会导致大量的延迟甚至推送失败,导致客户端大量接受不到新的数据变更等问题,而拉模式也可能存在每个客户端主动拉取数据的延迟性,例如30s轮询一次服务端,但是在这个时间间隔内可能会导致大量的请求不可达或者服务不可用,也会因为每个客户端的网络和启动时间等原因导致,部分客户端已经轮询到了最新的数据,而部分客户端依然是上一次的旧数据的情况。可见,无论是哪种模式,都存在一定的弊端,也有一定的优势,而zk的发布/订阅则是基于推拉结合的模式,具体实现原理如下:

客户端向服务端注册需要订阅的节点,而订阅则会使得客户端本地存在一个回调通知机制(watch),一旦服务端发现这个节点有数据变更,就去主动的将变更的信息推送给每一个客户端,触发客户端的watch机制。

那么,我们如果通过zk实现配置中心,该如何操作呢?大致可以分为以下三步:

配置的存储

在进行配置存储之前,我们需要在zk上创建一个节点,用来初始化阶段将数据存储进去,例如/app1/database_config节点:

1580803203912.png

然后将需要管理的配置信息写入

配置获取

分布式集群环境中的每台机器在工程初始化的时候,都会去zk上初始化一个配置信息,并且向该节点注册一个watch,一旦该节点的数据发生了变更,所有的客户端都会获取到数据变更的通知

配置变更

在分布式系统运行的过程中,可能会出现配置修改的情况,这个时候就需要将zk上该节点的配置进行更新,当我们触发完修改操作后,zk的服务端就会将此变化发送给所有的客户端,当客户端收到通知以后,则可以重新进行最新数据的获取

Master选举

Master选举是分布式系统中最常见的应用场景之一,我们常用的组件,例如kafka等就是借助zk实现的Master选举,除此之外,大数据中的组件也有很多利用zk实现选举等功能的。而Master往往是一个分布式系统中具有协调其他系统单元,具有系统中状态变更的决定权。

在前面的文章中,我们学习过zk创建节点的Api特性,其中很重要的一点就是,zk自身具有强一致性,可以保证在分布式环境下高并发创建节点一定可以保证全局唯一。即无法重复的创建一个已经存在的数据节点。如果有多个客户端同时请求创建该节点,那么最终只有一个客户端能创建成功,利用这个特性,就可以简单的实现Master选举等功能。

例如,我们在系统中创建一个日期节点,例如‘2013-09-20’,如图:
1580804876772.png

而客户端每天定时往zk的对应日期节点中创建一个临时节点,例如图中的/master_election/2013-09-20/binding节点,只有一个客户端可以创建这个节点,而其他客户端都是创建失败,此时创建成功的客户端自然而然可以视为master,具有一定的管理权。而创建失败的客户端则可以选择对该节点注册一个watch,用来监控master节点,一旦发现master挂了,所有的客户端收到通知后,再次去创建binding节点,重复上述操作即可保证master节点的选举操作了。

分布式锁

分布式锁是控制分布式系统之间同步访问资源的一种方式,如果在分布式系统下,需要共享访问一个或者一组资源,那么访问这些资源的时候,往往需要一些互斥的手段来保证一致性,这种场景下就需要使用分布式锁了。而zk可以实现的分布式锁一般分为共享锁排他锁两种。

排他锁

排他锁又称写锁或者独占锁,是一种基本的锁类型。如果对客户端对数据对象O1添加了排他锁,那么在整个持有锁的过程中,只允许当前客户端对O1进行读取和更新操作。在zk中,我们可以通过节点来标识一个锁,例如/exclusive_lock/lock节点被定义为一个锁,如图所示:

1580994032074.png

在获取排他锁的时候,所有的客户端都会试图通过create()方法去在/exclusive_lock下面创建临时节点/lock,而zk的创建方法最终能保证只有一个客户端创建成功,此客户端则认为成功获取到了锁,其他客户端创建失败以后,则可以选择注册一个watch监听。我们创建的锁节点属于临时节点,因此在以下两种情况下,都有可能被释放:

1.由于临时节点的特性,不会进行持久化保存,而是和当前连接的session关联,因此当服务器出现宕机的情况下会被释放

2.在创建临时节点成功后,客户端执行需要处理的业务操作完毕后,主动调用临时节点的删除操作

因此无论什么情况下移除了/lock节点,zk都会将事件通知给所有的watch监听的客户端。而这个时候所有的客户端则可以在收到通知后,再次发起创建节点的请求操作,重复上面的操作。整个排他锁的流程如图所示:


1580994501214.png

共享锁

我们在日常开发过程中,还会使用到一个共享锁。所谓共享锁,又称之为读锁,即如果当前资源O1被挂载了共享锁,那么其他的客户端只能对当前的资源O1进行读取的操作,而不是进行更新操作,但是当前资源可以同时挂载多个共享锁,如果想要更新当前资源,则需要挂载的共享锁全部释放。与排他锁不同的是,排他锁仅仅对一个客户端可见,而共享锁则是可以对多个客户端同时可见。

我们同样可以使用zk的临时节点来实现共享锁操作,我们指定一个节点/shared_lock为共享锁的节点,而每个客户端则是在当前节点下创建临时顺序节点,例如/shared_lock/192.168.1.1-R-000000001,节点名称规则为/shared_lock/ip-读写类型-临时节点序号,如图:

1580995176804.png

当所有的客户端都创建完属于自己类型的共享锁后,我们则需要进行读写规则判断,确定当前各个客户端对资源的操作属于读还是写操作,大概如下几个步骤:

1.每个客户端都在当前节点下创建对应规则类型的临时顺序节点,并且对该节点注册一个子节点变更的watch监听

2.当创建节点成功以后,每个客户端需要判断创建的节点在所有的子节点中的顺序

3.并且判断在当前节点的类型的操作

  • 如果是读请求的节点,在此之前的所有的子节点类型都是读类型的,那么当前客户端也可以执行读操作;如果之前的节点有写请求类型,那么在写请求之后的所有客户端都需要进行等待写操作完成。
  • 如果当前的节点是写请求操作,那么除非当前节点在第一个,否则进入等待

4.当接收到watch通知操作后,每个客户端都会重复创建watch监听操作

整个共享锁的逻辑基于排他锁的思想,但是逻辑较复杂,整体流程如图所示:


1580995854408.png

羊群效应

上面我们创建的两种分布式锁,一般在规模不大的情况下,性能还算不错,但是我们不禁考虑一个问题,每次有子节点变化的时候,都会将所有的watch进行通知,但是每次也只有一个客户端在收到通知后拥有锁的操作权,其他的客户端其实是没有作用的,依然是重新注册watch监听,等待下一次通知。如果在频繁的事件监听触发情况下,我们不免发现有着大量的重复通知,有多少个客户端监听,每次触发就会通知到多少客户端,这种重复通知操作称之为羊群效应(惊群效应)。那么我们在使用过程中能不能优化分布式锁,避免触发羊群效应呢?其实是可以的,我们不妨思考触发羊群效应的原因,即每个客户端注册了watch监听都是对父节点的子节点变化的监听,但是我们知道每次触发监听,其实就是排在第一位的临时节点被该客户端释放,那么我们可以在每次注册watch监听的时候,找到当前客户端创建的临时节点的前一个节点,进行监听,这样,无论前面的锁如何变化,每个客户端只关心上一个客户端的节点是否被释放,一旦释放,则会触发通知给当前客户端,避免了所有客户端都收到通知的情况

分布式队列

提到队列,我们最先想到的会是FIFO的队列,即先进先出的顺序队列,在分布式环境下想要实现FIFO的队列很简单,我们可以利用类似共享锁的的实现。例如,所有的客户端都会在/queue_fifo这个节点下创建一个临时顺序节点,如/queue_fifo/192.168.1.1-0000000001/queue_fifo/192.168.1.1-0000000002等,如图所示:

1580910825630.png

创建完节点后,我们将根据以下4个步骤来确定执行的顺序:

1.通过调用getChildren()接口来获取/queue_fifo节点下的所有子节点,即获取了当前队列中的所有元素

2.确定自己的节点序号在所有子节点中的顺序

3.如果自己不是序号最小的子节点,即不是最前面的子节点,那么则需要进入继续等待,同时这里我们需要向比当前序号小的最后一个节点注册watch监听

4.接受到watch监听后,重新重复步骤1即可

整个的过程大概如图所示:


1580911157009.png

而在分布式开发环境中,除了FIFO队列以外,我们往往还存在一个分布式队列,即等待一个队列的元素都聚集以后才进行下一步统一的安排,否则一直进行等待操作。而这种操作往往存在于分布式环境的并行计算或者并行操作中,而该队列则是基于FIFO队列的一个增强实现,我们可以通过注册一个已经存在的节点,如/queue_barrier,我们将需要等待的数量n作为值写入当前节点中,例如n=10,则代表当前节点下存在10个子节点的时候才会开始下一步操作,而这个过程中,每个客户端都会在该节点下创建一个临时节点,如/queue_barrier/192.168.1.1,如图所示:

1580911442877.png

创建完节点以后,我们可以通过以下五个步骤来确定执行顺序:

1.每个客户端通过调用getData()来获取/queue_barrier节点中规定的子节点数量

2.通过调用getChildren()接口获取/queue_barrier节点下的所有子节点,然后对/queue_barrier节点添加一个子节点列表变化的watch

3.每次触发watch的时候计算子节点数量

4.如果当前数量不足规定的数量,则继续添加监听,进入等待

5.如果数量刚好满足,则可以执行下一步的业务逻辑操作

整个分布式等待队列的操作过程,如图所示:


1580912053345.png

推荐阅读更多精彩内容