Zookeeper 应用场景

Zookeeper 应用场景

zookeeper 是一个典型的发布/订阅模式的分布式数据管理与协调框架,我们可以使用它来进行分布式数据的发布与订阅。另一方面,通过对 zookeeper 中丰富的数据节点类型进行交叉使用,配合 Watcher 事件通知机制,可以非常方便地构建一系列分布式应用中都会涉及的核心功能,如 数据发布/订阅、命名服务、集群管理、Master选举、分布式锁、分布式队列等

数据发布/订阅

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

发布/订阅系统一般有两种设计模式,分别是 推(Push)模式和拉(Pull)模式。在推模式中,服务端主动将数据更新发送给所有订阅的客户端;而拉模式则是由客户端主动发起请求来获取最新数据,通常客户端都采用定时进行轮询拉取的方式。

zookeeper 采用的是推拉相结合的方式:

客户端向服务端注册自己需要关注的节点,一旦该节点的数据发生变更,那么服务端就会向相应的客户端发送 Watcher 事件通知,客户端接收到这个消息后,需要主动到服务端获取最新的数据。

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

下面我们通过一个“配置管理”的实际案例来展示zookeeper在“数据发布/订阅”场景下的使用方式:

需求:系统中需要使用一些通用的配置信息,例如机器列表信息、运行时的开关配置、数据库配置信息等。这些全局配置通常具备以下三个特性:

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

对于这类配置信息,一般的做法通常可以选择将其存储在本地配置文件或是内存变量中。无论采用哪种方式,其实都可以简单地实现配置管理,在集群机器规模不大、配置变更不是特别频繁的情况下,无论刚刚提到的哪种方式,都能够非常方便地解决配置管理的问题。

但是,一旦机器规模变大,且配置信息变更越来越频繁后,我们发现依靠现有的这两种方式解决配置管理就变得越来越困难。我们既希望能够快速地做到全局配置信息的变更,同时希望变更成本足够小,因此我们必须寻求一种更为分布式化的解决方案。

接下来我们就以一个 “数据库切换” 的应用场景展开,看看如何使用 zookeeper来实现配置管理:

  • 配置存储

    在进行配置管理之前,首先我们需要将初始化配置信息存储到 zookeeper 上去,一般情况下,我们可以在 zookeeper上选取一个数据节点用于配置信息的存储,例如 /app1/database_config,配置管理的zookeeper 节点示意图:

    在这里插入图片描述

​ 我们将需要管理的配置信息写入到该数据节点中取,如:

#数据库配置信息
#DBCP
dbcp.driverClassName=com.mysql.jdbc.Driver
dbcp.dbJDBCUrl=jdbc:mysql://127.0.0.1:3306/test
dbcp.username=root
dbcp.password=root
dbcp.maxActive=30
dbcp.maxIdle=10
  • 配置获取

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

  • 配置变更

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

命名服务

命名服务(Name Service)也是分布式系统中比较常见的一类场景,是分布式系统最基本的公共服务之一。在分布式系统中,被命名的实体通常可以是集群中的机器、提供的服务地址或远程对象等,这些我们都可以统称它们为名字(Name),其中较为常见的就是一些分布式服务框架(如RPC、RMI)中的服务地址,通过使用命名服务,客户端应用能够根据指定的名字来获取资源的实体、服务地址和提供者的信息等。

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

所以接下来,我们来看看如何使用 zookeeper 来实现一套分布式全局唯一ID的分配机制:

