Zookeeper 快速入门

Curator | API Docs

初始 Zookeeper

Zookeeper 概念

  • Zookeeper 是 Apache Haddop 项目下的一个子项目,是一个树形目录服务
  • Zookeeper 翻译过来就是 动物园管理员,他是用来管 Hadoop(大象)、Hive(蜜蜂)、Pig(小猪)的管理员。简称 zk
  • Zookeeper 是一个分布式的、开源的分布式应用程序的协调服务。
  • Zookeeper 提供的主要功能包括:
    • 配置管理
    • 分布式锁
    • 集群管理 --> 就是注册中心

Zookeeper 安装

官网上下载一下 zookeeper,这里我用的是 3.8.6 版本 下载,选择 -bin.tar.gz,然后解压到本地,这里我放到了 /Users/ice/Desktop/cola/environment/zookeeper 目录下,同目录下创建一个空文件夹 zkdata,用来存放数据

然后进入 conf 目录下,把 zoo_sample.cfg 文件复制一份,重命名为 zoo.cfg。然后改动 zoo.cfg 中的 dataDir 属性

properties 复制代码
dataDir=/Users/ice/Desktop/cola/environment/zookeeper/zkdata

改动完就可以了,之后启动

sh 复制代码
# 进入 bin 目录
cd /Users/ice/Desktop/cola/environment/zookeeper/apache-zookeeper-3.8.6-bin/bin

# 启动
./zkServer.sh start

# 查看状态
./zkServer.sh status

# 关闭
./zkServer.sh stop

或者更简单的方式,使用 Docker

sh 复制代码
docker run -d \
  --name zk386 \
  -p 2181:2181 \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkData:/data \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkDatalog:/datalog \
  zookeeper:3.8.6

命令操作

数据模型

  • ZooKeeper 是一个树形目录服务,其数据模型和 Unix 的文件系统目录树很类似,拥有一个层次化结构。
  • 这里面的每一个节点都被称为:ZNode,每个节点上都会保存自己的数据和节点信息。(Unix 的文件夹可存不了数据)
  • 节点可以拥有子节点,同时也允许少量(1MB)数据存储在该节点之下。
  • 节点可以分为四大类
    • PERSISTENT 持久化节点
    • EPHEMERAL 临时节点:-e (客户端连接一关闭数据就没有了)
    • PERSISTENT_SEQUENTIAL 持久化顺序节点:-s
    • EPHEMERAL_SEQUENTIAL 临时顺序节点:-es

服务端常用命令

sh 复制代码
# 启动服务
./zkServer.sh start

# 停止服务
./zkServer.sh stop

# 查看服务状态
./zkServer.sh status

# 重启服务
./zkServer.sh restart

客户端常用命令

先让客户的连接上 Server

sh 复制代码
# 进入目录
Mac-mini ~ % docker exec -it zk386 bash
root@35e53d23c323:/apache-zookeeper-3.8.6-bin# cd bin

# 启动客户端
root@35e53d23c323:/apache-zookeeper-3.8.6-bin/bin# ./zkCli.sh -server 127.0.0.1:2181

这就连接上了

查看节点

sh 复制代码
ls /

可以查看根目录下什么节点

text 复制代码
[dubbo, services, zookeeper]

有 dubbo 是因为我们之前用 zookeeper 作为 dubbo 的注册中心了。通过 ls 命令可以一级一级的查看

sh 复制代码
[zk: 127.0.0.1:2181(CONNECTED) 0] ls /
[dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 1] ls /dubbo
[config, mapping, metadata, org.apache.dubbo.metadata.MetadataService, org.apache.dubbo.mock.api.MockService, org.apache.dubbo.samples.quickstart.dubbo.api.DemoService]
[zk: 127.0.0.1:2181(CONNECTED) 2] ls /dubbo/config
[DUBBO_SERVICEDISCOVERY_MIGRATION, configurators, consumers, dubbo, providers, routers]

创建节点 create path [数据],数据可以不写,就是空,不能重复创建已经存在的节点,创建的节点必须父节点存在,不能创建多级节点,比如创建 /app1/p1,那么 /app1 必须存在。

修改节点 set path 数据

获取节点 get path

删除节点 del path,如果节点有子节点就不可以删除,可以使用命令 deleteall path 来进行删除

