ZooKeeper八大典型的应用场景

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

1.1 什么是配置中心,有什么用

数据发布/订阅( Publish/Subscribe)系统,即所谓的配置中心,顾名思义就是发布者将数据发布到ZooKeeper的一个或一系列节点上,供订阅者进行数据订阅,进而达到动态获取数据的目的,实现配置信息的集中式管理和数据的动态更新。

1.2 配置中心的设计模式

发布/订阅系统一般有两种设计模式,分别是推(Push) 模式和拉(Pull) 模式。在推模式中,服务端主动将数据更新发送给所有订阅的客户端;而拉模式则是由客户端主动发起请求来获取最新数据,通常客户端都采用定时进行轮询拉取的方式。ZooKeeper采用的是推拉相结合的方式:客户端向服务端注册自己需要关注的节点,一旦该节点的数据发生变更,那么服务端就会向相应的客户端发送Watcher事件通知,客户端接收到这个消息通知之后,需要主动到服务端获取最新的数据。

1.3 配置中心实现原理

如果将配置信息存放到ZooKeeper上进行集中管理,那么通常情况下,应用在启动的时候都会主动到ZooKeeper服务端上进行一次配置信息的获取,同时,在指定节点上注册一个Watcher监听,这样一来,但凡配置信息发生变更,服务端都会实时通知到所有订阅的客户端,从而达到实时获取最新配置信息的目的。

在我们平常的应用系统开发中,经常会碰到这样的需求:系统中需要使用一些通用的配置信息,例如机器列表信息、运行时的开关配置、数据库配置信息等。这些全局配置信息通常具备以下3个特性。

1.4 全局配置信息通常具备哪些特性

  • 数据量通常比较小。
  • 数据内容在运行时会发生动态变化。
  • 集群中各机器共享,配置一致。

对于这类配置信息,一般的做法通常可以选择将其存储在本地配置文件或是内存变量中。

1.5 为什么要使用配置中心

无论采用哪种方式,其实都可以简单地实现配置管理。如果采用本地配置文件的方式,那么通常系统可以在应用启动的时候读取到本地磁盘的一个文件来进行初始化,并且在运行过程中定时地进行文件的读取,以此来检测文件内容的变更。在系统的实际运行过程中,如果我们需要对这些配置信息进行更新,那么只要在相应的配置文件中进行修改,等到系统再次读取这些配置文件的时候,就可以读取到最新的配置信息,并更新到系统中去,这样就可以实现系统配置信息的更新。另外一种借助内存变量来实现配置管理的方式也非常简单,以Java系统为例,通常可以采用JMX方式来实现对系统运行时内存变量的更新。从上面的介绍中,我们基本了解了如何使用本地配置文件和内存变量方式来实现配置管理。通常在集群机器规模不大、配置变更不是特别频繁的情况下,无论上面提到的哪种方式,都能够非常方便地解决配置管理的问题。但是,一旦机器规模变大,且配置信息变更越来越频繁后,我们发现依靠现有的这两种方式解决配置管理就变得越来越困难了。我们既希望能够快速地做到全局配置信息的变更,同时希望变更成本足够小,因此我们必须寻求–种更为分布式化的解决方案。

1.6 配置中心实现的步骤

第一步:配置存储
在进行配置管理之前,首先我们需要将初始化配置存储到ZooKeeper.上去。一般情况下,我们可以在ZooKeeper上选取一个数据节点用于配置的存储,例如/app/database_ config (“配置节点”)

第二步:配置获取
集群中每台机器在启动初始化阶段,首先会从上面提到的ZooKeeper配置节点上读取数据库信息,同时,客户端还需要在该配置节点上注册一个数据变更的Watcher监听,一旦发生节点数据变更,所有订阅的客户端都能够获取到数据变更通知。