所谓 ID,就是一个能够唯一标识某个对象的标识符。在我们熟悉的关系型数据库中,各个表都需要一个主键来唯一标识每条数据库记录,这个主键就是这样的唯一 ID。在过去的单表单库型系统中,通常可以使用数据库字段自带的 auto_increment 属性来自动为每条数据库记录生成一个唯一的 ID,数据库会保证生成的这个 ID 在全局唯一。但是随着数据库数据规模不断增大,分表分库随之出现,而 auto_increment 属性只能针对单一表中的记录自动生成 ID,在这种情况下,我们必须寻求一种能够在分布式环境下生成全局唯一 ID 的方法。

  • UUID:

    一说起全局唯一 ID,很快就会联想到 UUID,没错,UUID 是通用唯一识别码(Universally Unique Identifier)的简称,是一种在分布式系统中广泛使用的用于唯一标识元素的标准。确实,UUID 是一个非常不错的全局唯一 ID 生成方式,能够非常简便地保证分布式环境中的唯一性。一个标准的 UUID 是一个包含32位字符和4个短线的字符串,例如 e70f1357-f260-46ff-a32d-53a086c57ade,但是UUID的长度过长,含义也不明确。

  • Zookeeper 的全局唯一ID:

    我们结合一个分布式任务调度系统来看看如何使用 zookeeper 来实现这类全局唯一 ID 的生成。

    我们可以通过调用 Zookeeper 节点创建的 API 接口可以创建一个顺序节点,并且会返回这个节点的完整名字,利用这个特性,我们可以借助 zookeeper 来生成全局唯一ID:

在这里插入图片描述

对于一个任务列表的主键,使用zookeeper 生产唯一 ID 的基本步骤如下:

  1. 所有客户端都会根据自己的任务类型,在指定类型的任务下面通过调用 create() 接口来创建一个顺序节点,例如创建 job- 节点。
  2. 节点创建完成完毕后,create() 会返回一个完整的节点名,例如 job-0000000003
  3. 客户端拿到这个返回值后,拼接上 type 类型,例如 type2-job-0000000003 ,这就考验作为一个全局唯一的ID了。

集群管理

随着分布式系统规模的日益扩大,集群中的机器规模也随之变大,那如何更好地进行集群管理也显得尤为重要。所谓集群管理,包括集群监控与集群控制两大块,前者侧重对集群运行时状态的收集,后者则是对集群进行操作与控制

在日常开发和运维过程中,我们经常会有类似如下的需求:

  • 如何快速统计出当前生产环境下一共有多少台机器
  • 如何快速获取到机器上下线情况
  • 如何实时监控集群中每台主机的运行时状态

在传统的基于 Agent 的分布式集群管理体系中,都是通过在集群中的每台机器上部署一个 Agent,由这个 Agent 负责主动向指定的一个监控中心系统汇报自己所在机器的状态。在集群规模适中的场景,这确实是一种在生产实践中广泛使用的解决方案,能够有效快速地实现分布式环境集群监控,但是一旦业务场景增多、集群规模变大之后,该解决方案的弊端也就显现出来了:

  • 大规模升级困难:

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

  • 统一的 Agent 无法满足多样的需求

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

zookeeper 的两大特性:

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

利用这两个特性,可以实现集群机器存活监控系统,若监控系统在 /clusterServers 节点上注册一个 Watcher 监听,那么但凡进行动态添加机器的操作,就会在 /clusterServers 节点下创建一个临时节点 /clusterServers/[hostname] ,这样,监控系统就能够实时监测机器的上下线情况。

下面通过一个分布式日志收集系统来看下如何用 zookeeper 实现集群管理:

分布式日志收集系统的核心工作就是收集分布在不同机器上的系统日志,在这里我们重点来看分布式日志系统的收集器模块。

在一个典型的日志系统架构中,整个日志系统会把所有需要收集的日志机器分为多个组别,每个组别对应一个收集日志机器

对于大规模分布式日志收集系统场景,通常要解决两个问题:

  1. 变化的日志源机器
  2. 变化的收集器机器

无论是日志源机器还是收集器机器的变更,最终都可以归结为如何快速、合理、动态地为每个收集器分配对应的日志源机器。这也成为了整个日志系统正确稳定运转的前提,也是日志收集过程中最大的技术挑战之一。我们可以引入 zookeeper,看看zookeeper是如何解决这些问题的:

① 注册收集器机器

