Skip to content

Latest commit

 

History

History

apache-zookeeper

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

Zookeeper

核心概念

Zookeeper 的数据模型是类似文件系统的树型结构,树的每一个节点叫做znode,它可以存储少量数据,它还可以有多个子节点。 Zookeeper 的根节点名字为/,节点路径是节点的唯一标识,比如/config/video。提供createdelete来创建和删除节点,ls获取子节点列表,setget来读写节点的内容。 Zookeeper中只有绝对路径,没有相对路径。

一共有四种不同类型的节点:

  • 持久节点(PERSISTENT):默认的节点类型,节点一旦创建,除非显示删除,否则一直存在
  • 临时节点(EPHEMERAL):Zookeeper的客户端和服务端使用长连接方式进行通信,并通过心跳来保持连接,这个连接状态称之为session, 客户端在创建节点之后,如果一直保持连接则节点有效,一旦连接断开,该节点自动删除; 注意,临时节点不能有子节点。
  • 持久顺序节点(PERSISTENT_SEQUENTIAL):默认情况下,Zookeeper不允许创建同名节点,如果节点是顺序节点,如果该节点是顺序节点,Zookeeper就会自动在节点路径末尾添加递增序号;
  • 临时顺序节点(EPHEMERAL_SEQUENTIAL):顺序节点,但是只有在客户端连接的时候存在

准确来说,节点的类型只有持久和临时两种,顺序节点是指在创建节点时可以指定一个顺序标志,让节点名称添加一个递增的序号,但是节点一旦创建好了,它要么是持久的,要么是临时的,只有这两种类型。
这几种类型的节点虽然看上去很平常,但是它们正是实现 ZooKeeper 分布式协调服务的关键,如果再加上节点监听的特性,可以说是无所不能。节点监听(Watch)可以用于监听节点的变化,
包括节点数据的修改或者子节点的增删变化,一旦发生变化,可以立即通知注册该 Watch 的客户端。我们在后面的例子中将会看出这些特性结合在一起的强大威力。
譬如我们在执行 get 命令查询节点数据时指定一个 Watch,那么当该节点内容发生变动时,就会触发该 Watch,要注意的是 Watch 只能被触发一次,如果要一直获得该节点数据变动的通知,
那么就需要在触发 Watch 时重新指定一个 Watch。只有节点的读操作(例如:get、ls、stat)可以注册 Watch,写操作(例如:set、create、delete)会触发 Watch 事件。

使用 ZooKeeper 客户端

# 不连接远程可省略 -server 和 ip
zkCli.sh -server 192.168.0.101:2181
# 使用 help 查看可用命令列表
help
# 几个基本命令:get、set、ls、create、delete 等。譬如我们通过 ls 命令查看根节点 / 的子节点
ls /
# create 默认创建的是持久节点,可以指定参数 -e 创建临时节点或 -s 创建顺序节点
create /data Hello
# 删除节点数据
delete /data/s1
# 整个节点删除
rmr /data/s1

统一命名服务(Name Service)

所谓命名服务,就是帮助我们对资源进行统一命名的服务,通常需要有一套完整的命名规则,既能够产生唯一的名称又便于人们识别和记住,通常情况下用树形的名称结构是一个理想的选择,
树形的名称结构是一个有层次的目录结构,既对人友好又不会重复。使用命名服务可以更方便的对资源进行定位,比如计算机地址、应用提供的服务地址或者远程对象等。

想象一下 DNS,它就是一种命名服务,可以将域名转换为 IP 地址,这里的域名就是全局唯一的名称,方便人们记忆,而 IP 地址就是该名称对应的资源。再想象一下 JNDI,这也是一种命名服务,
JNDI 的全称为 Java Naming and Directory Interface(Java 命名和目录接口),它是 J2EE 中重要的规范之一,标准的 J2EE 容器都提供了对 JNDI 规范的实现,
它也是将有层次的目录结构关联到一定资源上。譬如我们在配置数据源时一般会在 JDBC 连接字符串中指定数据库的 IP 、端口、数据库名、用户名和密码等信息,这些信息如果散落在分布式应用的各个地方,不仅会给资源管理带来麻烦,比如当数据库 IP 发生变动时要对各个系统进行修改,而且数据库的用户名密码暴露在外,也存在安全隐患。使用 JNDI 可以方便的解决这两方面的问题。

在 ZooKeeper 中创建的所有节点都具有一个全局唯一的路径,其对应的节点可以保存一定量的信息,这个特性和命名服务不谋而合。所以如果你在分布式应用中需要用到自己的命名服务,
使用 ZooKeeper 是个比较合适的选择。

配置管理(Configuration Management)