第三步:配置变更
在系统运行过程中,可能会出现需要进行数据库切换的情况,这个时候就需要进行配置变更。借助ZooKeeper,我们只需要对ZooKeeper上配置节点的内容进行更新,ZooKeeper就能够帮我们将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进行最新数据的获取。

二、负载均衡

2.1 什么是负载均衡

负载均衡(Load Balance)是一种相当常见的计算机网络技术,用来对多个计算机(计算机集群)、网络连接、CPU、磁盘驱动器或其他资源进行分配负载,以达到优化资源使用、最大化吞吐率、最小化响应时间和避免过载的目的。通常负载均衡可以分为硬件和软件负载均衡两类,本节主要探讨的是ZooKeeper在“软”负载均衡中的应用场景。

在分布式系统中,负载均衡更是一种普遍的技术,基本上每一个分布式系统都需要使用负载均衡。分布式系统具有对等
性,为了保证系统的高可用性,通常采用副本的方式来对数据和服务进行部署。而对于消费者而言,则需要在这些对等的服务提供方中选择一个来执行相关的业务逻辑,其中比较典型的就是DNS服务。

2.2 ZooKeeper怎么实现的负载均衡

ZooKeeper采用了一种动态DNS的一种方案实现的负载均衡。一般企业不会使用zk做负载均衡。有nginx和Ribbon不用那不是傻子吗。

三、命名服务

3.1 什么是命名服务

命名服务(Name Service)也是分布式系统中比较常见的类场景,命名服务是分布式系统最基本的公共服务之一。能够帮助应用系统通过一个资源引用的方式来实现对资源的定位与使用。
在分布式系统中,被命名的实体通常可以是集群中的机器、提供的服务地址或远程对象等这些我们都可以统称它们为名字(Name),其中较为常见的就是一些分布式服务框架(如RPC、RMI)中的服务地址列表。

3.2 命名服务有什么用

通过使用命名服务,客户端应用能够根据指定名字来获取资源的实体、服务地址和提供者的信息等。Java语言中的JNDI便是一种典型的命名服务。JNDI是Java 命名与目录接口(JavaNaming and Directory Interface)的缩写,是J2EE体系中重要的规范之一,标准的J2EE容器都提供了对JNDI规范的实现。因此,在实际开发中,开发人员常常使用应用服务器自带的JNDI实现来完成数据源的配置与管理一使用JNDI方式后,开发人员可以完全不需要关心与数据库相关的任何信息,包括数据库类型、JDBC驱动类型以及数据库账户等。

ZooKeeper提供的命名服务功能与JNDI技术有相似的地方,都能够帮助应用系统通过一个资源引用的方式来实现对资源的定位与使用。另外,广义上命名服务的资源定位都不是真正意义的实体资源——在分布式环境中,上层应用仅仅需要一个全局唯一的名字,类似于数据库中的唯一主键。

3.3 ZooKeeper实现分布式唯一ID

所谓ID,就是一个能够唯一标识某个对象的标识符。在我们熟悉的关系型数据库中,各个表都需要一个主键来唯–标识每条数据库记录,这个主键就是这样的唯一ID。在过去的单库单表型系统中,通常可以使用数据库字段自带的auto_increment属性来自动为每条数据库记录生成一个唯一的ID,数据库会保证生成的这个ID在全局唯一。
但是随着数据库数据规模的不断增大,分库分表随之出现,而auto_ increment 属性仅能针对单一表中的记录自动生成ID,因此在这种情况下,就无法再依靠数据库的auto_ increment 属性来唯一标识一条记录了。于是,我们必须寻求一种能够在分布式环境下生成全局唯一ID的方法。
说起全局唯一ID,肯定少不了UUID。没错,UUID是通用唯一识别码(Universally Unique ldentifier) 的简称,是一种在分布式系统中广泛使用的用于唯-标识元素的标准,最典型的实现是GUID (Globally Unique ldentifier, 全局唯-标识符),主流ORM框架Hibernate有对UUID的直接支持。确实,UUID是一个非常不错的全局唯一ID生成方式,能够非常简便地保证分布式环境中的唯一性。一个标准的UUID是一个包含32位字符和4个短线的字符串,例如“e70f1357-f260-46ff- a32d- 53a086c57ade"。但是UUID也具有一定的缺陷,比如长度过长,含义不明等。

