【笔记】从 Paxos 到 Zookeeper:第 5 章 使用 Zookeeper

部署和运行

zk 有三种模式:单机模式、集群模式、伪集群模式,单机模式就是只部署一台服务器,集群模式是在多台服务器上部署多个 zk 进程,伪集群模式是在一台服务器上部署多个 zk 进程。

zk 不同模式的配置都是差不多的,配置也非常简单,只需要创建两个配置文件 zoo.cfg 和 myid 文件即可。myid 文件内容是 zk 在集群中的唯一标识,zoo.cfg 内容示例如下:

tickTime=2000
dataDir=/home/myname/zookeeper
clientPort=2181
initLimit=5
syncLimit=2
server.1=192.168.229.160:2888:3888
server.2=192.168.229.161:2888:3888
server.3=192.168.229.162:2888:3888

各个配置项介绍:

配置 说明
initLimit ZooKeeper 集群模式下包含多个 zk 进程,其中一个进程为 leader,余下的进程为 follower。当 follower 最初与 leader 建立连接时,它们之间会传输相当多的数据,尤其是 follower 的数据落后 leader 很多。initLimit 配置 follower 与 leader 之间建立连接后进行同步的最长时间。
syncLimit 配置 follower 和 leader 之间发送消息,请求和应答的最大时间长度。
tickTime 是上述两个超时配置的基本单位,例如对于 initLimit,其配置值为 5,说明其超时时间为 2000ms * 5 = 10秒。
server.id=host:port1:port2 其中 id 为一个数字,表示 zk 进程的序号,值必须在 1 ~ 255,这个 id 也是 dataDir 目录下 myid 文件的内容。host 是该 zk 进程所在的 IP 地址,port1 表示 follower 和 leader 交换消息所使用的端口,port2 表示选举 leader 所使用的端口。
dataDir 无默认配置,必须配置,用于配置存储快照文件的目录。如果没有配置dataLogDir,那么事务日志也会存储在此目录。
clientPort 和客户端通信的端口

在 Zookeeper 的 bin 目录下有几个可执行脚本,可供运行和维护服务器:

脚本 说明
zkServer Zookeeper 服务器的启动、停止和重启
zkCli Zookeeper 简易客户端
zkEnv 设置 Zookeeper 的环境变量
zkCleanup 清理 Zookeeper 的历史数据,包括事务日志文件和快照数据文件

客户端脚本

使用刚刚提到的 zkCli 脚本即可连接服务器进行操作:

./zkCli -server ip:port

客户端可执行的命令主要有增(创建节点)、删(删除节点)、改(修改节点)、查(查询节点):

命令 说明
create [-s] [-e] path data acl 创建节点,-s、-e 表示顺序、临时节点,默认是创建永久节点
ls path [watch] 列出 path 下所有子节点
get path [watch] 查看指定节点的内容和属性信息
set path data [version] 更新指定节点内容,带 version 参数时,会比较被修改的节点版本是否一致,一致才能修改
delete path [version] 删除指定节点,被删除的节点必须没有子节点才能删除成功

原生 Java API

原生 Java API 的功能和命令行脚本的功能基本一致,也是创建会话(相当于zkCli连接server),增删改查,Watch。

// Zookeeper 对象的构造方法,用于创建会话
Zookeeper(String connectString, int sessionTimeout, Watcher watcher);
// 创建节点
String create(final String path, byte[] data, List<ACL> acl, CreateMode createMode); // 同步
void create(final String path, byte[] data, List<ACL> acl, CreateMode createMode, StringCallback cb, Object ctx); // 异步
// 删除节点
void delete(final String path, int version);
void delete(final String path, int version, VoidCallback cb, Object ctx);
// 读取子节点列表
List<String> getChildren(final String path, Watcher watcher); // 获取子节点
void getChildren(final String path, Watcher watcher, ChildrenCallback cb, Object ctx);
// 读取数据
byte[] getData(final String path, Watcher watcher, Stat stat);
void getData(final String path, Watcher watcher, Stat stat, DataCallback cb, Object ctx););
// 修改数据
Stat setData(final String path, byte[] data, int version);
void setData(final String path, byte[] data, int version, StatCallback cb, Object ctx);
// 检测节点是否存在
Stat exists(final String path, Watcher watcher);
void exists(final String path, Watcher watcher, StatCallback cb, Object ctx);

