ZooKeeper

概述

ZooKeeper是一个开源的分布式协调服务框架,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。

ZooKeeper通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、分布式锁和分布式队列等功能。这些功能的实现主要依赖于ZooKeeper提供的数据存储 + 事件监听功能。

ZooKeeper将数据保存在内存中,性能不错。协调服务的典型场景是读多于写,更能凸显ZooKeeper的优势。


重要概念

数据模型 Data model

ZK的数据模型采用层次化的多叉树型结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二级制序列。

每个节点可以拥有N个子节点,最上层是根节点,以/来代表。每个数据节点在ZK中被称为znode,它是ZK中数据的最小单元。并且,每个znode都有一个唯一的路径标识。

ZK主要是用来协调服务的,而不是用来存储业务数据的,所以不要放比较大的数据在znode上,ZK给出的上限是每个节点的数据大小为1M


数据节点 znode

通常将znode分为4大类:

  • 持久节点 PERSISTENT

    一旦创建就一直存在,即使ZK集群宕机也是如此,直到将其删除。

  • 临时节点 EPHEMERAL

    临时节点的生命周期与客户端会话(Session)绑定,Session消失则节点消失。并且,临时节点只能做叶子节点,不能创建子节点。

  • 持久顺序节点 PERSISTENT_SEQUENTIAL

    除了具有持久节点的特性之外,子节点的名称还具有顺序性,比如/node1/app0000001/node/app0000002

  • 临时顺序节点 EPHEMERAL_SEQUENTIAL

    除了具有临时节点的特性之外,子节点的名称还具有顺序性

每个znode由两部分组成:

  1. stat 状态信息
  2. data 节点存放的数据的具体内容

版本 version

stat中记录了znode的三个相关版本:

  1. dataVersion

    当前znode节点的版本号

  2. cversion

    当前znode子节点的版本

  3. aclVersion

    当前znode的ACL版本


权限控制 ACL

ZK采用ACL(AccessControlLists)策略来进行权限控制,类似于UNIX文件系统的权限控制。

ZK提供了5种znode的操作权限

  1. CREATE 能创建子节点
  2. READ 能获取节点数据和列出其子节点
  3. WRITE 能设置/更新节点数据
  4. DELETE 能删除子节点
  5. ADMIN 能设置节点ACL的权限

ZK还提供了4种身份认证的方式:

  1. world 默认,所有用户都可以访问
  2. auth 不使用任何id,表示任何已认证的用户
  3. digest 用户名:密码认证方式
  4. ip 对指定ip进行限制

事件监听器 Watcher

ZK允许用户在指定节点上注册一些Watcher,并且在一些特定事件触发的时候,ZK服务端将事件通知到感兴趣的客户端上。该机制是ZK实现分布式协调服务的重要特性


会话 Session

Session可以看作是ZK服务器与客户端之间的一个TCP长连接,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能向ZK服务器发送请求并接收响应,同时还能够通过该连接接收来自服务器的Watcher事件通知

在为客户端创建会话之前,服务端首先会为每个客户端都分配一个sessionIDsessionID是ZK会话的一个重要标识,许多与会话相关的运行机制都基于这个sessionID,因此,**sessionID的分配务必保证全局唯一**。


ZooKeeper集群

通常3台服务器就可以构成一个ZK集群,每台服务器都会在内存中维护当前的服务器状态,并且服务器之间都保持着通信。集群之间通过ZAB协议(ZooKeeper Atomic Broadcast)来保持数据的一致性。


集群角色

最典型的集群模式:Master/Slave 模式(主备模式)。这种模式中,通常Master服务器作为主服务器提供写服务,其他Slave服务器作为从服务器通过异步复制的方式获取Master服务器最新的数据提供读服务。但是ZK中没有选择传统的Master/Slave概念,而是引入了Leader、Follower、Observer三种角色。

image-20230919104014780

ZK集群中的所有机器通过一个Leader选举过程来选定一台称为Leader的机器。

角色 说明
Leader 为客户端提供读、写服务。负责选举过程的投票发起和决议,更新系统状态
Follower 为客户端提供读服务,如果是写服务则转发给Leader参与选举过程中的投票
Observer 为客户端提供读服务,如果是写服务则转发给Leader不参与选举过程中的投票,也不参与写操作的“过半写成功”策略,因此Observer可以在不影响写性能的情况下提升集群的读性能。ZK 3.3新增的角色。

Leader选举

当Leader服务器出现网络中断、崩溃退出、重启等异常情况时,就会进入Leader选举过程,产生新的Leader服务器。Leader选举过程如下:

  1. Leader election (选举阶段)

    节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,就可以当选准Leader

  2. Discovery (发现阶段)

    follower跟准Leader进行通信,同步follower最近接收的事务提议

  3. Synchronization (同步阶段)

    利用Leader前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成后,准Leader称为真正的Leader

  4. Broadcast (广播阶段)

    ZK正式对外提供事务服务,并且Leader可以进行消息广播。如果有新节点加入,对新节点进行同步。