sh 复制代码
[zk: 127.0.0.1:2181(CONNECTED) 3] create /app1 ergou
Created /app1
[zk: 127.0.0.1:2181(CONNECTED) 4] ls /
[app1, dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 5] create /app2
Created /app2
[zk: 127.0.0.1:2181(CONNECTED) 6] ls /
[app1, app2, dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 7] get /app1
ergou
[zk: 127.0.0.1:2181(CONNECTED) 8] get /app2
null
[zk: 127.0.0.1:2181(CONNECTED) 9] set /app2 shagou
[zk: 127.0.0.1:2181(CONNECTED) 10] get /app2
shagou
[zk: 127.0.0.1:2181(CONNECTED) 11] delete /app1
[zk: 127.0.0.1:2181(CONNECTED) 12] ls /
[app2, dubbo, services, zookeeper]

可以通过 help 查看有哪些命令

  • quit 断开连接

下面演示创建时候的 -e-s-es 参数

sh 复制代码
[zk: 127.0.0.1:2181(CONNECTED) 15] ls /
[app2, dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 17] create -e /app1
Created /app1
[zk: 127.0.0.1:2181(CONNECTED) 18] ls /
[app1, app2, dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 19] quit

root@35e53d23c323:/apache-zookeeper-3.8.6-bin/bin# ./zkCli.sh -server 127.0.0.1:2181
[zk: 127.0.0.1:2181(CONNECTED) 0] ls /
[app2, dubbo, services, zookeeper]

/app1 是临时节点,/app2 是原来 create /app2 这样创建的,默认是持久化的,然后,断开并重新连接,/app2 还在,/app1 没有了

然后演示 -s (-es 含义类似,就不演示了)

sh 复制代码
[zk: 127.0.0.1:2181(CONNECTED) 0] ls /
[app2, dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 1] create -s /app1
Created /app10000000006
[zk: 127.0.0.1:2181(CONNECTED) 2] ls /
[app10000000006, app2, dubbo, services, zookeeper]
[zk: 127.0.0.1:2181(CONNECTED) 3] create -s /app1
Created /app10000000007
[zk: 127.0.0.1:2181(CONNECTED) 4] create -s /app3
Created /app30000000008
[zk: 127.0.0.1:2181(CONNECTED) 5] ls /
[app10000000006, app10000000007, app2, app30000000008, dubbo, services, zookeeper]
  • 之前说过,已经存在的节点不能再 create 了,但是加上 -s 就可以,这里创建了两次 /app1 但是都成功了,分别是 app10000000006app10000000007,注意 app1 是名字,后面 00000000060000000007 是编号,是递增的。
  • 编号是共享的,我创建 /app3,它的序号是 0000000008

然后我们再讲 stat path (或者 ls -s /,效果一样)

sh 复制代码
[zk: 127.0.0.1:2181(CONNECTED) 6] stat /
cZxid = 0x0
ctime = Thu Jan 01 00:00:00 UTC 1970
mZxid = 0x0
mtime = Thu Jan 01 00:00:00 UTC 1970
pZxid = 0x169
cversion = 11
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0 # 是否为临时,0 为非临时,1 为临时
dataLength = 0
numChildren = 7
  • czxid:节点被创建的事务 ID
  • ctime:创建时间
  • mzxid:最后一次被更新的事务 ID
  • mtime:修改时间
  • pzxid:子节点列表最后一次被更新的事务 ID
  • cversion:子节点的版本号
  • dataversion:数据版本号
  • aclversion:权限版本号
  • ephemeralOwner:用于临时节点,代表临时节点的事务 ID,如果为持久节点则为 0
  • dataLength:节点存储的数据的长度
  • numChildren:当前节点的子节点个数

JavaAPI 操作

Curator 介绍

Curator 是 Apache ZooKeeper 的 Java 客户端库,中文含义是馆长(动物园馆长)

  • 常见的 ZooKeeper Java API
    • 原声 JavaAPI
    • ZkClient
    • Curator
  • Curator 项目的目标是简化 ZooKeeper 客户端的使用。
  • Curator 最初是 Netflix 研发的,后来捐献了 Apache 基金会,目前是 Apache 的顶级项目。

建立连接

引入依赖,目前最新版本是 5.9.0