直接使用 Zookeeper 原生 API 的人并不多,因为:

  1. 连接的创建是异步的,需要开发人员自行编码实现等待。
  2. 连接没有超时自动的重连机制。
  3. Zookeeper 本身没提供序列化机制,需要开发人员自行指定,从而实现数据的序列化和反序列化。
  4. Watcher 注册一次只会生效一次,需要不断的重复注册。
  5. Watcher 的使用方式不符合 Java 本身的术语,如果采用监听器方式,更容易理解。
  6. 不支持递归创建树形节点。

因为原生 API 不好用,所以出现了一些开源客户端:ZkClient 和 Curator。

开源客户端

ZkClient

ZkClient 是 Github 上的一个开源 Zookeeper 客户端,是由 Datameer 工程师 Stefan Groschupf 和 Peter Voss 一起开发。ZkClient 在原生 Zookeeper api 的基础上进行封装,是一个更易用的客户端,解决了如下问题:

  1. 异步连接改成了同步连接。
  2. Session 会话超时重连。
  3. 解决了 Watcher 反复注册的问题。
  4. 简化 API 开发,ZkClient 里监听器不再叫 Watcher,而是大家更习惯的 Listener。
  5. 支持创建节点时,若父节点不存在则递归创建之,同时也可以递归删除。
  6. 支持自定义序列化方法,写节点数据的参数从 byte[] 变成 Object。

ZkClient 常用 API 如下所示:

// 创建连接
public ZkClient(String zkServers, int sessionTimeout, int connectionTimeout, ZkSerializer zkSerializer)
// 创建节点
public void createPersistent(String path, boolean createParents, List<ACL> acl)
public String create(final String path, Object data, final List<ACL> acl, final CreateMode mode) 
public void createPersistent(String path, Object data, List<ACL> acl)
public String createPersistentSequential(String path, Object data, List<ACL> acl) 
// 删除节点
public boolean delete(final String path)
public boolean delete(final String path, final int version)
public boolean deleteRecursive(String path)
// 读取节点列表
public List<String> getChildren(String path)
// 读取节点内容
public <T extends Object> T readData(String path, boolean returnNullIfPathNotExists)
// 更新数据
public void writeData(final String path, Object datat, final int expectedVersion)
public Stat writeDataReturnStat(final String path, Object datat, final int expectedVersion)
// 注册 Listener,监听数据变化
public void handleChildChange(String parentPath, List<String> currentChilds)
public void handleDataChange(String dataPath, Object data) throws Exception;
// 检测节点是否存在
protected boolean exists(final String path, final boolean watch)

代码示例:

public class TestZkClient {
    public static void main(String[] args) throws InterruptedException {

        ZkClient zkClient = new ZkClient("127.0.0.1:2181",5000);
        System.out.println("ZK 成功建立连接!");

        String path = "/zk-test";
        // 注册子节点变更监听(此时path节点并不存在,但可以进行监听注册)
        zkClient.subscribeChildChanges(path, new IZkChildListener() {
            public void handleChildChange(String parentPath, List<String> currentChilds) throws Exception {
                System.out.println("路径" + parentPath +"下面的子节点变更。子节点为:" + currentChilds );
            }
        });

        // 递归创建子节点(此时父节点并不存在)
        zkClient.createPersistent("/zk-test/a1",true);
        Thread.sleep(5000);
        System.out.println(zkClient.getChildren(path));
    }
}

Curator

Curator 是 Netflix 公司开源的一套 Zookeeper 客户端框架,作者是 Jordan Zimmerman。Curator 也解决了原生 API 的一些问题:

  1. 异步连接改成了同步连接。
  2. Session 会话超时重连。
  3. 解决了 Watcher 反复注册的问题。
  4. 支持创建节点时,若父节点不存在则递归创建之,同时也可以递归删除。

除此之外,还提供了一些额外的方便开发者使用的功能:

  1. 提供了一套 Fluent 风格的客户端 API 框架。
  2. 通过指定独立命名空间,实现不同会话的命名空间隔离。
  3. 提供强制保证删除的功能,也就是会在后台持续删除,直到成功。
  4. 高级功能:分布式锁、master选举等。