ZK集群中的服务器状态有4种

  1. LOOKING 寻找Leader
  2. LEADING leader状态
  3. FOLLOWING Follower状态
  4. OBSERVING Observer状态

ZK集群为什么最好奇数台?

ZK集群在宕机了几个ZK服务器后,如果剩下的ZK服务器个数>宕机的服务器个数,ZK才依然可用。奇数台能更高效地保持ZK集群的稳定性(3服务器,允许宕机1台,4服务器也只能允许宕机1台,偶数台对ZK集群的稳定性没有提升)。


选举的过半机制 防止脑裂

对于一个集群,通常会将多台机器部署在不同机房,来提高这个集群的可用性。如果发生一种机房间网络线路故障,导致机房之间的网络不不通,集群被割裂成几个小集群,这时候子集群各自选主,当网络恢复的时候,会有多个主节点,仿佛是大脑分裂,故称脑裂现象。脑裂期间,各个主节点都可能对外提供了服务,会带来数据一致性等问题

ZK的过半机制导致不可能产生2个leader。


ZAB 协议

ZAB (ZooKeeper Atomic Broadcast 原子广播)协议是为分布式协调服务ZK专门设计的一种支持崩溃恢复的原子广播协议。ZK主要依赖ZAB协议来实现分布式数据一致性。基于该协议,ZK实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。

ZAB协议包括两种基本的模式:

  1. 崩溃恢复

    当整个服务框架在启动过程中,或是Leader服务器出现网络中断、崩溃退出、重启等异常情况,ZAB协议就会进入恢复模式并选举产生新的Leader服务器。

    当选举产生了新的Leader服务器,同时集群中已经有过半的机器完成了状态同步(数据同步),ZAB协议就退出恢复模式。

  2. 消息广播

    当集群中已经有过半的Follower服务器完成了和Leader服务器的状态同步,整个服务框架就可以进入消息广播模式。


一致性问题

设计一个分布式系统一定会遇到一个问题:因为分区容忍性(partition tolerance)的存在,就必定要求我们需要在系统可用性(availability)数据一致性(consistency)中做出权衡。


典型应用场景

选主

由于ZK的强一致性,能够很好地保证在高并发的情况下,保证节点创建的全局唯一性(即无法重复创建一样的节点)

  1. 利用这个特性,可以让多个客户端创建一个指定的临时节点,创建成功的就是Master。
  2. 让不是master的节点监听master节点的状态(比如监听这个临时节点的父节点,如果子节点数变了,就表示master挂了,触发回调函数重新进行选举;或者直接监听节点的状态,通过节点是否已经失去连接来爬暖master是否挂了)

分布式锁

互斥锁

  1. 由于创建节点的唯一性,可以让多个客户端同时创建一个临时节点,创建成功的说明获取到了锁
  2. 没有获取到锁的客户端创建一个watcher进行节点状态的监听,如果这个互斥锁被释放了(客户端宕机了,或者客户端主动释放了锁),可以调用回调函数重新获得锁。

同时实现共享锁和互斥锁

  1. 创建有序节点。
  2. 读请求时(需要获取共享锁),如果没有比自己更小的节点,或者比自己小的节点都是读请求,就可以获取到共享锁。如果比自己小的节点中有写请求,则当前客户端无法获取到共享锁,只能等待写请求完成。
  3. 写请求时(需要获取互斥锁),如果没有比自己更小的节点,就可以获取到互斥锁,对数据进行修改。如果发现有比自己更小的节点,无论读写操作,都不能获取到互斥的写锁,等待所有前面的操作完成。

这样就很好地同时实现了共享锁和互斥锁。但是当一个锁得到释放,它会通知所有等待地客户端,从而造成羊群效应,可以让等待的节点只监听他们前面的节点


命名服务

如何给一个对象设置ID,大家可能都会想到UUID,但是UUID太长了。可以使用节点的全路径作为命名方式,由于路径是可以自定义的,对于有些语义的对象的ID设置可以提高可读性。


集群管理和注册中心

  • 集群管理

    可能我们会有这样的需求,需要了解整个集群中有多少机器在工作,我们想对集群中的每台机器的运行时状态进行数据采集,对集群中的机器进行上下线等操作。

    ZK天然支持的watcher和临时节点能很好地实现这些需求。我们可以为每台机器创建临时节点,并监控其父节点,如果子节点列表有变动,可以使用在其父节点绑定的watcher进行状态监控和回调。

  • 注册中心

    服务提供者在ZK中创建一个临时节点,并将自己的ip、port、调用方式写入节点,当服务消费者需要调用的时候,通过注册中心找到相应的服务的地址列表 ,并缓存到本地(方便以后调用),当消费者调用服务时,不再去请求注册中心,直接通过负载均衡算法从地址列表中取一个服务提供者的服务器调用服务。

    当服务提供者的某台服务器宕机或者下线时,相应的地址会从服务提供者地址列表中移除。同时,注册中心会将新的服务地址列表发送给服务消费者的机器并缓存在消费者本机(也可以让消费者进行节点监听)