xml 复制代码
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.9.0</version>
</dependency>
<!-- 如果要用分布式锁、Leader选举等,再加这个 -->
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.9.0</version>
</dependency>

然后我们演示创建连接的代码

java 复制代码
@Test
public void testConnect() {
    // 重试策略,有好多种,可以看看实现类有啥自己选择
    RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
    CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181",
            60 * 1000,
            15 * 1000,
            retryPolicy
    );
    client.start();
}

其中四个参数

  • connectString 连接字符串,zkServer 地址和端口,如果有多个,也就是集群,用逗号隔开(注意是在一个字符串里面用逗号隔开)
  • 会话超时时间,单位 ms,默认 1 分钟,ZooKeeper 服务端多久没有收到客户端心跳,就认为这个客户端的会话失效(我们就是客户端,我们在连接 Zookeeper)
  • 连接超时时间,单位 ms,连接的时候,多久连接不上算超时
  • 重试策略,当连接失败、请求失败、网络抖动时,Curator 怎么重试。

当然更推荐下面这种构建方法

java 复制代码
CuratorFramework client = CuratorFrameworkFactory.builder()
        .connectString("127.0.0.1:2181")
        .sessionTimeoutMs(60 * 1000)
        .connectionTimeoutMs(15 * 1000)
        .retryPolicy(retryPolicy)
        .namespace("lh")
        .build();
client.start();

namespace("lh") 命名空间,作用是在每次创建的时候都在前面加个前缀 /lh,因为应用有很多吗,你像前面 Dubbo 的应用就有 /Dubbo,加上这个之后,如果我们执行 create /app,那么实际上就是 create /lh/app,不用我们每次手动指定公共前缀了。

创建节点

java 复制代码
@SpringBootTest
class QuickstartApplicationTests {

    private CuratorFramework client;

    @BeforeEach
    public void testConnect() {
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        client = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181")
                .sessionTimeoutMs(60 * 1000)
                .connectionTimeoutMs(15 * 1000)
                .retryPolicy(retryPolicy)
                .namespace("lh")
                .build();
        client.start();
    }

    @AfterEach
    public void testClose() {
        if (client != null) {
            client.close();
        }
    }

    @Test
    public void testCreateNode() throws Exception {
        String path = client.create()
                .forPath("/app1");
        System.out.println(path);
    }
}

执行前先看一下 Zookeeper 的节点数据

然后跑一下代码,插入节点之后再看

可以看到我们创建 /app1 的时候自动帮我们加入了 /lh,里面是没有数据的

含值

java 复制代码
@Test
public void testCreateNode2() throws Exception {
    // 含值
    String path = client.create()
            .forPath("/app2", "hello zookeeper".getBytes());
    System.out.println(path);
}

这次插入带数据,注意必须是 Byte[] 类型

设置节点类型

java 复制代码
@Test
public void testCreateNode3() throws Exception {
    // 设置节点类型
    String path = client.create()
            .withMode(CreateMode.EPHEMERAL) // 临时的
            .forPath("/app3", "hello zookeeper".getBytes());
    System.out.println(path);
}

没有 /app3,因为我们的 @BeforeEach@AfterEach 会分别在方法执行前后调用,执行完后会关闭连接,所以就查不到了。

{% note info %}

我们是在用 Zookeeper Client 在查看 Zookeeper 数据,它一直建立着连接,我们通过代码的方式又是另外一个会话连接,我们设置的方法执行前建立连接,执行完释放连接。
{% endnote %}

创建多级节点

java 复制代码
@Test
public void testCreateNode4() throws Exception {
    
    String path = client.create()
            .forPath("/app4/p1");
    System.out.println(path);
}

执行会报错 org.apache.zookeeper.KeeperException$NoNodeException: KeeperErrorCode = NoNode for /lh/app4/p1,因为前面说过不能创建多级节点,父节点必须存在才能创建子节点。解决方式如下

java 复制代码
@Test
public void testCreateNode4() throws Exception {
    // 创建多级节点
    String path = client.create()
            .creatingParentContainersIfNeeded() // 加上这个
            .forPath("/app4/p1");
    System.out.println(path);
}

查询节点

查询数据

java 复制代码
@Test
public void testGet1() throws Exception {
    // 查询数据: get
    byte[] data = client.getData().forPath("/app1");
    System.out.println(new String(data)); // 127.0.0.1
}