​ 在zookeeper上创建一个节点,如/logs/collector ,每台收集器机器在启动的时候,都会在 /logs/collector 节点下创建自己的节点 /logs/collector/hostname

在这里插入图片描述

② 任务分发

待所有收集器机器都创建好节点后,系统根据收集器节点下的子节点个数,将所有日志源分为对应的若干组,然后将分组后的机器列表分别写到这些收集器机器创建的子节点(例如: /logs/collector/host1 )上去,这样一来,每个收集器机器都能够从自己对应的数据节点上获取到日志源机器列表,开始进行日志收集工作

③ 任务汇报

完成收集器的注册以及任务分发后,我们还要考虑到这些机器随时都有挂掉的可能。因此,针对这个问题,我们需要有一个收集器状态汇报机制:每个收集器机器在创建完自己的数据节点后,还需要在节点上创建一个状态子节点,例如 /logs/collector/hostname/status ,每个收集器机器都需要定时向该节点写入自己的状态信息。我们可以把这种策略看做是一种心跳检测机制,通常收集器会在这个节点写入日志收集进度信息。日志系统根据这个状态的最后更新时间来判断对应收集器是否存活。

④ 动态分配

如果收集器机器扩容或者挂掉了,就需要动态地分配收集任务。在运行过程中,日志系统始终关注这 /logs/collector 这个节点下所有子节点的变更,一旦检测到有收集器机器停止汇报或者有新的收集器加入,就要开始进行任务的重新分配。无论扩容还是缩容,日志系统都需要将之前分配的任务进行重新分配,通常有两种做法:

  • 全局动态分配

    简单粗暴,无论扩容还是缩容,都把所有的日志源机器重新分组,再分配给收集器机器。

    但是这样会导致,一个或一部分机器的变更,都要导致全局变动,影响范围太大。

  • 局部动态分配

    在这种策略中,每个收集器机器在汇报自己日志收集状态的同时,也会把自己的负载上报。如果有机器挂了,日志系统会把之前分配给这个机器的任务重新分配到那些负载比较低的机器中。如果有新机器加入,则会把高负载的机器转移部分给新机器。

上述步骤以及大概说明了整个日志收集系统的工作流程,其中有两点需要注意:

  • 节点类型:

    /logs/collector 节点下创建临时节点是可以很方便的判断机器是否存活,但如果机器挂了,其节点会被删除,记录在节点上的日志源机器列表也被清除。所以需要选择持久性节点来标识每一台机器,同时创建子节点 status 来表示机器状态,这样既能实现对所有机器的监控,当机器挂掉后,依然能够将分配任务还原。

  • 日志系统节点监听

    若采用 Watcher 机制,那么通知消息的网络开销非常大;若采用日志系统主动轮询收集器节点的状态,这样可以节省网络流量,但存在一定的延时。

Master 选举

Master 选举是一个在分布式系统中非常常见的应用场景。分布式最核心的特性就是能够将具有独立计算能力的系统单元部署在不同的机器上,构成一个完整的分布式系统。

在分布式系统中,Master 往往用来协调集群中其他系统单元,具有对分布式系统状态变更的的决定权。例如:在一些读写分离的应用场景中,客户端的写请求往往是由 Master来处理的;而在另一些场景中,Master 则常常负责处理一些复杂的逻辑,并将处理结果同步给集群中其他机器。

Master 选举可以说是zookeeper最经典的应用场景了,接下来,我们结合“一种海量数据处理与共享模型”这个具体例子来看看 zookeeper 在集群 Master 选举中的应用场景。

分布式环境下,有一种场景:集群中的所有系统单元需要对前端业务提供数据,比如一个广告ID(广告投放系统),而这个ID往往需要从一系列的海量数据处理计算得到的,这通常是一个非常耗费IO和CPU资源的过程。鉴于该计算过程的复杂性,如果让集群中的所有机器都执行这个计算逻辑的话,将耗费非常多的资源。比较好的方法就是,让集群中的部分或者其中一台机器去处理,计算出结果后共享给集群中的其他机器。

