Zookeeper 学习记录

173 阅读17分钟

Zookeeper 学习记录

Zookeeper 通常作为注册中心,负责管理服务调用、进行负载均衡

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

特点

image-20231220162011136.png

  1. 集群

    Zookeeper 服务器是由一个领导者(Leader)+ 多个跟随着(Follower)组成的集群

    如果每个服务器都是老大的话,那不同的服务器上有不同的 Client 过来更新,可能会导致同一数据的一致性问题,听谁的也是问题,因此 Zookeeper 中只有一个 Leader ,所有写操作都交给这个 Leader ,那剩下的那些服务器干啥?剩下的 Follower 可以负责客户端读数据的需求,即在 Leader 处写,在 Follower 处读

  2. 高可用性

    集群中只要有半数以上的节点存活, Zookeeper 集群就能正常服务,因此 Zookeeper 适合安装奇数台服务器

    • 为什么奇数台呢?

      因为如果我现有5台服务器组成集群,当有2台服务器宕机了,再坏一台集群就无法正常工作,但是如果我增加一台用6台服务器组成集群,面临的情况和上面的一模一样,因此没有区别,新加的那一台没有意义,为了节省服务器资源,通常安装奇数台服务器

  3. 全局数据一致

    每个 Server 保存一份相同的数据副本,Client 无论连接到哪个Server,数据都是一致的

  4. 更新请求顺序执行

    来自同一个 Client 的更新请求按发送顺序依次执行

  5. 数据更新原子性

    一次数据更新要么成功,要么失败

  6. 实时性

    在一定时间范围内, Client 能读到最新数据

  7. 从设计模式角度来看,zk 是一个基于观察者设计模式的框架,它负责管理跟存储大家都关心的数据,然后接受观察者的注册,数据反生变化 zk 会通知在 zk 上注册的观察者做出反应。

  8. Zookeeper是一个分布式协调系统,满足CP性,跟 SpringCloud 中的Eureka满足AP不一样。

数据结构

Zookeeper 数据结构与 Linux 的文件系统很类似,整体上也是一棵树,每个节点都是一个ZNode,每个ZNode默认存储 1MB 的数据,每个ZNode都可以通过其路径唯一标识出来。如下图所示:

image-20231220164215915.png

由于每个 ZNode 可以存储的数据很少(1MB),因此 Zookeeper 能够存储的数据量很少,不可能用于存储海量的信息,只能存放一些配置信息来实现分布式应用程序协同服务

  • 为什么 ZooKeeper 不适合大数据量存储呢?
  1. 设计方面:ZooKeeper 需要把所有的数据(它的 data tree)加载到内存中。这就决定了ZooKeeper 存储的数据量受内存的限制。这一点 ZooKeeper 和 Redis 比较像。一般的数据库系统例如 MySQL(使用 InnoDB 存储引擎的话)可以存储大于内存的数据,这是因为 InnoDB 是基于 B-Tree 的存储引擎。B-tree 存储引擎和 LSM 存储引擎都可以存储大于内存的数据量。
  2. 工程方面:ZooKeeper 的设计目标是为协同服务提供数据存储,数据的高可用性和性能是最重要的系统指标,处理大数量不是 ZooKeeper 的首要目标。因此,ZooKeeper 不会对大数量存储做太多工程上的优化。

应用场景

应用场景共有:统一命名服务、统一配置管理、统一集群管理、服务器节点动态上下线、软负载均衡等,下面进行详细介绍

  1. 统一命名服务

    在分布式环境下,经常需要对应用/服务进行统一命名,便于识别,如域名容易记住,但是 IP 不好记,客户端访问域名而不会直接访问某个服务器的 IP ,比如在百度的域名下,可能有几万台服务器有很多的 IP ,但是用户访问百度的域名的时候,Zookeeper 会自动分配该域名下的某个 IP 进行服务。该域名下某些服务器暴毙了也没关系,不会影响用户的访问

  1. 统一配置管理

    在分布式环境下,需要所有节点的配置信息是一致的,当配置文件发生修改之后,希望能够快速同步到各个节点上

    而配置管理的工作就可以交给 Zookeeper 来实现:

    • 可以将配置信息写入到 Zookeeper 上的一个 ZNode
    • 各个客户端会监听这个 ZNode
    • 一旦 ZNode 中的数据被修改, Zookeeper 将会通知各个客户端