其实如果不放值,默认存储的是本机 IP,注意是 Curator 客户端帮我们存的,Zookeeper Client 可不会帮我们放。

{% note info %}

如果明确想放一个空节点,可以用下面的方式

java 复制代码
client.create()
      .forPath("/app1", new byte[0]);

{% endnote %}

查询子节点

java 复制代码
@Test
public void testGet2() throws Exception {
    // 查询子节点: ls
    List<String> path = client.getChildren().forPath("/app4");
    System.out.println(path); // [p1]
}

注意如果我们这里写 forPath("/") 实际查询的是 /lh/

查询节点状态信息

java 复制代码
@Test
public void testGet3() throws Exception {
    // 查询节点状态信息: stat
    Stat status = new Stat();
    byte[] data = client.getData().storingStatIn(status).forPath("/app1");
    System.out.println(new String(data)); // 127.0.0.1
    System.out.println(status); // 583,583,1782897140752,1782897140752,0,0,0,0,9,0,583
}
  • 创建一个空的 Stat 对象传参进去
  • 在执行查询数据的时候也会把节点状态信息查询出来放入 Stat 对象里面

所以查询节点状态信息的同时,把节点数据也查出来了。Stat 对象里面就存储了这些信息,通过 get 方法获取即可

修改节点

修改节点值

java 复制代码
@Test
public void testSet() throws Exception {
    client.setData().forPath("/app1", "ergou".getBytes());
}

根据版本修改节点值

java 复制代码
@Test
public void testSetForVersion() throws Exception {
    Stat status = new Stat();
    client.getData().storingStatIn(status).forPath("/app1");
    int version = status.getVersion();
    client.setData()
            .withVersion(version)
            .forPath("/app1", "ergou".getBytes());
}

还有根据版本修改,加上 withVersion,防止客户端 A 查询后要修改,但是修改前客户端 B 修改了,导致客户端 A 查询和修改时数据不一致。通过这种方式可以保证操作的原子性(查和改时版本一致,如果不一致则修改失败,直接报错),依据的就是前面说的 dataversion 数据版本号。

删除节点

原始数据如上,下面演示删除节点

删除单个节点

java 复制代码
@Test
public void testDelete() throws Exception {
    client.delete().forPath("/app1");
}

删除带有子节点的节点

单独删除 /app4 是不会成功的,因为有子节点,会报错 org.apache.zookeeper.KeeperException$NotEmptyException: KeeperErrorCode = Directory not empty for /lh/app4

java 复制代码
@Test
public void testDeleteAll() throws Exception {
    client.delete()
            .deletingChildrenIfNeeded()
            .forPath("/app4");
}

一定能删除成功

java 复制代码
@Test
public void testDeleteAll() throws Exception {
    client.delete()
            .guaranteed()
            .forPath("/app2");
}

解决服务器端操作成功但连接失败导致无法成功向客户端返回响应的极端情况。就是如果不成功就一直重试直到成功。

{% note info %}

一般都会加上,防止网络抖动

{% endnote %}

回调函数

java 复制代码
@Test
public void testDelete4() throws Exception {
    client.delete()
            .guaranteed()
            .inBackground((client, event) -> {
                System.out.println("删除操作回调了");
                System.out.println(event);
            })
            .forPath("/app2");
}

这是在后台执行完删除操作之后,进行回调,在 event 中可以查看操作信息。

比如 resultCode=0 代表删除成功,children 含义是有无子节点

我这里执行完发现 /app2 没了,这个时候 /lh 下就是空的,之后 /lh 也没了!!!其实是如果子节点是空的,过一段时间这个节点也会被删除(我们自己 create /lh 创建的不会空)

{% note info %}

namespace("lh")/lh 本身被创建成了 Container 父节点,空了以后被 ZooKeeper 自动清理了。它不是持久化的

{% endnote %}

Watch 事件监听

  • ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。
  • ZooKeeper 中引入了 Watcher 机制来实现了发布/订阅功能,能够让多个订阅者同时监听某一个对象,当一个对象自身状态变化时,会通知所有订阅者。
  • ZooKeeper 原生支持通过注册 Watcher 来进行事件监听,但是其使用并不是特别方便,需要开发人员自己反复注册 Watcher,比较繁琐。
  • Curator 引入了 Cache 来实现对 ZooKeeper 服务端事件的监听。(还是 Zookeeper 支持这个,Curator 是用代码又封装了一下)
  • ZooKeeper 提供了三种 Watcher
    • NodeCache:只是监听某一个特定的节点
    • PathChildrenCache:监控一个 ZNode 的子节点。
    • TreeCache:可以监控整个树上的所有节点,类似于 PathChildrenCache 和 NodeCache 的组合(当前节点和其下的所有子节点,不是整棵树节点)