在这里插入图片描述

整个系统大体上可以分为客户端集群、分布式缓存系统、海量数据处理总线和 zookeeper 四个部分。

首先我们来看整个系统的运行机制,图中的 Client 集群每天定时通过 zookeeper来实现Master 选举。选举产生 Master客户端后,这个Master就会负责进行一系列的海量数据处理,最终计算得到一个结果,并将其放在数据库/共享内存中,其他客户端可以访问。

我们重点看 Master 选举的过程,首先来明确下 Master 选举的需求:在集群中所有机器中选举出一台机器作为 Master。针对这个需求,我们可以选择常见的关系型数据库的主键特性来实现:集群中的所有机器都向数据库中插入一条相同主键ID的记录,数据库会帮我们做主键冲突检查,最终只有一台机器能够插入成功。

借助数据库的这种方案确实能选举出唯一的Master,但是如果 Master挂了,关系型数据库明显不能通知我们这个事件。那我们可以使用 zookeeper 来完成整个 Master选举的过程:

利用zookeeper的强一致性,能够很好保证在分布式高并发情况下节点的创建是唯一的,即zookeeper无法创建已存在的数据节点。利用这个特性,就能很容易在分布式环境下进行Master选举了:

在这里插入图片描述

在这个系统中,客户端集群每天会定时在zookeeper上创建一个临时节点,例如 /master_election/2020-11-11/binding ,在这个过程中,只有一个客户端能够成功创建,即为 Master。其他没有选举成功的节点,将会在这个master 节点上注册一个子节点变更的 Watcher,用于监控当前master是否存活,一旦master挂了,其余客户端会重新进行master选举。

分布式锁

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

在平时的实际项目开发中,我们往往很少会去在意分布式锁,而是依赖关系型数据库的事务来实现不同进程之间的互斥。这确实是一种非常简便且被广泛使用的分布式锁实现方式。然而,目前绝大多数的分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给数据库再添加一些额外的锁,那么会使数据库负担更重。

下面我们来看看使用 zookeeper如何实现分布式锁,这里主要讲排他锁和共享锁两种

排他锁

排他锁(Exclusive Locks,简称 X 锁),又称为写锁或独占锁,是一种基本的锁类型。如果事务 T1 对数据对象 O1 加上了排他锁,那么在整个加锁期间,只允许事务 T1 对 O1 进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作——直到 T1 释放了排他锁

从上面的概念中我们可以看到,排他锁的核心是—— 保证当前有且只有一个事务获得锁,并且锁释放后,所有正在等待获取锁的事务都能被通知到。

下面我们来看看如何使用zookeeper实现排他锁:

① 定义锁

在 Java 开发中,有两种常见的方式可以用来定义锁,分别是 synchronized 机制和 JDK5提供的 ReentrantLock 。在zookeeper中,没有类似这样的API 可以直接使用,而是通过 zookeeper 上的一个临时数据节点来表示锁。例如:/exclusive_lock/lock 就可以被定义为一个锁。

② 获取锁

在需要获取排他锁时,所有的客户端都会试图通过创建 /exclusive_lock/lock 节点来获取锁。zookeeper 保证在所有的客户端中,只有一个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,没有获取到该锁的客户端则在 /exclusive_lock 上注册一个子节点变更的 Watcher 监听,以便实时监听到lock节点的变更情况

③ 释放锁

/exclusive_lock/lock 是一个临时节点,因此在这两种情况下,都有可能释放锁:

  • 获取到锁的客户端断开连接
  • 正常执行完业务逻辑后,客户端主动删除这个锁节点

在这里插入图片描述

共享锁