而ZooKeeper实现分布式唯一ID只要创建顺序节点就可以了。
在ZooKeeper中,每一个数据节点都能够维护一份子节点的顺序顺列,当客户端对其创建一个顺序子节点的时候ZooKeeper 会自动以后缀的形式在其子节点上添加一个序号,在这个场景中就是利用了ZooKeeper的这个特性。如图

四、分布式协调/通知

4.1 什么是分布式协调/通知

分布式协调/通知服务是分布式系统中不可缺少的一个环节,是将不同的分布式组件有机结合起来的关键所在。

4.2 分布式协调/通知的作用

对于一个在多台机器上部署运行的应用而言,通常需要一个 协调者(Coordinator)来控制整个系统的运行流程,例如分布式事务的处理、机器间的互相.协调等。同时,引入这样一一个协调者,便于将分布式协调的职责从应用中分离出来,从而可以大大减少系统之间的耦合性,而且能够显著提高系统的可扩展性。

ZooKeeper中特有的Watcher注册与异步通知机制,能够很好地实现分布式环境下不同机器,甚至是不同系统之间的协调与通知,从而实现对数据变更的实时处理。基于ZooKeeper实现分布式协调与通知功能,通常的做法是不同的客户端都对ZooKeeper上同一个数据节点进行Watcher注册,监听数据节点的变化(包括数据节点本身及其子节点),如果数据节点发生变化,那么所有订阅的客户端都能够接收到相应的Watcher 通知,并做出相应的处理。

4.3 分布式系统机器间通信方式

在绝大部分的分布式系统中,系统机器间的通信无外乎心跳检测、工作进度汇报和系统调度这三种类型。接下来,我们将围绕这三种类型的机器通信来讲解如何基于ZooKeeper去实现一种分布式系统间的通信方式。

4.3.1 心跳检测

机器间的心跳检测机制是指在分布式环境中,不同机器之间需要检测到彼此是否在正常运行,例如A机器需要知道B机器是否正常运行。在传统的开发中,我们通常是通过主机之间是否可以相互PING通来判断,更复杂一点的话,则会通过在机器之间建立长连接,通过TCP连接固有的心跳检测机制来实现上层机器的心跳检测,这些确实都是一些非常常见的心跳检测方法。

ZooKeeper怎么实现分布式机器间的心跳检测?

基于ZooKeeper的临时节点特性,可以让不同的机器都在ZooKeeper的一个指定节点下创建临时子节点,不同的机器之间可以根据这个临时节点来判断对应的客户端机器是否存活。通过这种方式,检测系统和被检测系统之间并不需要直接相关联,而是通过ZooKeeper上的某个节点进行关联,大大减少了系统耦合。

4.3.2 工作进度汇报

在一个常见的任务分发系统中,通常任务被分发到不同的机器上执行后,需要实时地将自己的任务执行进度汇报给分发系统。这个时候就可以通过ZooKeeper 来实现。在ZooKeeper.上选择一个节点,每个任务客户端都在这个节点下面创建临时子节点,这样便可以实现两个功能:

  • 通过判断临时节点是否存在来确定任务机器是否存活;
  • 各个任务机器会实时地将自己的任务执行进度写到这个临时节点上去,以便中心系统能够实时地获取到任务的执行进度。

4.3.3 系统调度

使用ZooKeeper,能够实现另一种系统调度模式:一个分布式系统由控制台和一些客户端系统两部分组成,控制台的职责就是需要将一些指令信息发送给所有的客户端,以控制它们进行相应的业务逻辑。后台管理人员在控制台上做的一些操作,实际上就是修改了ZooKeeper上某些节点的数据,而ZooKeeper进一步把这些数据变更以事件通知的形式发送给了对应的订阅客户端。