{% note warning %}

ZooKeeper 原生支持通过 Watcher 进行事件监听。普通 Watcher 是一次性触发的,触发后如果还要继续监听,需要重新注册。

从 ZooKeeper 3.6.0 开始,ZooKeeper 支持持久 Watcher 和递归持久 Watcher

  • PERSISTENT:监听指定节点,触发后不会自动移除;
  • PERSISTENT_RECURSIVE:监听指定节点及其所有子孙节点,触发后不会自动移除。

Curator 对 ZooKeeper Watcher 做了进一步封装。早期常用 NodeCache、PathChildrenCache、TreeCache 来监听节点变化,但在新版 Curator 中,这三个类已经被标记为 Deprecated,新代码推荐使用 CuratorCache。

旧版 Curator 三种 Cache

  • NodeCache:监听某一个节点的数据变化;
  • PathChildrenCache:监听某一个节点的直接子节点变化;
  • TreeCache:监听某一个节点及其子孙节点变化。

新版推荐

  • CuratorCache:统一替代 NodeCache、PathChildrenCache、TreeCache,可以监听指定节点,也可以选择监听该节点下的整棵子树。
    {% endnote %}

监听单节点

这里演示 CuratorCache 做法

java 复制代码
@Test
public void testCuratorCache() throws Exception {
    // 1. 创建 CuratorCache
    // SINGLE_NODE_CACHE 表示只监听 /app1 这一个节点,等价于以前的 NodeCache
    CuratorCache cache = CuratorCache.build(
            client,
            "/app1",
            CuratorCache.Options.SINGLE_NODE_CACHE
    );

    // 2. 注册监听
    cache.listenable().addListener((type, oldData, data) -> {
        switch (type) {
            case NODE_CREATED:
                System.out.println("节点创建了!");
                printData(data);
                break;

            case NODE_CHANGED:
                System.out.println("节点变化了!");
                printData(data);
                break;

            case NODE_DELETED:
                System.out.println("节点删除了!");
                break;

            default:
                break;
        }
    });

    // 3. 开启监听
    cache.start();

    // 测试方法别立刻结束,否则监听线程还没来得及收到事件
    Thread.sleep(Integer.MAX_VALUE);
}

private void printData(ChildData data) {
    if (data == null || data.getData() == null) {
        System.out.println("节点数据为空");
        return;
    }

    System.out.println(new String(data.getData(), StandardCharsets.UTF_8));
}

我们执行三个命令来看一看输出结果

sh 复制代码
[zk: 127.0.0.1:2181(CONNECTED) 29] create /lh/app1
Created /lh/app1
[zk: 127.0.0.1:2181(CONNECTED) 30] set /lh/app1 ergou
[zk: 127.0.0.1:2181(CONNECTED) 31] delete /lh/app1
[zk: 127.0.0.1:2181(CONNECTED) 32] 

只监听直接子节点

直接子节点,孙子节点不监听

java 复制代码
@Test
public void testWatchChildren() throws Exception {
    String path = "/app2";

    // 1. 创建 CuratorCache
    CuratorCache cache = CuratorCache.build(client, path);

    // 2. 注册监听器:只监听 /app2 的子节点变化
    cache.listenable().addListener(
            CuratorCacheListener.builder()
                    .forPathChildrenCache(path, client, (client, event) -> { // forPathChildrenCache,把自己过滤掉,只要子节点
                        System.out.println("子节点事件:" + event.getType());

                        if (event.getData() != null) {
                            System.out.println("子节点路径:" + event.getData().getPath());

                            byte[] data = event.getData().getData();
                            if (data != null) {
                                System.out.println("子节点数据:" + new String(data));
                            }
                        }
                    })
                    .build()
    );

    // 3. 开启监听
    cache.start();

    // 防止测试方法结束
    Thread.sleep(Integer.MAX_VALUE);
}