image-20231220172212370.png

  1. 统一集群管理

    分布式环境中,实时掌握每个节点的状态是必要的,我们可以根据节点实时状态做出一些调整

    Zookeeper 可以实现实时监控节点状态变化,将节点信息写入一个 ZNode ,监听这个 ZNode 可以获取它的实时状态变化

    如下图所示,client1在 ZK 中进行注册之后,同时它也可以监听 client2 和 client3,client2 和 client3也是这样,注册并且相互监控

image-20231220173249140.png

  1. 服务器节点动态上下线

    服务器会在 Zookeeper 集群中注册,客户端可以获取到当前在线的服务器列表,并且注册监听,选择调用某个服务器来实现服务,当某个服务器宕机了,ZK 集群会把该服务器从服务器列表中删除并且通知给客户端,这个服务器已经不可用了

image-20231220173525242.png

  1. 软负载均衡

    客户端访问一个域名,域名下有多个服务器,软负载均衡可以均匀地分配每个服务器的访问负载,充分利用服务器资源

Linux 系统安装

  1. 将压缩包解压到 /usr/local/apache-zookeeper-3.5.7-bin 下
        tar -zxvf apache-zookeeper-3.5.7-bin.tar.gz
    
  2. 把 conf 目录下的 zoo_sample.cfg 重命名为 zoo.cfg
        [root@feiqiu conf]# mv ./zoo_sample.cfg zoo.cfg
    
  3. 修改配置 zoo.cfg
        # 心跳检查的时间 2秒
        tickTime=2000
        # 初始化时 连接到服务器端的间隔次数,总时间10*2=20秒
        initLimit=10
        # ZK Leader 和follower 之间通讯的次数,总时间5*2=10秒
        syncLimit=5
        # 存储内存中数据快照的位置,如果不设置参数,更新事务日志将被存储到默认位置 /temp,会定期删除。
        dataDir=/usr/local/apache-zookeeper-3.5.7-bin/zkData
        # ZK 服务器端的监听端口  
        clientPort=2181
    
  4. 进入 ./bin 目录使用 zkServer.sh 开启服务器,zkCli.sh 开启客户端
        [root@feiqiu bin]# ./zkServer.sh start
        ZooKeeper JMX enabled by default
        Using config: /usr/local/apache-zookeeper-3.5.7-bin/bin/../conf/zoo.cfg
        Starting zookeeper ... STARTED
    
    
        [root@feiqiu bin]# ./zkCli.sh
        // ...
        WATCHER::
    
        WatchedEvent state:SyncConnected type:None path:null
        [zk: localhost:2181(CONNECTED) 0]
    
  5. 退出 ZK 客户端:quit

选举机制

当 ZK 集群中的一台服务器出现以下两种情况之一时,就会进入 Leader 选举:

  1. 服务器初始化启动
  2. 服务器运行期间无法和 Leader 保持连接

第一次选举

简单理解:超过一半机器启动,则zookeeper启动成功,且启动成功后myid最大的那个机器成为leader,后续再启动新机器leader不变

注:myid 为每个服务器注册进入集群时的身份证,作为每个服务器的唯一标识

详细过程:

假设该集群中总共有 5 台服务器,在配置 zoo.cfg 的时候已经设置了每个服务器的 myid、名称,并且服务器数量已知,如下图所示

image-20231220204703167.png

该过程中默认共 5 台服务器,且服务器开启顺序按照 myid 递增,因此超过半数的票数为:5/2向下取整 + 1 = 3

(1)服务器1启动,发起一次选举,服务器1是自私的,首先先给自己投了一票,由于要超过半数以上的票数才能当选为 Leader(5/2向下取整 + 1 = 3),选举无法完成,服务器1进入 Looking 状态