基础 API

Curator API 如下所示,除了创建 zookeeper 会话有两种方式:普通方式和 fluent 风格 API,其他都只有 fluent 一种方式。Fluent Interface无疑是让调用方的处理更加优雅,逻辑流程更加连贯,在多参时更加明显,普通的构造函数来限定的方式无法比拟。

// 普通方式,定制化信息也没有 fluent 模式丰富
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client =
CuratorFrameworkFactory.newClient(
                        connectionInfo,
                        5000,
                        3000,
                        retryPolicy);
// fluent 风格
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client =
CuratorFrameworkFactory.builder()
                .connectString(connectionInfo)
                .sessionTimeoutMs(5000)
                .connectionTimeoutMs(5000)
                .retryPolicy(retryPolicy)
                .namespace("base") // 指定命令空间
                .build();
// 创建节点
client.create().creatingParentContainersIfNeeded().withMode(CreateMode.EPHEMERAL).forPath("path");
// 删除节点
client.delete().deletingChildrenIfNeeded().withVersion(10086).guaranteed().forPath("path");
// 读取节点数据
client.getData().storingStatIn(stat).usingWatcher(watcher).forPath("path");
// 修改节点数据
client.setData().withVersion(10086).forPath("path","data".getBytes());
// 检查节点是否存在
client.checkExists().forPath("path");
// 读取子节点
client.getChildren().usingWatcher(watcher).forPath("path");
// 事务
client.inTransaction().check().forPath("path")
      .and()
      .create().withMode(CreateMode.EPHEMERAL).forPath("path","data".getBytes())
      .and()
      .setData().withVersion(10086).forPath("path","data2".getBytes())
      .and()
      .commit();

监听功能

在 Curator 里监听某个节点的变化,可以使用原生 Zookeeper 的 Watcher(一次性的),也可以使用经过 Curator 封装的 Cache。Cache 是 Curator 中对事件监听的包装,其对事件的监听其实可以近似看作是一个本地缓存视图和远程 ZooKeeper 视图的对比过程。同时 Curator 能够自动为开发人员处理反复注册监听,从而大大简化了原生 API 开发的繁琐过程。Cache 分为两类监听类型:节点监听和子节点监听。

NodeCache用于监听指定ZooKeeper数据节点本身的变化,其构造方法有如下两个:

public NodeCache(CuratorFramework client, String path);
public NodeCache(CuratorFramework client, String path, boolean dataIsCompressed);

NodeCacheListener回调接口定义:

public interface NodeCacheListener {
	public void nodeChanged() throws Exception;
}

代码示例如下所示:

public class NodeCacheDemo {
    private static final String PATH = "/example/cache";
    public static void main(String[] args) throws Exception {
        TestingServer server = new TestingServer();
        CuratorFramework client = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(1000, 3));
        client.start();
        client.create().creatingParentsIfNeeded().forPath(PATH);
        final NodeCache cache = new NodeCache(client, PATH);
        NodeCacheListener listener = () -> {
            ChildData data = cache.getCurrentData();
            if (null != data) {
                System.out.println("节点数据:" + new String(cache.getCurrentData().getData()));
            } else {
                System.out.println("节点被删除!");
            }
        };
        cache.getListenable().addListener(listener);
        cache.start();
        client.setData().forPath(PATH, "01".getBytes());
        Thread.sleep(100);
        client.setData().forPath(PATH, "02".getBytes());
        Thread.sleep(100);
        client.delete().deletingChildrenIfNeeded().forPath(PATH);
        Thread.sleep(1000 * 2);
        cache.close();
        client.close();
        System.out.println("OK!");
    }
}

Node Cache 用于监听节点数据变化,另外还有 Path Cache 用于监听子节点变化,Tree Cache 用于监听整棵树上的所有节点,相当于 Node Cache 和 Path Cache 的结合。

leader 选举

在分布式计算中,leader elections 是很重要的一个功能,这个选举过程是这样子的:指派一个进程作为组织者,将任务分发给各节点。在任务开始前,哪个节点都不知道谁是 leader(领导者)或者 coordinator(协调者). 当选举算法开始执行后,每个节点最终会得到一个唯一的节点作为任务 leader. 除此之外, 选举还经常会发生在 leader 意外宕机的情况下,新的 leader 要被选举出来。