监听自己加所有子节点

包括孙子节点

java 复制代码
@Test
public void testTreeCache() throws Exception {
    String path = "/app2";

    // 1. 创建 CuratorCache,监听 /app2 以及它下面的所有子孙节点
    CuratorCache cache = CuratorCache.build(client, path);

    // 2. 注册监听器
    cache.listenable().addListener((type, oldData, data) -> {
        System.out.println("事件类型:" + type);

        if (data != null) {
            System.out.println("节点路径:" + data.getPath());

            byte[] bytes = data.getData();
            if (bytes != null) {
                System.out.println("节点数据:" + new String(bytes));
            }
        }
    });

    // 3. 开启监听
    cache.start();

    Thread.sleep(Integer.MAX_VALUE);
}

{% note info %}

如果想监听所有子节点(孙子节点)但是不包含自己,可以自己写一下过滤,就是

java 复制代码
if (path.equals(data.getPath())) {
    return;
}

{% endnote %}

  • NodeCache: CuratorCache.build(client, path, SINGLE_NODE_CACHE)
  • PathChildrenCache: CuratorCache + forPathChildrenCache(...)
  • TreeCache: CuratorCache.build(client, path)

分布式锁

  • 在我们进行单机应用开发,涉及并发同步的时候,我们往往采用 synchronized 或者 Lock 的方式来解决多线程间的代码同步问题,这时多线程的运行都是在同一个 JVM 之下,没有任何问题。
  • 但当我们的应用是分布式集群工作的情况下,属于多 JVM 下的工作环境,跨 JVM 之间已经无法通过多线程的锁解决同步问题。
  • 那么就需要一种更加高级的锁机制,来处理中跨机器的进程之间的数据同步问题------这就是分布式锁。

常见的分布式锁有三种

  • 基于缓存实现分布式锁
    • Redis 性能好,缺点是它实现的分布式锁是 AP 高可用,不能保证高一致性,也就是没有 CP 模式
    • Memcache
  • Zookeeper 实现分布式锁,可以实现 CP 模式,性能当然没有 Redis 好
    • Curator
  • 数据库层面实现分布式锁
    • 悲观锁、乐观锁

Zookeeper 分布式锁原理

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

  1. 客户端获取锁时,在 lock 节点下创建临时顺序节点
    • 创建临时节点的原因是,比如 client1 在获取锁之后,/lock/1 节点创建了,但是 client1 就宕机了,此时这个锁就释放不了了,其他客户端也用不了这个锁了。
    • 用顺序节点是为了让多个客户端按照节点序号排队竞争锁,序号最小的节点获得锁
  2. 然后获取 lock 下面的所有子节点,客户端获取到所有的子节点之后,如果发现自己创建的子节点序号最小,那么就认为该客户端获取到了锁。使用完锁后,将该节点删除。
  3. 如果发现自己创建的节点并非 lock 所有子节点中最小的,说明自己还没有获取到锁,此时客户端需要找到比自己小的那个节点,同时对其注册事件监听器,监听删除事件。
  4. 如果发现比自己小的那个节点被删除,则客户端的 Watcher 会收到相应通知,此时再次判断自己创建的节点是否是 lock 子节点中序号最小的,如果是则获取到了锁,如果不是则重复以上步骤继续获取到比自己小的一个节点并注册监听。
    • 重新判断的目的是,比如 client1 正在持有锁,但是 client2 突然宕机了,那么因为是临时的,数据就会丢,相当于删除,此时就会触发 client3 监听事件,但是 client1 还没释放呢,所以还要再去判断。

JavaAPI 操作

在 Curator 中有五种常见的分布式锁/同步方案

  • InterProcessSemaphoreMutex:分布式不可重入互斥锁
  • InterProcessMutex:分布式可重入互斥锁
  • InterProcessReadWriteLock:分布式可重入读写锁
  • InterProcessMultiLock:将多个锁组合为一个整体进行管理
  • InterProcessSemaphoreV2:分布式信号量,用于控制并发访问数量

模拟 12306 售票

我们先来模拟一下问题

java 复制代码
public class Ticket12306 implements Runnable{

    private int tickets = 10; // 数据库的票数