使用ZooKeeper来实现分布式系统机器间的通信的好处?

不仅能省去大量底层网络通信和协议设计.上重复的工作,更为重要的一点是大大降低了系统之间的耦合,能够非常方便地实现异构系统之间的灵活通信。

五、集群管理

5.1 什么是集群管理

所谓集群管理,包括集群监控与集群控制两大块,前者侧重对集群运行时状态的收集,后者则是对集群进行操作与控制。在日常开发和运维过程中,我们经常会有类似于如下的需求。

  • 希望知道当前集群中究竟有多少机器在工作。
  • 对集群中每台机器的运行时状态进行数据收集。
  • 对集群中机器进行上下线操作。
    在传统的基于Agent的分布式集群管理体系中,都是通过在集群中的每台机器上部署一个Agent, 由这个Agent 负责主动向指定的一个监控中心系统(监控中心系统负责将所有数据进行集中处理,形成一系列报表,并负责实时报警,以下简称“监控中心”) 汇报自己所在机器的状态。在集群规模适中的场景下,这确实是一 种在生产实践中广泛使用的解决方案,能够快速有效地实现分布式环境集群监控,但是一旦系统的业务场景增多,集群规模变大之后,该解决方案的弊端也就显现出来了。

5.2 基于Agent的分布式集群管理体系的弊端

5.2.1 大规模升级困难

以客户端形式存在的Agent, 在大规模使用后,一旦遇上需要大规模升级的情况,就非常麻烦,在升级成本和升级进度的控制上面临巨大的挑战。

5.2.2 统一的Agen无法满足多样的需求

对于机器的CPU使用率、负载(Load)内存使用率、网络吞吐以及磁盘容量等机器基本的物理状态,使用统一的 Agent来进行监控或许都可以满足。但是,如果需要深入应用内部,对一些业务状态进行监控,例如,在一个分布式消息中间件中,希望监控到每个消费者对消息的消费状态;或者在一个分布式任务调度系统中,需要对每个机器上任务的执行情况进行监控。很显然,对于这些业务耦合紧密的监控需求,不适合由一个统一的Agent来提供。

5.2.3 编程语言多样性

随着越来越多编程语言的出现,各种异构系统层出不穷。如果使用传统的Agent方式,那么需要提供各种语言的Agent客户端。另- -方面, “监控中心”在对异构系统的数据进行整合上面临巨大挑战。

5.3 ZooKeeper具有的两大特性

  • 客户端如果对ZooKeeper的一个数据节点注册Watcher监听,那么当该数据节点的内容或是其子节点列表发生变更时,ZooKeeper服务器就会向订阅的客户端发送变更通知。
  • 对在ZooKeeper上创建的临时节点,一旦客户端与服务器之间的会话失效,那么该临时节点也就被自动清除。

利用ZooKeeper 的这两大特性,就可以实现另一种集群机器存活性监控的系统。例如, 监控系统在/clusterServers节点上注册一个 Watcher监听,那么但凡进行动态添加机器的操作,就会在/clusterServers节点下创建一个临时节点: /clusterServers/[Hostname] 这样一来,监控系统就能够实时检测到机器的变动情况,至于后续处理就是监控系统的业务了。

六、Master 选举

6.1 Master选举的意义

Master选举是一个在分布式系统中非常常见的应用场景。分布式最核心的特性就是能够将具有独立计算能力的系统单元部署在不同的机器上,构成–个完整的分布式系统。而与此同时,实际场景中往往也需要在这些分布在不同机器上的独立系统单元中选出一个所谓的“老大”, 在计算机科学中,我们称之为Master.在分布式系统中,Master往往用来协调集群中其他系统单元,具有对分布式系统状态变更的决定权。例如,在一些读写分离的应用场景中,客户端的写请求往往是由Master来处理的;而在另一些场景中,Master则常常负责处理一些复杂的逻辑,并将处理结果同步给集群中其他系统单元。Master选举可以说是ZooKeeper最典型的应用场景了。