在 zookeeper 集群中,leader 负责写操作,然后通过 Zab 协议实现 follower 的同步,leader 或者 follower 都可以处理读操作。

Curator 有两种 leader 选举的 recipe, 分别是 LeaderSelector 和 LeaderLatch。前者是所有存活的客户端不间断的轮流做 Leader,大同社会。后者是一旦选举出 Leader,除非有客户端挂掉重新触发选举,否则不会交出领导权。

LeaderLatch 代码示例如下所示:

public class LeaderLatchDemo extends BaseConnectionInfo {
    protected static String PATH = "/francis/leader";
    private static final int CLIENT_QTY = 10;

    public static void main(String[] args) throws Exception {
        List<CuratorFramework> clients = Lists.newArrayList();
        List<LeaderLatch> examples = Lists.newArrayList();
        TestingServer server=new TestingServer();
        try {
            for (int i = 0; i < CLIENT_QTY; i++) {
                CuratorFramework client
                        = CuratorFrameworkFactory.newClient(server.getConnectString(), new ExponentialBackoffRetry(20000, 3));
                clients.add(client);
                LeaderLatch latch = new LeaderLatch(client, PATH, "Client #" + i);
                latch.addListener(new LeaderLatchListener() {

                    @Override
                    public void isLeader() {
                        // TODO Auto-generated method stub
                        System.out.println("I am Leader");
                    }

                    @Override
                    public void notLeader() {
                        // TODO Auto-generated method stub
                        System.out.println("I am not Leader");
                    }
                });
                examples.add(latch);
                client.start();
                latch.start();
            }
            Thread.sleep(10000);
            LeaderLatch currentLeader = null;
            for (LeaderLatch latch : examples) {
                if (latch.hasLeadership()) {
                    currentLeader = latch;
                }
            }
            System.out.println("current leader is " + currentLeader.getId());
            System.out.println("release the leader " + currentLeader.getId());
            currentLeader.close();

            Thread.sleep(5000);

            for (LeaderLatch latch : examples) {
                if (latch.hasLeadership()) {
                    currentLeader = latch;
                }
            }
            System.out.println("current leader is " + currentLeader.getId());
            System.out.println("release the leader " + currentLeader.getId());
        } finally {
            for (LeaderLatch latch : examples) {
                if (null != latch.getState())
                CloseableUtils.closeQuietly(latch);
            }
            for (CuratorFramework client : clients) {
                CloseableUtils.closeQuietly(client);
            }
        }
    }
}

LeaderLatch 原理是在启动时,在指定目录下,创建一个 EPHEMERAL_SEQUENTIAL 节点。当该节点是在当前目录下排第一个时(字符串排序),就称为 leader。

分布式 Barrier

Barrier 是一种用来控制多线程之间同步的经典方式,在 JDK 中有一个 CyclicBarrier,Curator 提供了两个 Barrier:DistributedBarrier 和 DistributedDoubleBarrier。下面通过代码示例来看看两者的区别,首先看 DistributedBarrier:

public class CuratorBarrier2 {
	static final int SESSION_OUTTIME = 5000;//ms 
	static DistributedBarrier barrier = null;
	
	public static void main(String[] args) throws Exception {
		for(int i = 0; i < 5; i++){
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 10);
						CuratorFramework cf = CuratorFrameworkFactory.builder()
									.connectString("127.0.0.1:2181")
									.sessionTimeoutMs(SESSION_OUTTIME)
									.retryPolicy(retryPolicy)
									.build();
						cf.start();
						
						barrier = new DistributedBarrier(cf, "/super");
						System.out.println(Thread.currentThread().getName() + "设置barrier!");			
						
						barrier.setBarrier();	//设置
						
						barrier.waitOnBarrier();	//等待
						System.out.println("---------开始执行程序----------");
					} catch (Exception e) {
						e.printStackTrace();
					}
				}
			},"t" + i).start();
		}
		
		//Thread.sleep(1000); // 如果时间过短时,会报空指针异常的
		Thread.sleep(1000*10);  // 等待时间需要估算,确保所有的线程都处于 waitOnBarrier() ,否则会报空指针。
		barrier.removeBarrier();	//释放		
		
	}
}