    @Override
    public void run() {
        while (true) {
            if (tickets > 0) {
                System.out.println(Thread.currentThread() + ":" + tickets);
                tickets--;
            }
    }
}

对外提供服务,12306 肯定一直在运行,线程进来就会一直抢,速度很快

java 复制代码
public static void main(String[] args) {

    Ticket12306 ticket12306 = new Ticket12306();

    // 创建客户端
    Thread t1 = new Thread(ticket12306, "携程");
    Thread t2 = new Thread(ticket12306, "飞猪");

    t1.start();
    t2.start();
}

两个线程同时来抢票,模拟并发,看结果

之后我们进行加锁,就没啥问题了,核心代码已经标注

diff 复制代码
public class Ticket12306 implements Runnable{

    private int tickets = 10; // 数据库的票数

+   private InterProcessMutex lock;

    public Ticket12306() {
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181")
                .sessionTimeoutMs(60 * 1000)
                .connectionTimeoutMs(15 * 1000)
                .retryPolicy(retryPolicy)
                .build();
        client.start();

+       lock = new InterProcessMutex(client, "/lock");
    }

    @Override
    public void run() {
        while (true) {
            // 获取锁 --> 3s 钟
            try {
+               lock.acquire(3, TimeUnit.SECONDS);
                if (tickets > 0) {
                    System.out.println(Thread.currentThread() + ":" + tickets);
                    tickets--;
                }
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                // 释放锁
                try {
+                   lock.release();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

集群

集群介绍

ZooKeeper 的 Leader 选举可以简单理解为:集群中的每台服务器启动时,都会先投自己一票,认为自己可以成为 Leader。然后各个服务器之间会互相交换投票信息,比较谁更适合当 Leader。

比较的时候,ZooKeeper 主要看两个东西:zxid 和 myid。zxid 可以理解为事务编号,表示这台服务器的数据新不新。zxid 越大,说明数据越新,越有资格成为 Leader。如果多个服务器的 zxid 一样大,那么就比较 myid,也就是服务器编号,编号大的优先。

比如有三台服务器 server.1、server.2、server.3,如果它们的数据一样新,也就是 zxid 相同,那么通常会选 myid 最大的 server.3 作为 Leader。但如果 server.1 的 zxid 最大,说明它的数据最新,那么即使它的编号不是最大,也会优先选 server.1。

当某台服务器获得超过半数的投票后,它就会成为 Leader。比如 3 台机器中获得 2 票,5 台机器中获得 3 票,就满足多数派条件。Leader 选出来后,其他节点会成为 Follower,并和 Leader 进行数据同步,同步完成后集群才正式对外提供服务。

{% note info %}

如果选举超不过半数,那就一直选不出 Leader,集群就一直处于选举状态,不能正常对外提供服务。这样也可以防止脑裂问题。

{% endnote %}

集群搭建

下面说一下集群搭建,这里还是用 Docker

{% note info %}

思路是,我们先搞三个 Zookeeper 服务,然后如果互相之间想要知道对方的存在,还要分别给三个人都说另外几个服务的 IP 端口之类的

{% endnote %}

创建三个 Zookeeper 服务

sh 复制代码
docker run -d \
  --name zk386-1 \
  -p 2181:2181 \
  -p 2881:3881 \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkData1:/data \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkDatalog1:/datalog \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/conf1:/conf \
  zookeeper:3.8.6

docker run -d \
  --name zk386-2 \
  -p 2182:2181 \
  -p 2882:3881 \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkData2:/data \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkDatalog2:/datalog \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/conf2:/conf \
  zookeeper:3.8.6

docker run -d \
  --name zk386-3 \
  -p 2183:2181 \
  -p 2883:3881 \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkData3:/data \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/zkDatalog3:/datalog \
  -v /Users/ice/Desktop/cola/environment/zookeeper/docker/conf3:/conf \
  zookeeper:3.8.6

第二个是 服务器之间投票选举的端口

之后我们在每个 zookeeper 的 data 目录下创建一个 myid 文件(已经有了就修改),内容分别是 1、2、3,对,就只放一个数字

建立网络

sh 复制代码
docker network create zk-net
docker network connect zk-net zk386-1
docker network connect zk-net zk386-2
docker network connect zk-net zk386-3

然后在每一个 zookeeper 的 zoo.cfg 配置集群 IP 列表,三个都写

text 复制代码
server.1=zk386-1:2888:3888;2181
server.2=zk386-2:2888:3888;2181
server.3=zk386-3:2888:3888;2181

然后重新启动集群

sh 复制代码
docker restart zk386-1
docker restart zk386-2
docker restart zk386-3

查看每个实例的运行状态

sh 复制代码
docker exec zk386-1 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-2 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-3 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status

1 开机的时候,只有它自己,所以没办法成功对外访问,所以它是 follower,2 开机的时候有两个了,并且超过半数了,它的 myid 最大,所以它是 leader,第 3 个来的时候已经有 leader 了,所以它就跟随

模拟集群异常

首先测试如果从服务器挂掉会如何,停掉 3 号,看 1、2 号状态

sh 复制代码
docker stop zk386-3

docker exec zk386-1 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-2 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status

可以看出,3 个节点的集群,从服务器挂掉,集群正常

再把 1 号停掉,看看 2 号状态

sh 复制代码
docker stop zk386-1

docker exec zk386-2 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status

2 号也停止运行了,因为超过半数的机器都挂了,主服务器也无法运行了

然后再把 1 号服务启动

sh 复制代码
docker start zk386-1

docker exec zk386-2 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-1 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status

这个时候 2 号又能正常工作了,并且是领导者。

然后我们把 3 号服务器也启动起来,把 2 号停掉,看看 1, 3 号状态

sh 复制代码
docker start zk386-3
docker stop zk386-2

docker exec zk386-1 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-3 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status

3 号成为新的 leader,也就是说当集群中的主服务器挂了,集群中的其他服务器会自动进行选举状态,然后产生新得 leader。

我们再把 2 号启动起来看看

sh 复制代码
docker start zk386-2

docker exec zk386-1 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-2 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status
docker exec zk386-3 /apache-zookeeper-3.8.6-bin/bin/zkServer.sh status

3 号还是 leader 不会变,也就是说当领导者产生后,再次有新服务器加入集群,不会影响到现任领导者。

集群角色

在 ZooKeeper 集群服务中有三个角色

Leader 领导者

  • 负责处理事务请求,也就是增、删、改操作;
  • 负责为事务请求生成事务提案;
  • 负责协调 Follower 完成事务提交;
  • 负责集群内部数据同步和调度。

Follower 跟随者

  • 可以直接处理客户端的非事务请求,比如查询;
  • 如果收到事务请求,会转发给 Leader;
  • 参与 Leader 选举投票;
  • 参与事务提案投票。

Observer 观察者

  • 可以处理客户端的非事务请求,比如查询;
  • 如果收到事务请求,也会转发给 Leader;
  • 会同步 Leader 的数据变化;
  • 不参与 Leader 选举投票;
  • 不参与事务提案投票。

当客户端发起一个删除请求时,比如

sh 复制代码
delete /app1

如果这个请求直接发送到了 Leader,那么 Leader 会直接处理这个事务请求;如果请求发送到了 Follower 或 Observer,那么 Follower / Observer 会先把这个写请求转发给 Leader。ZooKeeper 中真正负责协调写操作的是 Leader。

Leader 收到删除请求后,不是自己直接把 /app1 删除完就结束,而是会先生成一个事务提案。这个事务提案可以理解为:Leader 把这次写操作包装成一个带有全局事务编号 zxid 的请求,例如

text 复制代码
zxid = 1001
操作 = 删除 /app1

然后 Leader 会把这个事务提案发送给 Follower。Follower 收到提案后,会先把这个事务记录到自己的事务日志中,然后给 Leader 返回确认响应,也就是 ACK。

当 Leader 收到超过半数投票节点的 ACK 后,说明这个事务提案已经被集群多数节点认可,此时 Leader 才会提交这个事务。提交之后,Leader 会通知其他节点执行提交操作,Follower 和 Observer 再把这个删除结果应用到自己的数据中。

这里要注意,Observer 不参与投票。也就是说,超过半数这个要求只和参与投票的节点有关,也就是 Leader 和 Follower,不把 Observer 算进去。Observer 主要是用来分担读请求压力的,它会同步数据,但不会影响 Leader 选举和事务提交的过半数判断。

所以 Observer 宕机时,一般不会影响集群能否选出 Leader,也不会影响事务是否能达到超过半数确认,只是少了一个可以处理查询请求的节点,Follower 的查询压力可能会变大。