在分布式环境中,经常会碰到这样的应用场景:集群中的所有系统单元需要对前端业务提供数据,比如一个商品ID,或者是一个网站轮播广告的广告ID (通常出现在一些广告投放系统中)等,而这些商品ID或是广告ID往往需要从一系列的海量数据处理中计算得到一这通常是 一个非常耗费I/O和CPU资源的过程。鉴于该计算过程的复杂性,如果让集群中的所有机器都执行这个计算逻辑的话,那么将耗费非常多的资源。一种比较好的方法就是只让集群中的部分,甚至只让其中的一台机器去处理数据计算,一旦计算出数据结果,就可以共享给整个集群中的其他所有客户端机器,这样可以大大减少重复劳动,提升性能。

6.2 普通实现Master选举的过程

首先来明确下Master选举的需求:在集群的所有机器中选举出一台机器作为Master。
针对这个需求,通常情况下,我们可以选择常见的关系型数据库中的主键特性来实现:集群中的所有机器都向数据库中插入一条相同主键ID的记录,数据库会帮助我们自动进行主键冲突检查,也就是说,所有进行插入操作的客户端机器中,只有一台机器能够成功一那么, 我们就认为向数据库中成功插人数据的客户端机器成为Master。

6.2.1 普通实现Master选举的弊端

乍一看,这个方案确实可行,依靠关系型数据库的主键特性能够很好地保证在集群中选举出唯一的一个Master。但是我们需要考虑的另一个问题是,如果当前选举出的Master挂了,那么该如何处理?谁来告诉我Master挂了呢?显然,关系型数据库没法通知我们这个事件。

6.3 Zookeeper实现Master选举的过程

利用ZooKeeper的强一致性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即ZooKeeper将会保证客户端无法重复创建一个已经存在的数据节点。也就是说,如果同时有多个客户端请求创建同一个节点,那么最终一定只有一个客户端请求能够创建成功。利用这个特性,就能很容易地在分布式环境中进行Master选举了。

例如:
客户端集群每天都会定时往ZooKeeper上创建一个临时节点,例如/master_ election/2020-1-26/binding,在这个过程中,只有一个客户端能够成功创建这个节点,那么这个客户端所在的机器就成为了Master。同时,其他没有在ZooKeeper上成功创建节点的客户端,都会在节点/master_ election/2020-1-26上注册一个子节点变更的Watcher, 用于监控当前的Master机器是否存活,一旦发现当前的Master挂了,那么其余的客户端将会重新进行Master选举。

七、分布式锁

7.1 什么是分布式锁

分布式锁是控制分布式系统之间同步访问共享资源的一种方式。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要通过一些互斥手段来防止彼此之间的干扰,以保证一致性,在这种情况下,就需要使用分布式锁了。

在平时的实际项目开发中,我们往往很少会去在意分布式锁,而是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥。这确实是一种非常简便且被广泛使用的分布式锁实现方式。然而有一个不争的事实是,目前绝大多数大型分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给数据库添加一些额外的锁,例如行锁、表锁甚至是繁重的事务处理,就会让数据库更加不堪重负。所以一般不使用数据库来实现分布式锁。

7.2 Zookeeper实现分布式锁

7.2.1 排他锁

7.2.1.1 什么是排他锁

排他锁(Exclusive Locks, 简称X锁),又称为写锁或独占锁,是一种基本的锁类型。
如果事务T对数据对象O 加上了排他锁,那么在整个加锁期间,只允许事务T对O进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作一直到T释放了排他锁。排他锁的核心是如何保证当前有且仅有一个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。