image-20231220204818744.png

(2)服务器2启动,再发起一次选举,1和2都先投给自己一票,然后两台服务器会互相交换自己的 myid ,来比一比大小,服务器1发现服务器2 的 myid 比自己的大,就把自己的票投给服务器2,这时服务器2就有了两张选票,但还是小于3,因此两个服务器都 Looking

image-20231220205421785.png

(3)服务器3启动,再发起一次选举,一比大小发现服务器3更是重量级,就都会把自己的选票投给服务器3,此时服务器3的票数已经超过半数,成功当选为 Leader,服务器1和2变为 Following

image-20231220205639004.png

(4)服务器4、5启动,发现团队里面已经有大哥了,自己也就不能当大哥了,进入 Following 状态

相关概念:

  • SID:服务器 ID,用于标识 ZK 集群中的服务器,与 myid 一致
  • ZXID:事务 ID,用来标识一次服务器状态的变更
  • Epoch:每个Leader任期的代号(年号)

ZooKeeper 采用全局递增的事务 id 来标识,所有 proposal(提议)在被提出的时候加上了 ZooKeeper Transaction Id 。

ZXID是64位的Long类型,这是保证事务的顺序一致性的关键。

ZXID中高32位表示纪元epoch,低32位表示事务标识xid。你可以认为 ZXID 越大说明存储数据越新,如下图所示:

image-20231220210758626.png

每个leader都会具有不同的epoch值,表示一个纪元/朝代,用来标识 leader周期。每个新的选举开启时都会生成一个新的epoch,从1开始,每次选出新的Leader,epoch递增1,并会将该值更新到所有的 zkServer 的 zxid 的 epoch

xid 是一个依次递增的事务编号,数值越大说明数据越新,可以简单理解为递增的事务id。每次epoch变化,都将低32位的序号重置,这样保证了zxid的全局递增性。(每到一个新的朝代,记事务就要从头开始啦,如万历三年...)

非第一次选举

当一台机器进入 Leader 选举流程后,当前集群会面临下面两种情况:

  1. 集群中已经存在一个 Leader 了

    这种情况下,当新来的机器试图去参与 Leader 选举时,会被告知当前的 Leader 信息,新来的机器只需要和 Leader 建立连接,并进行状态同步即可(已经有一个皇上的话,可不能自己篡位了)

  2. 集群中确实没有 Leader

    假设 ZK 集群由 5 台服务器组成,SID 为 1、2、3、4、5,XID 为 8、8、8、7、7,且SID=3 的是 Leader,

    若这种情况下 3 和 5 服务器故障,就会开始 Leader 选举(临危受命)

    SID 为 1、2、4 的机器的投票情况是:

EPOCHXIDSID
181
182
174

选举 Leader 规则:

  • EPOCH 大的直接胜出
  • EPOCH 相同,事务 ID(XID)大的胜出
  • 事务 ID(XID)相同,服务器 ID(SID)大的胜出

节点类型

节点类型可以分为持久、临时,也可以分为顺序编号、非顺序编号两种

持久:客户端和服务器端断开连接后,创建的节点不删除

临时:客户端和服务器端断开连接后,创建的节点自己删除

因此 ZK 中共有下面这四种类型的节点:

  1. 持久化目录节点 PERSISTENT:客户端与zookeeper断开连接后,该节点依旧存在。

  2. 持久化顺序编号目录节点 PERSISTENT_SEQUENTIAL:客户端与zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号。

  3. 临时目录节点 EPHEMERAL:客户端与zookeeper断开连接后,该节点被删除。

  4. 临时顺序编号目录节点 EPHEMERAL_SEQUENTIAL:客户端与zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号。

说明:在创建 ZNode 时会设置顺序标识,会在 znode 名称后面附加一个值,如 znode2_001,001就是顺序号,顺序号是一个单调递增的计数器,由父节点维护。在分布式系统中,顺序号可被用于给所有的事件进行全局排序,可以通过顺序号来判断事件的顺序