正如上面所说的数据库配置一样,在应用程序中一般还会用到很多其他的配置,这些配置往往都是写在某个配置文件中,程序在运行时从配置文件中读取。
如果程序是单机应用,并且配置文件数量不多,变动也不频繁,这种做法倒没有什么大问题。但是在分布式系统中,每个系统都有大量的配置文件,而且某些配置项是相同的,
如果这些配置项发生变动时,让运维人员在每台服务器挨个修改配置文件,这样的维护成本就太高了,不仅麻烦也容易出错。

配置管理(Configuration Management)在分布式系统中很常见,一般也叫做 发布与订阅,我们将所有的配置项统一放置在一个集中的地方,所有的系统都从这里获取相应的配置项,
如果配置项发生变动,运维人员只需要在一个地方修改,其他系统都可以从这里获取变更。在 ZooKeeper 中可以创建一个节点,比如:/configuration,并将配置信息放在这个节点里,
在应用启动的时候通过 getData() 方法,获取该节点的数据(也就是配置信息),并且在节点上注册一个 Watch,以后每次配置变动时,应用都会实时得到通知,
应用程序获取最新数据并更新配置信息即可。

集群管理(Group Membership)

在分布式系统中,我们常常需要将多台服务器组成一个集群,这时,我们就需要对这个集群中的服务器进行管理,譬如:我们需要知道当前集群中有多少台服务器,当集群中某台服务器下线时需要及时知道,
能方便的向集群中添加服务器。利用 Zookeeper 可以很容易的实现集群管理的功能,实现方法很简单,首先我们创建一个目录节点 /groups,用于管理所有集群中的服务器,
然后每个服务器在启动时在 /groups 节点下创建一个 EPHEMERAL 类型的子节点,譬如 /member-1、member-2 等,并在父节点 /groups 上调用 getChildren() 方法并设置 Watch,
这个 Watch 会在 /groups 节点的子节点发生变化(增加或删除)时触发通知,由于每个服务器创建的子节点是 EPHEMERAL 类型的,当创建它的服务器下线时,这个子节点也会随之被删除,
从而触发 Watch 通知,这样其它的所有服务器就知道集群中少了一台服务器,可以使用 getChildren() 方法获取集群的最新服务器列表,并重新注册 Watch。

集群选主(Leader Election)

在上面的集群管理一节,我们看到了可以使用 EPHEMERAL 类型的节点,对集群中的成员进行管理和监控,其实集群管理除了成员的管理和监控功能之外,还有另一个功能,
那就是:集群选主(Leader Election),也叫做 Leader 选举或 Master 选举。这个功能在分布式系统中往往很有用,比如,应用程序部署在不同的服务器上,它们都运行着相同的业务,
如果我们希望某个业务逻辑只在集群中的某一台服务器上运行,就需要选择一台服务器出来作为主服务器。一般情况下,在一个集群中只有一台主服务器(Master 或 Leader),
其他的都是从服务器(Slave 或 Follower)。我们刚刚已经在目录节点 /groups 下创建出一堆的成员节点 /member-1、member-2 了,那么怎么知道哪个节点才是 Master 呢?

实现方法很简单,和前面一样,我们还是为每个集群成员创建一个 EPHEMERAL 节点,不同的是,它还是一个 SEQUENTIAL 节点,这样我们就可以给每个成员编号,然后选择编号最小的成员作为主服务器。
这样做的好处是,如果主服务器下线,这个编号的节点也会被删除,然后通知集群中所有的成员,这些成员中又会出现一个编号是最小的,继而被选择当作新的主服务器。

我们在创建节点时,选择 CreateMode.EPHEMERAL_SEQUENTIAL 模式,并将创建的节点名称保存下来。使用 getChildren() 方法获取集群成员列表时,
按序号排序,取序号最小的一个成员,如果和自己的节点名称一样,则可以认为自己就是主服务器。

上面介绍的这个方法可以动态的选出集群中的主服务器,所以又叫 动态选主,实际上,还有一种 静态选主 的方法,这个方法利用了 ZooKeeper 节点的全局唯一性,
如果多个服务器同时创建 /master 节点,最终一定只有一个服务器创建成功,利用这个特性,谁创建成功,谁就是主服务器。这种方法非常简单粗暴,如果对可靠性要求不高,
不需要考虑主服务器下线问题,可以考虑采用这种方法。

分布式锁(Locks)

在单个应用中,锁可以防止多个线程同时访问同一个资源,常用的编程语言都提供了锁机制很容易实现,但是在分布式系统中,要防止多个服务器同时访问同一个资源,就不好实现了。
不过在上一节中,我们刚刚介绍了如何使用 ZooKeeper 来做集群选主,可以在多个服务器中选择一个服务器作为主服务器,这和分布式锁要求的多个服务器中只有一个服务器可以访问资源的概念是完全一样的。