7.2.1.2 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监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。整个排他锁的获取和释放流程,见下图。

7.2.2 共享锁

7.2.2.1 什么是共享锁

共享锁(Shared Locks,简称S锁),又称为读锁,同样是一种基本的锁类型。如果事务T对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁一直到该数据对象上的所有共享锁都被释放。共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对所有事务都可见。

7.2.2.2 ZooKeeper来实现共享锁

定义锁

和排他锁一样,同样是通过ZooKeeper.上的数据节点来表示一一个锁, 是一个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点,例如/shared_lock/192.168.0.1-R-0000000001,那么,这个节点就代表了一个共享锁。

获取锁
在需要获取共享锁时,所有客户端都会到/shared_lock这个节点下面创建一个临时顺序节点,如果当前是读请求,那么就创建例如/shared_ lock/192.168.0.1-R-0000000001的节点;如果是写请求,那么就创建例如/shared_ lock/192.168.0.1- W000000001的节点。

判断读写顺序
根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,我们来看看如何通过ZooKeeper的节点来确定分布式读写顺序,大致可以分为如下4个步骤。

  1. 创建完节点后,获取/shared_ lock节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。
  2. 确定自己的节点序号在所有子节点中的顺序。
  3. 对于读请求:
    如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑。如果比自己序号小的子节点中有写请求,那么就需要进入等待。
    对于写请求:
    如果自己不是序号最小的子节点,那么就需要进入等待。
  4. 接收到Watcher通知后,重复步骤1。

释放锁

和排他锁一样。

完整的共享锁流程。如图。

7.2.2.2 共享锁带来的问题(羊群效应)

上面的这个共享锁实现,大体上能够满足一般的分布式集群竞争锁的需求,开且性能都还可以,这里说的一般场景是指集群规模不是特别大,一般是在10台机器以内。

7.2.2.2.1共享锁在实际运行中最主要的步骤
  1. 192.168.0.1 这台机器首先进行读操作,完成读操作后将节点/192.168.0.1-R-000000001删除。
  2. 余下的4台机器均收到了这个节点被移除的通知,然后重新从/shared_lock节点上获取一份新的子节点列表。
  3. 每个机器判断自己的读写顺序。其中192.168.0.2 这台机器检测到自己已经是序号最小的机器”了,于是开始进行写操作,而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。
  4. 继续…
    上面这个过程就是共享锁在实际运行中最主要的步骤了,我们着重看下上面步骤3中提到的:“而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。”很明显,我们看到,192.168.0.1 这个客户端在移除自己的共享锁后,ZooKeeper发送了子节点变更Watcher通知给所有机器,然而这个通知除了给192.168.0.2这台机器产生实际影响外,对于余下的其他所有机器都没有任何作用。
7.2.2.2.2 羊群效应

在这整个分布式锁的竞争过程中,大量的“ Watcher通知”和“子节点列表获取”两个操作重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知,这个看起来显然不怎么科学。客户端无端地接收到过多和自己并不相关的事件通知,如果在集群规模比较大的情况下,不仅会对ZooKeeper服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper服务器就会在短时间内向其余客户端发送大量的事件通知——这就是所谓的羊群效应
上面这个ZooKeeper分布式共享锁实现中出现羊群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾一下上面的分布式锁竞争过程,它的核心逻辑在于:判断自己是否是所有子节点中序号最小的。于是,很容易可以联想到,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了一而不需要关注全局的子列表变更情况。

7.2.3 改进后的分布式锁实现