共享锁(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/host1-W-0000000002 的节点

判断读写顺序

通过zookeeper来确定分布式读写顺序,大致分为四步

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

③ 释放锁

和排他锁一样:

  • 获取到锁的客户端断开连接
  • 正常执行完业务逻辑后,客户端主动删除这个锁节点

羊群效应

上面的这个共享锁实现,在机器规模不大的情况下,能满足一般的需求,但当机器规模扩大后,会出现一些问题。我们着重来看上面判断读写顺序的步骤3,结合下面的图,看看实际运行情况

在这里插入图片描述

  1. host1首先进行读操作,完成后删除 /shared_lock/host1-R-00000001 节点
  2. 余下4台机器收到这个节点移除的通知,重新从 /shared_lock 节点获取一份新的子节点列表
  3. 每台机器判断自己的读写顺序,host2 检测到自己的序号最小,执行写操作,余下机器继续等待
  4. 继续…

可以发现,host1客户端在移除自己的共享锁后,zookeeper发送了子节点变更的 Watcher 通知给所有机器,然而除了给host2产生影响外,其他机器其实不需要任何操作。大量的 Watcher 通知和子节点列表获取这两个操作会重复运行,会给zookeeper服务器造成没必要的开销,zookeeper也会在段时间内向其余客户端发送大量的事件通知,这就是所谓的羊群效应。

改进后的分布式共享锁实现:

首先,我们需要肯定的是,上面提到的共享锁实现,整体思路是对的,我们需要改动的是:每个锁竞争者,只需关注 /shared_lock 节点下序号比自己小的那个节点是否存在即可,具体实现如下:

  1. 客户端调用 create 接口创建 /shared_lock/[hostname]-请求类型-序号 的临时顺序节点
  2. 客户端调用 getChildren 接口获取所有已经创建的子节点列表(不注册Watcher)
  3. 如果无法获取共享锁,就调用 exist 接口来对比自己小的节点注册 Watcher。对于读请求:向比自己序号小的最后一个写请求节点注册 Watcher 监听;对于写请求:向比自己小的最后一个节点注册 Watcher 监听
  4. 等待 Watcher 通知,继续进入步骤2

此方案改动在于:每个锁竞争者,只需关注 /shared_lock 节点下序号比自己小的那个节点是否存在即可

在这里插入图片描述

分布式队列

分布式队列可以简单分为两大类:

  1. FIFO先进先出队列模型
  2. 等待队列元素聚集后统一安排处理的 Barrier 模型

FIFO 先进先出

FIFO(First Input First Output)先进先出队列是一种非常典型且广泛应用在按序执行的队列模型:先进入队列的请求操作先完成后,才会开始处理后面的请求。

使用 zookeeper 实现FIFO队列,和共享锁的实现非常类似。FIFO队列就类似于一个全写的共享锁模型,大体的思路:所有的客户端到 /queue_fifo 这个节点创建一个临时顺序节点,例如 /queue_fifo/host1-00000001

在这里插入图片描述

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

  1. 通过 getChildren 接口来获取 /queue_fifo 节点的所有子节点,即队列中的所有元素

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

  3. 如果自己的序号不是最小,那么需要等待,同时向比自己序号小的最后一个节点注册 Watcher监听

  4. 接收到 Watcher 通知后,重复步骤1

在这里插入图片描述

Barrier :分布式屏障

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

  • 开始时,/queue_barrier 节点是一个已经存在的默认节点,并且将其节点的数据内容赋值为一个数字 n 来代表 Barrier 值(例如 n = 10 表示只有当 /queue_barrier 节点下的子节点个数达到10后,才会打开 Barrier)

  • 之后,所有的客户端都会到 /queue_barrier 节点下创建一个临时节点,例如 /queue/barrier/host1 ,如图所示

在这里插入图片描述

  • 创建完节点后,按照如下步骤执行
    • 通过调用 getData 接口获取 /queue_barrier 节点的数据内容 : 10
    • 通过调用 getChildren 接口获取 /queue_barrier 节点下的所有子节点,同时注册对子节点变更的 Watcher 监听
    • 统计子节点的个数
    • 如果子节点个数还不足 10 个,那么需要等待
    • 接收到 Watcher 通知后,重复步骤2

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值