数据发布/订阅

通过Watcher机制,可以方便地实现数据发布/订阅。将数据发布到ZK被监听的节点上,其他机器可以通过监听节点上的变化来实现配置的动态更新。


Docker安装ZooKeeper

  1. docker pull zookeeper
  2. docker run -d -name=zookeeper -p 2181:2181 zookeeper

连接ZooKeeper服务

  1. docker ps查看ZooKeeper容器的ID
  2. docker exec -it ContainerId bash启动并进入容器
  3. ./bin/zkCli.sh -server 127.0.0.1:2181命令连接至ZooKeeper服务

常用命令

  1. ls 查看节点结构

  2. create 创建节点

    • 在根目录创建了node1节点,与之关联的字符串是”node1”

      create /node1 "node1"

    • 在node1节点下创建node1.1节点,与之关联的内容是数字123

      create /node1/node1.1 123

  3. set 更新节点数据内容

    set /node1 "set node1"

  4. get 获取节点的数据

    get -s /node1

    -s 额外显示节点状态信息

  5. stat 查看节点状态

  6. delete 删除节点

    需要被删除的节点没有子节点

    delete /node1


ZooKeeper的Java客户端Curator

相比于ZooKeeper自带的客户端zookeeper来说,Curator的封装更加完善,各种API都能比较方便地使用。

1
2
3
4
5
6
7
8
9
10
11
<!-- zookeeper的java客户端 -->
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version>
</dependency>

创建客户端

Curator的创建会话方式与原生的API和ZkClient的创建方式区别很大。Curator创建客户端是通过CuratorFrameworkFactory工厂类来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
// 提供重试策略的接口,初始sleep时间1000ms,最大重试次数3次,并在之后的每次重试的等待时间大概率会增加(随机)
// long sleepMs = baseSleepTimeMs * Math.max(1, random.nextInt(1 << (retryCount + 1)));
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
private static CuratorFramework Client = CuratorFrameworkFactory.builder()
.connectString("hadoop1:2181,hadoop2:2181,hadoop3:2181") // zk的server地址,多个server之间使用英文逗号分隔开
.connectionTimeoutMs(5000) // 连接超时时间
.sessionTimeoutMs(3000) // 会话超时时间
.retryPolicy(retryPolicy) // 失败重试策略
.build();

// 阻塞到创建会话成功为止
client.start();

创建节点

创建一个初始内容为空的节点

1
client.create().forPath(path);

Curator默认创建的是持久节点,内容为空。


创建一个包含内容的节点

1
client.create().forPath(path, "我是内容".getBytes());

Curator和ZkClient不同的是依旧采用Zookeeper原生API的风格,内容使用byte[]作为方法参数。


创建临时节点,并递归创建父节点

1
client.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(path);

此处Curator和ZkClient一样封装了递归创建父节点的方法。在递归创建父节点时,父节点为持久节点


删除节点

删除一个子节点

1
client.delete().forPath(path);

删除节点并递归删除其子节点

1
client.delete().deletingChildrenIfNeeded().forPath(path);

指定版本进行删除

1
client.delete().withVersion(1).forPath(path);

如果此版本已经不存在,则删除异常,异常信息如下:org.apache.zookeeper.KeeperException$BadVersionException: KeeperErrorCode = BadVersion for


强制保证删除一个节点

1
client.delete().guaranteed().forPath(path);

只要客户端会话有效,那么Curator会在后台持续进行删除操作,直到节点删除成功。比如遇到一些网络异常的情况,此guaranteed的强制删除就会很有效果。


读取数据

Curator提供了传入一个Stat,使用节点当前的Stat替换到传入的Stat的方法,查询方法执行完成之后,Stat引用已经执行当前最新的节点Stat。

1
2
3
4
5
// 普通查询
client.getData().forPath(path);
// 包含状态查询
Stat stat = new Stat();
client.getData().storingStatIn(stat()).forPath(path);

更新数据

更新数据,如果未传入version参数,那么更新当前最新版本,如果传入version则更新指定version,如果version已经变更,则抛出异常。

1
2
3
4
// 普通更新
client.setData().forPath(path,"新内容".getBytes());
// 指定版本更新
client.setData().withVersion(1).forPath(path);

版本不一直异常信息:org.apache.zookeeper.KeeperException$BadVersionException: KeeperErrorCode = BadVersion for


Zookeeper框架Curator使用 - 扎心了,老铁 - 博客园 (cnblogs.com)