现在我们来看看如何改进上面的分布式锁实现。首先,我们需要肯定的一点是,上面提.到的共享锁实现,从整体思路上来说完全正确。这里主要的改动在于:**每个锁竞争者,只需要关注/shared_lock节点下序号比自己小的那个节点是否存在即可,**具体实现如下。

  1. 客户端调用create()方法创建-一个类似于“/shared_ lock/[Hostname]-请求类型序号”的临时顺序节点。
  2. 客户端调用getChildren() 接口来获取所有已经创建的子节点列表,注意,这里不注册任何Watcher。
  3. 如果无法获取共享锁,那么就调用exist()来对比自己小的那个节点注册Watcher。
    注意,这里“比自己小的节点”只是一个笼统的说法,具体对于读请求和写请求不一样。
    读请求:向比自己序号小的最后一个写请求节点注册Watcher监听。
    写请求:向比自己序号小的最后一个节点注册Watcher监听。
  4. 等待Watcher通知,继续进入步骤2。
    改进后的分布式锁流程如下图所示。
    在这里插入图片描述

7.2.4 建议

在多线程并发编程实践中,我们会去尽量缩小锁的范围一对于分布式锁实现的改进其实也是同样的思路。那么对于开发人员来说,是否必须按照改进后的思路来设计实现自己的分布式锁呢?答案是否定的。在具体的实际开发过程中,建议根据具体的业务场景和集群规模来选择适合自己的分布式锁实现。
在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是简单实用的选择
而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么不妨试试改进版的分布式锁实现

八、分布式队列

分布式队列,简单地讲分为两大类,一种是常规的先入先出队列,另一种则是要等到队列元素集聚之后才统一安排执行的Barrier模型。

8.1 FIFO:先入先出队列

FIFO (First Input First Output,先入先出)的算法思想,以其简单明了的特点,广泛应用于计算机科学的各个方面。而FIFO队列也是一种非常典型且应用广泛的按序执行的。

队列模型:先进入队列的请求操作先完成后,才会开始处理后面的请求。
使用ZooKeeper实现FIFO队列,和共享锁的实现非常类似。FIFO 队列就类似于一个全写的共享锁模型,大体的设计思路其实非常简单:所有客户端都会到/queue_ fifo这个节点下面创建一个临时顺序节点,例如/queue_ fifo/192.168.0.1-0000000001

创建完节点之后,根据如下4个步骤来确定执行顺序。

  1. 通过调用getChildren()接口来获取/queue_ fifo 节点下的所有子节点,即获取队列中所有的元素。
  2. 确定自己的节点序号在所有子节点中的顺序。
  3. 如果自己不是序号最小的子节点,那么就需要进入等待,同时向比自己序号小的最后一个节点注册Watcher监听。
  4. 接收到Watcher通知后,重复步骤1。

8.2 Barrier:分布式屏障

Barrier原意是指障碍物、屏障,而在分布式系统中,特指系统之间的一个协调条件,规定了一个队列的元素必须都集聚后才能统一进行安排, 否则一直等待。这往往出现在那些大规模分布式并行计算的应用场景上:最终的合并计算需要基于很多并行计算的子结果来进行。

这些队列其实是在FIFO队列的基础上进行了增强,大致的设计思想如下:
开始时,/queue_ barrier 节点是一个已经存在的默认节点,并且将其节点的数据内容赋值为一个数字n来代表Barrier 值,例如n=10表示只有当/queue_ barrier 节点下的子节点个数达到10后,才会打开Barrier。 之后,所有的客户端都会到/queue_ barrier 节点下创建一个临时节点,例如/queue_ barrier/192.168.0.1

创建完节点之后,根据如下5个步骤来确定执行顺序。

  1. 通过调用getData()接口获取/queue_ barrier 节点的数据内容: 10。
  2. 通过调用getChildren( )接口获取/queue_ barrier 节点下的所有子节点,即获取队列中的所有元素,同时注册对子节点列表变更的Watcher监听。
  3. 统计子节点的个数。
  4. 如果子节点个数还不足10个,那么就需要进入等待。
  5. 接收到Watcher通知后,重复步骤2。
发布了46 篇原创文章 · 获赞 10 · 访问量 4313

猜你喜欢

转载自blog.csdn.net/weiwei_six/article/details/104084711