我们介绍了两种集群选主的方法,刚好对应锁服务的两种类型:静态选主方法是让所有的服务器同时创建一个相同的节点 lock,最终只有一个服务器创建成功,那么创建成功的这个服务器就相当于获取
了一个独占锁。动态选主方法是在某个目录节点 locks 下创建 EPHEMERAL_SEQUENTIAL 类型的子节点,譬如,lock-1、lock-2 等,然后调用 getChildren() 方法获取子节点列表,
将这些子节点按序号排序,编号最小的即获得锁,同时监听目录节点变化;释放锁就是将该子节点删除即可,那么其他所有服务器都会收到通知,每个服务器检查自己创建的节点是不是序号最小的,
序号最小的服务器再次获取锁,依次反复。

我们假设有 100 台服务器试图获取锁,这些服务器都会在目录节点 locks 上监听变化,每次锁的释放和获取,也就是子节点的删除和新增,都会触发节点监听,所有的服务器都会得到通知,但是节点新增
并不会发生锁变化,节点删除也只有序号最小的那个节点可以获取锁,其他节点都不会发生锁变化,像这种有大量的服务器得到通知而只有很小的一部分服务器对通知做出响应的现象,有时候又被称为 羊群效应(Herd Effect),这无疑对 ZooKeeper 服务器造成了很大的压力。

为了解决这个问题,我们可以不用关注 locks 目录节点下的子节点变化(删除和新增),也就是说不使用 getChildren() 方法注册节点监听,而是只关注比自己节点小的那个节点的变化,
我们通过使用 exists() 方法注册节点监听

栅栏和双栅栏(Barrier & Double Barrier)

栅栏(Barrier) 是用于阻塞一组线程执行的一种同步机制,只有当这组线程全部都准备就绪时,才开始继续执行,就好像赛马比赛,先要等所有的赛马都来到起跑线前准备就绪,然后才能开始比赛。

双栅栏的意思不言而喻,就是两道栅栏,第一道栅栏用于同步一组线程的开始动作,让一组线程同时开始,第二道栅栏用于同步一组线程的结束动作,让它们同时结束,这就好像在赛马比赛中,
要等所有的赛马都跑到终点比赛才真正结束一样。

使用 ZooKeeper 实现栅栏很简单,和上面的集群选主和分布式锁类似,都是先创建一个目录节点 /barrier,然后每个线程挨个在这个节点下创建 EPHEMERAL_SEQUENTIAL 类型的子节点,
譬如 node-1,node-2 等,表示这个线程已经准备就绪,然后调用 getChildren() 方法获取子节点的个数,并设置节点监听,如果节点个数大于等于所有的线程个数,
则表明所有的线程都已经准备就绪,然后开始执行后续逻辑。Barrier 的实现可以参考 ZooKeeper 官方的开发者文档。

实际上这个算法还可以优化,使用 getChildren() 监听节点存在上文提到的羊群效应(Herd Effect)问题,我们可以在创建子节点时,根据子节点个数是否达到所有线程个数,
来单独创建一个节点,譬如 /barrier/enter,表示所有线程都准备就绪,没达到的话就调用 exists() 方法监听 /barrier/enter 节点。
这样只有在 /barrier/enter 节点创建时才需要通知所有线程,而不需要每加入一个节点都通知一次。双栅栏的算法可以采用同样的方法增加一个 /barrier/leave 节点来实现。

队列(Queue)

队列是一种满足 FIFO 规则的数据结构,在分布式应用中,队列经常用于实现生产者和消费者模型。使用 ZooKeeper 实现队列的思路是这样的:首先创建目录节点 /queue
然后生产者线程往该节点下写入 SEQUENTIAL 类型的子节点,比如 node-1node-2 等,由于是顺序节点,ZooKeeper 可以保证创建的子节点是按顺序递增的。
消费者线程则是一直通过 getChildren() 方法读取 /queue 节点的子节点,取序号最小的节点(也就是最先入队的节点)进行消费。这里我们要注意的是,
消费者首先需要调用 delete() 删除该节点,如果有多个线程同时删除该节点,ZooKeeper 的一致性可以保证只会有一个线程删除成功,删除成功的线程才可以消费该节点,
而删除失败的线程通过 getChildren() 的节点监听继续等待队列中新元素。

本文介绍的 ZooKeeper 功能都是基于官方提供的原生 API org.apache.zookeeper 来实现的,但是原生的 API 有一个问题,就是太底层了,不方便使用,而且很容易出错。
因此 Netflix 的 Jordan Zimmerman 开发了 Curator 项目,并在 GitHub 上采用 Apache 2.0 协议开源了。在生产环境推荐直接使用 Curator 而不是原生的 API,
可以大大简化 ZooKeeper 的开发流程,可以参考 Apache Curator Getting Started。

参考

新技术学习笔记:ZooKeeper,包含部署、集群部署、概念等