执行程序,输入如下所示:

t0设置barrier!
t3设置barrier!
t2设置barrier!
t1设置barrier!
t4设置barrier!
---------开始执行程序----------
---------开始执行程序----------
---------开始执行程序----------
---------开始执行程序----------
---------开始执行程序----------

在上面这个实例程序中,我们模拟了 5 个线程,通过调用 DistributedBarrier. setBarrier() 方法来完成 Barrier 的设置,并通过调用 DistributedBarrier.waitOnBarrier() 方法来等待 Barrier 的释放。然后在主线程中,通过调用 DistributedBarrier.removeBarrier() 方法来释放 Barrier,同时触发所有等待 该 Barrier 的 5 个线程同时进行各自的业务逻辑。

DistributedBarrier 的实现也很简单,在 setBarrier 时创建了一个节点,所有 DistributedBarrier 创建的都是同一个节点,如果已经有则不创建。在 waitOnBarrier 的时候 watch 该节点,当有节点变更时检查该节点是否存在,存在则继续 watch,不存在则退出。removeBarrier 则是删除该节点,也就是只要有一个 Barrier 来删除该节点,即可触发所有 DistributedBarrier 执行退出 waitOnBarrier。

DistributedBarrier 是需要主线程来触发释放的,而 DistributedDoubleBarrier 是自动触发释放的:

public class CuratorBarrier1 {
	static final int SESSION_OUTTIME = 5000;//ms 
	
	public static void main(String[] args) throws Exception {		
		for(int i = 0; i < 5; i++){
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 2);
						CuratorFramework cf = CuratorFrameworkFactory.builder()
									.connectString("127.0.0.1:2181")
									.retryPolicy(retryPolicy)
									.build();
						cf.start();
						
						DistributedDoubleBarrier barrier = new DistributedDoubleBarrier(cf, "/super", 5);
						Thread.sleep(1000 * (new Random()).nextInt(5)); 
						System.out.println(Thread.currentThread().getName() + "已经准备");
						
						barrier.enter();
						System.out.println("同时开始运行...");
						
						Thread.sleep(1000 * (new Random()).nextInt(5));
						System.out.println(Thread.currentThread().getName() + "运行完毕");
						
						barrier.leave();
						System.out.println("同时退出运行...");						
					} catch (Exception e) {
						e.printStackTrace();
					}
				}
			},"t" + i).start();
		}		
		
	}
}

执行程序,输入结果如下:

t2已经准备
t4已经准备
t0已经准备
t3已经准备
t1已经准备
同时开始运行...
同时开始运行...
同时开始运行...
同时开始运行...
同时开始运行...
t3运行完毕
t2运行完毕
t0运行完毕
t4运行完毕
t1运行完毕
同时退出运行...
同时退出运行...
同时退出运行...
同时退出运行...
同时退出运行...

上面这个示例程序就是一个和 JDK 自带的 CyclicBarrier 非常类似的实现了,它们都指定了进入 Barrier 的成员数阈值,例如上面示例程序中的“5”。每个 Barrier 的参与者都会在调用 DistributedDoubleBarrier.enter() 方法之后进行等待,此时处于准备进入状态。一旦准备进入 Barrier 的成员数达到 5 个后,所有的成员会被同时触发进人。之后调用 DistributedDoubleBarrier.leave() 方法则会再次等待,此时处于准备退出状态。一旦准备退出 Barrier 的成员数达到 5 个后,所有的成员同样会被同时触发退出。因此,使用 Curator 的 DistributedDoubleBarrier 能够很好地实现一个分布式 Barrier,并控制其同时进入和退出。

DistributedDoubleBarrier 在 enter 方法里,先在指定目录下创建了自己的节点,然后检测该目录下子节点数目是否满足要求,满足则退出 enter。在 leave 方法里则是检测子节点是否被删光(或自己的节点是否是最后一个),是则退出。

资料

  1. Curator DistributedDoubleBarrier 源码分析
发布了232 篇原创文章 · 获赞 347 · 访问量 79万+

猜你喜欢

转载自blog.csdn.net/hustspy1990/article/details/90544707
今日推荐