在使用过程中,带序号的节点可以重复的创建,多个带序号的节点之间使用序号来区分,不带序号的节点不能重复创建,只能创建一次

节点创建:

  • 创建持久化节点
        create /sanguo/shuguo "liubei"
    
  • 创建持久化顺序编号节点
        create -s /sanguo/shuguo "liubei"
    ```java
    
  • 创建临时节点
        create -e /sanguo/shuguo "liubei"
    
  • 创建临时顺序编号节点
        create -e -s /sanguo/shuguo "liubei"
    

监听器通知原理

我们基于 Zookeeper上创建的节点,可以对这些节点绑定监听事件,比如可以监听节点数据变更、节点删除、子节点状态变更等事件,通过这个事件机制,可以基于 Zookeeper 实现分布式锁、集群管理等多种功能

它有点类似于订阅的方式,即客户端向服务端 注册 指定的 watcher ,当服务端符合了 watcher 的某些事件或要求则会向客户端发送事件通知 ,客户端收到通知后找到自己定义的 Watcher 然后 执行相应的回调方法 。详细过程如下图所示:

image-20231221142117665.png

监听器详细实现过程:

  1. 首先有一个 Main 线程
  2. 在 Main 线程中创建 Zookeeper 客户端 zkClient,这是就会创建两个线程,一个负责网络连接通信(connect),另一个负责监听(listener)
  3. 通过 connect 线程将注册的监听事件发送给 Zookeeper
  4. 在 Zookeeper 的注册监听器列表中将注册的监听事件添加到列表中
  5. Zookeeper 监听到有数据或者路径变化时,就会将这个消息发送给 listener 线程(具体响应什么变化要看注册了哪种监听事件)
  6. listener 线程内部调用 proccess 方法

需要注意的是,每次注册监听之后,Zookeeper 只会在第一次发生变化时发送消息,如果有多次请求也只会发送一次信息(注册一次就响应一次),要想实现永久监听,可以使用循环监听

常见的监听包括:监听节点数据的变化 和 监听子节点增减的变化(路径变化)

Java API

Curator

Curator 是 Apache Zookeeper 的 Java 客户端库,简化封装了 ZK 的 JAVA API 调用

需要的依赖:

        <!-- curator -->
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-framework</artifactId>
            <version>4.0.0</version>
        </dependency>

        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>4.0.0</version>
        </dependency>

建立连接

  • 第一种方法
        /**
         * String connectString,    连接字符串,zk server 地址和端口 "192.168.200.130:2181"
         * int sessionTimeoutMs,    会话超时时间,单位ms
         * int connectionTimeoutMs, 连接超时时间,单位ms
         * RetryPolicy retryPolicy, 重试策略
         */
        // 第一种方法
        // 重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(3000, 10);
        CuratorFramework client1 = CuratorFrameworkFactory.newClient("192.168.200.130:2181",
                60 * 1000, 15 * 1000, retryPolicy);
        // 开启连接
        client1.start();
  • 第二种方法

    使用链式构造,记得最后要 .build() 才能返回创建好的客户端连接

        // 第二种方法
        CuratorFramework client2 = CuratorFrameworkFactory.builder()
            .connectString("192.168.200.130:2181")
            .sessionTimeoutMs(60 * 1000)
            .connectionTimeoutMs(15 * 1000)
            .retryPolicy(retryPolicy).build();
        client2.start();

创建节点

  • 基本创建
        client.create().forPath("/app1");
    
  • 传入数据
        client.create().forPath("/app2","...".getBytes());
    
  • 设置节点类型:持久、临时等
        client.create().withMode(CreateMode.EPHEMERAL).forPath("/app3");
    
  • 创建多级节点:如果父节点不存在,就创建父节点
        client.create().creatingParentsIfNeeded().forPath("/app1");
    

查询节点

  • 查询数据 get
        byte[] data = client.getData().forPath("/app1");
    
  • 查询子节点 ls
        List<String> ch = client.getChildren().forPath("/app1");
    
  • 查询子节点状态 ls -s
        Stat staus = new Stat();
        client.getData().storingStatIn(status).forPath("/app1");		// 查询到的状态会存进 Stat 对象中
    

修改节点

  • 修改节点数据
        client.setData().forPath("/app2","...".getBytes());
    
  • 根据版本号修改数据
        Stat staus = new Stat();
        client.getData().storingStatIn(status).forPath("/app1");		// 查询到的状态会存进 Stat 对象中
    
        int version = status.getVersion();
        client.setData().withVersion(version).forPath("/app2","...".getBytes());
    

删除节点

  • 删除单个节点

    client.delete().fotPath("/app1");
    
  • 删除带有子节点的节点

        client.delete().deletingChildrenIfNeeded().fotPath("/app1");
    
  • 必须成功删除:消除网络影响

        client.delete().guaranteed().fotPath("/app1");
    
  • 回调

    client.delete().guaranteed().inBackground(new BackgroundCallback() {
                        public void processResult(CuratorFramework curatorFramework, CuratorEvent curatorEvent) throws Exception {
                            System.out.println("被删除啦~");
                        }
                    },executorService).forPath("/app1");
    

分布式锁

在我们进行单机应用开发涉及到并发同步问题的时候,我们往往采用 synchronized 或者 LOCK 的方式解决,但是这时多线程运行是在同一个 JVM 下,没有任何问题

但是当我们的应用是分布式集群工作下,属于多 JVM 工作环境,这种跨 JVM 环境需要一种比多线程锁更高级的锁机制,来处理跨机器的进程之间的数据同步问题 —— 这就是分布式锁

  • 同一JVM多线程环境下:
  • 跨 JVM 环境下:需要一个分布式锁组件,当客户端访问 A1 服务中的临界区去修改数据时,从分布式锁组件中拿走一个锁,这时如果有人要调用 A2 服务也想改数据的话,也要去获取分布式锁组件中的锁,会发现锁已经没有了,从而实现跨 JVM 环境下的同步。当 A1 服务处理完之后,会将锁归还,让其他要处理数据的服务去操作。

分布式锁实现方式:

  1. 基于缓存实现分布式锁
    • Redis
    • Memcache
  2. zookeeper 实现分布式锁
    • Curator
  3. 数据库层面实现分布式锁
    • 悲观锁
    • 乐观锁

分布式锁实现原理:

核心思想:当客户端要获取锁,则创建节点,使用完锁,则删除该节点

image-20231221154004034.png

  1. 客户端获取锁时,在 lock 节点下创建 临时顺序 节点
  2. 然后获取 lock 下面的所有子节点,client 若发现自己创建的子节点序号是最小的,那么就认为该客户端获取到了锁。使用完锁之后,断开连接,该最小序号子节点自动删除
  3. 如果发现自己创建的节点并非 lock 下所有子节中最小的,说明自己还没有获取到锁,此时 client 需要找到比自己小的那个节点(只小一个!相当于看自己左边的那个节点),并且对其注册事件监听器,监听该节点的删除事件,注意只看比自己小一号的那个节点!
  4. 如果发现比自己小一号的那个节点被删除,则客户端的 Watcher 会收到相应通知,也会再次判断自己创建的节点是否是 lock 下子节点中序号最小的,如果是则获取到了锁,如果不是则重复以上步骤,找到比自己小一号的节点并且注册监听。
  • 为什么要是临时节点?

    因为客户端用完锁之后,要将锁释放,即把 lock 下创建的那个子节点删除,但是如果是持久化节点的话,若客户端突然宕机,那个子节点(锁)就永远不会删除,锁永远不会释放

  • 为什么要是顺序节点?

    因为分布式锁实现时需要根据创建的子节点的序号来判断哪个客户端获取到了锁,序号越小说明请求来的越早,同时应该更早拿到锁

Curator 中共实现了下面5种分布式锁:

在目标类中声明成员变量:

private InterProcessMutex lock;

获取锁:

lock.acquire(time,TimeUnit)			
// time 为该客户端等待锁多长时间,TimeUnit 为时间单位

释放锁:

lock.release();

建议放在 finally 块中