本笔记涉及代码:https://github.com/hackeryang/Hadoop-Exercises/tree/master/src/main/java/ZooKeeper
一、ZooKeeper介绍与安装
1.ZooKeeper是Hadoop的分布式协调服务,名称起作“动物管理员”是因为它可以管理Hadoop(大象)、Hive(蜜蜂)、Pig(小猪)等大数据组件。多台服务器并行计算响应用户的请求时,以往多台服务器之间没有完善的同步和配合机制,会有以下问题:
(1)多台机器中一台故障,其他机器如何接管计算任务,否则故障机器上的用户请求不会被响应。
(2)用户数量增加集群性能达到瓶颈或不再需要那么多性能,如何灵活增减机器且不重启整个集群。
(3)用户数量会随机增加或减少,集群中某些机器会负载很高,某些空闲,机器间不知道彼此的负载状态。
(4)缺乏机器间的消息通知机制,如何通知每个节点彼此的负载状态,并保证消息通知的可靠和实时性。
上面的问题需要有这样的一个组件和机制:能够记录掌握各机器的状态、能够互相发送消息通知、有一个可靠的中央调度器且管理简单。ZooKeeper就是在这样的需求下诞生,写分布式应用时主要困难在于出现“部分失败”(partial failure),一条消息在网络中两个节点间传输时如果出现网络错误,发送节点无法知道接收节点是否收到消息,发送节点只能再一次重连接收节点并重新发送消息,即不确定一个操作是否失败。简单来说ZooKeeper提供了一个文件系统和通知机制,可以对上述问题进行正确处理,ZooKeeper具有的特点如下所示:
(1)简单,ZooKeeper的核心是一个精简的文件系统,提供一些简单操作与一些额外抽象操作例如排序和通知。
(2)功能丰富,ZooKeeper的基本操作是一组丰富构件(building block),可用于实现多种分布式协调数据结构和协议,例如分布式队列、分布式锁以及一组节点中的领导节点选举(leader election)。
(3)高可用性,ZooKeeper可以避免系统出现master节点单点故障。
(4)松耦合交互方式,通信节点之间不需要彼此了解,例如实现数据聚合(rendezous)机制,使进程在不了解其他进程或网络状况时能够彼此发现并进行通信。通信的两个进程甚至可以不用同时存在,一个进程可以在ZooKeeper中留下一条消息,该进程结束后其他进程还可以读取该消息。
2.伪分布式安装配置步骤如下所示:
(1)如果使用的是CDH版ZooKeeper,可以通过以下链接下载:
https://archive.cloudera.com/cdh5/cdh/5/
进入网页后,可以按Ctrl+F查找ZooKeeper,在页面的最底下才是tar.gz压缩包的下载地址,页面前面的同名链接都不是,如下所示:
下载下来后,在命令行中通过以下命令解压到想要放置的目录下:
tar -zxvf /mnt/sda6/zookeeper-3.4.5-cdh5.15.0.tar.gz -C /mnt/sda6/Hadoop/
(2)配置相关设置,首先在在命令行中使用sudo vim /etc/profile命令编辑系统环境变量文件(也可以编辑当前用户的命令行环境变量文件~/.bashrc),按i键进入编辑模式,加入ZOOKEEPER_HOME变量与$PATH变量中ZooKeeper的内容,如下所示:
写入完成后,按ESC键退出编辑模式变为只读,接着按冒号键在冒号后输入wq回车,表示写入并退出profile文件。退出profile文件回到命令行后,使用source /etc/profile命令使环境变量修改生效。
然后,这里在一台机器上配置三个ZooKeeper的实例,模拟成3个节点组成的小集群,因为小于三个节点就无法进行leader选举。首先用以下命令创建用于放置三个模拟节点上ZooKeeper配置文件和数据的多个目录:
然后用以下命令将ZooKeeper安装目录下的conf/zoo_sample.cfg配置文件模板分别复制到上面创建的zk1到zk3目录下,并分别改名为zk1.cfg、zk2.cfg和zk3.cfg,如下所示:
然后分别对这三个配置文件作以下设置并保存:
其中常见的设置属性如下所示:
a.tickTime:指节点间用心跳包互相确认在线的时间间隔,以ms为单位,若超过该时间没有某节点的心跳包,则判定节点已离线。ZooKeeper的client和server之间也有类似于Web通信中的session概念,ZooKeeper中这种session的最小过期时间就是tickTime的2倍,session最大过期时间为tickTime的20倍。较短的会话超时时间能较快检测到机器故障,但过低会因为繁忙网络导致数据包传输延迟,从而会无意中导致会话过期,此时集群会出现“振动”(flap)现象:某些机器在很短时间内反复出现离线后又重新加入znode组的情况。
对于创建较复杂临时状态的应用来说,重建会话的开销很大,因此此时适合设置较长session超时时间,以便在会话过期之前来得及重新恢复会话连接。服务器会为每个通信会话分配一个唯一ID和密码,在建立连接的过程中传递给ZooKeeper,将会话ID和密码保存之后,可以将一个应用程序关闭,然后在重启应用之前通过保存的会话ID和密码来恢复会话环境。一般原则是ZooKeeper集群中服务器数量越多,会话超时的设置应越大,但连接超时、读超时和tickTime应反而越小。如果频繁遇到连接丢失的情况,应考虑增大超时设置。
b.initLimit:指follower节点连接并同步到leader节点的初始化连接时间,以tickTime时间的倍数来表示。当初始化连接时间超过设置的时间时,连接失败。如果在设定时间段内半数以上follower没有连接同步到leader,则leader放弃自己的领导地位,重新进行一次leader选举。如果查看日志发现该情况经常发生,则表明设定的值太小。
c.syncLimit:指follower和leader之间发送消息时请求和响应的时间长度,如果follower在设置的时间内没能和leader通信完成,则该follower将自己重启,所有连接到该follower的客户端会连接到另一个follower。该属性也是按照tickTime时间的倍数设置的。
d.dataDir:指存储内存数据快照的目录。ZooKeeper运行期间会将数据缓存在内存中用于快速读取。
e.clientPort:指节点监听其他节点客户端连接的端口号。
f.server.x=hostAddress:mainPort:selectPort语句:表示集群各节点的连接配置,其中x表示集群中该节点在集群中的节点ID;hostAddress指集群中节点x的IP地址;mainPort表示当节点x作为leader时被follower连接的端口;selectPort表示自己在leader选举时用来彼此通信的端口。
(3)进入3个模拟节点目录的dataDir,创建名为myid的文件,写入上面配置文件中server.x对应的编号x作为该节点在集群中的ID(范围在1-255),命令如下所示:
(4)进入ZooKeeper的安装目录,通过脚本文件zkServer.sh启动含有三个模拟节点的伪分布式集群,如下所示:
可以看到启动3个模拟节点成功,此时安装配置已经成功完成,使用jps命令查看本机进程:
显示有3个ZooKeeper的进程,可以用tree -L 4 /<目录>命令看一下3个模拟节点目录下4层目录结构(可以用sudo apt install tree安装tree命令组件),如下所示:
通过zkServer.sh的status命令可以查看3个模拟节点的状态和身份,可以看到zk2是leader,zk1和zk3是follower,如下所示:
通过客户端脚本zkCli.sh可以使用-server参数连接集群中的一个节点,下面的例子连接了zk1节点,可以看到Welcome to ZooKeeper字样说明连接成功,如下所示:
(5)配置Intellij IDEA的maven依赖,在pom.xml中加入如下依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.cloudera.hadoop</groupId>
<artifactId>bigdata</artifactId>
<version>1.0-SNAPSHOT</version>
<repositories>
<repository>
<id>cloudera</id>
<url>https://repository.cloudera.com/artifactory/cloudera-repos/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
......
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.4.5-cdh5.15.0</version>
</dependency>
......
</dependencies>
二、常用命令与集群管理示例
3.启动ZooKeeper集群后,可以使用nc或telnet发送ruok(Are you OK)命令到某个节点监听端口,以确定该节点是否正在运行,如下所示:
除了ruok命令,也有很多常用的四字母组成的命令,如下所示:
4.集群工作需要每台机器都能互相知道彼此的信息,集群中所有节点的成员列表肯定不可以存储在单个节点上,否则该节点故障会导致整个系统故障,而且当其中一个节点故障时,需要能够动态从成员列表中删除,肯定是由某个远程进程负责删除该故障节点,而不由故障节点自己操作。ZooKeeper能够满足这样的需求,可以将它看作一个动态高稳定性的文件系统,该文件系统中没有文件和目录,而是统一使用节点(node)的概念,称为znode。ZooKeeper内部存储的数据结构是一棵树,类似于Linux文件系统的目录组织方式。最上层是根节点“/”,ZooKeeper只是在内存中维护这样一种树状的数据结构,分布式锁、集群配置管理、以及leader选举等都需要在这种数据结构基础上去实现。znode既可以作为保存数据的容器(如同文件),也可以作为保存其他znode的容器(如同目录),所有znode构成一个层次化命名空间,即组成员列表的建立可以创建一个以组名为节点名称的znode作为父节点,然后创建多个以组成员名(服务器名)作为节点名称的znode作为子节点,具有层次结构的znode组例子如下所示:
5.下面的程序可以用于创建组名为/zoo的znode:
package ZooKeeper;
import org.apache.zookeeper.*;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
public class CreateGroup implements Watcher { //在ZooKeeper中新建表示组的znode
private static final int SESSION_TIMEOUT=5000; //会话超时时间为5秒钟
private ZooKeeper zk; //用于维护客户端和ZooKeeper服务之间的连接
//用于阻止使用新建的ZooKeeper对象,除非该ZooKeeper对象已经准备就绪,锁存器(latch)创建时带一个值为1的计数器,用于表示在它释放所有等待线程之前需要发生的事件数
private CountDownLatch connectedSignal=new CountDownLatch(1);
public void connect(String hosts) throws IOException,InterruptedException{
//三个参数分别是ZooKeeper服务的主机地址、以毫秒为单位的会话超时时间、Watcher对象实例(用于接收来自ZooKeeper的回调,以获得各种事件通知)
zk=new ZooKeeper(hosts,SESSION_TIMEOUT,this); //ZooKeeper实例创建时会启动一个线程连接到ZooKeeper服务,Watcher类用于获取ZooKeeper对象是否准备就绪的信息
connectedSignal.await(); //当锁存器的计数器变为0时,该方法的等待线程才结束,意思就是等到client和ZooKeeper服务器连接成功并返回连接成功事件信息后,才松开线程允许使用该ZooKeeper对象
}
public void process(WatchedEvent event) { //Watcher类中包含的唯一方法,当客户端与ZooKeeper服务建立连接后(SyncConnected事件),该方法会被触发,参数是触发Watcher的事件
if(event.getState()== Event.KeeperState.SyncConnected){ //当接收到一个已连接事件时,递减锁存器的计数器
connectedSignal.countDown(); //调用一次递减计数器的方法后,上面CountDownLatch计数器的值变为0,则await()方法结束并返回
}
}
//connect()方法结束并返回之后调用的方法
public void create(String groupName) throws KeeperException,InterruptedException{
String path="/"+groupName;
//被创建的znode参数包括所在路径、znode的内容(字节数组,本例中为空值)、访问控制列表(简称ACL,本例中为完全开放ACL,允许任何client对znode读写)、创建znode的类型
String createdPath=zk.create(path,null/*data*/, ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
System.out.println("Created "+createdPath); //打印一条表示znode在对应路径被成功创建的消息
}
public void close() throws InterruptedException{
zk.close();
}
public static void main(String[] args) throws Exception{
CreateGroup createGroup=new CreateGroup(); //创建继承Watcher类的类实例,Watcher对象可以接收各种事件的通知信息
createGroup.connect(args[0]); //客户端连接到命令行参数中指定的ZooKeeper服务器
createGroup.create(args[1]); //连接成功建立后,以组名为路径创建znode
createGroup.close();
}
}
其中create()方法中的zk.create()子方法里第四个参数是CreateMode.PERSISTENT,表示创建一个持久znode。有三种类型的znode:
(1)短暂(ephemeral)znode:创建znode的客户端断开连接时该znode将会被ZooKeeper服务器删除。适合用来存储与通信会话相关的数据,例如客户端状态或客户端持有的锁等,与会话相关的数据应随着会话一起生死。一个经典例子是,在分布式锁场景下,各个想要获取这把锁的客户端都会去创建同一个znode,哪个客户端创建成功,便获取到了这把锁,这里的znode必须是短暂临时性的,如果一个客户端获取锁后自己故障,若该znode不是临时性的,它会永远存在于集群中不消失,锁永远也不松开。重要的是,如果一个znode是临时性的,则该znode不允许有子znode。
(2)持久(persistent)znode:当客户端断开连接时,创建的znode不会被ZooKeeper服务器删除。
除了上面两种主要的znode类型,还有一种顺序znode,每次创建这种节点时,ZooKeeper都会在路径后面自动添加10位数字(计数器),例如/path0000000001、/path0000000002,该计数器可以保证在同一个父节点下是唯一的,在ZooKeeper内部使用了4个字节的有符号整数来表示这个计数器,即当计数器大小超过2147483647时,会发生溢出。顺序znode严格上不算是一种znode,只是节点的一种特性,所以短暂znode和持久znode都可以设置为顺序znode,因此再细分可以划分为4种znode:持久znode、临时znode、持久顺序znode、临时顺序znode。
为了执行上面的程序,首先将编写的程序打包为jar包,然后在$ZOOKEEPER_HOME/conf目录下修改zoo_sample.cfg为zoo.cfg,并在其中修改dataDir到另外的目录中,接着用以下命令启动单节点集群:
然后,用以下命令将ZooKeeper的相关目录以及自己打包的jar所在目录包括在Java执行程序所需的类路径中,然后用java命令执行jar文件以连接到本机节点,并在本机上创建组名为“/zoo”的znode,注意在Windows系统下,程序需要依赖的多个类路径可以用分号“;”来隔开,而Linux等其他系统是用冒号“:”隔开的:
java -cp .:/mnt/sda6/zk.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.CreateGroup localhost zoo
该程序的执行结果如下所示,显示出了“Created /zoo”:
6.在创建组的父znode之后,下一步是在组中注册组成员。每个组成员将作为一个程序运行,并加入到组中。当程序退出时,该组成员应当从组中被删除,因此可以使用短暂znode代表一个组成员。下面的代码用于注册组成员,首先是Watcher辅助类的代码:
package ZooKeeper;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
public class ConnectionWatcher implements Watcher { //等待与ZooKeeper建立连接的辅助类,用于JoinGroup类
private static final int SESSION_TIMEOUT=5000; //会话超时时间为5秒钟
protected ZooKeeper zk; //用于维护客户端和ZooKeeper服务之间的连接
//用于阻止使用新建的ZooKeeper对象,除非该ZooKeeper对象已经准备就绪,锁存器(latch)创建时带一个值为1的计数器,用于表示在它释放所有等待线程之前需要发生的事件数
private CountDownLatch connectedSignal=new CountDownLatch(1);
public void connect(String hosts) throws IOException,InterruptedException{
//三个参数分别是ZooKeeper服务的主机地址、以毫秒为单位的会话超时时间、Watcher对象实例(用于接收来自ZooKeeper的回调,以获得各种事件通知)
zk=new ZooKeeper(hosts,SESSION_TIMEOUT,this); //ZooKeeper实例创建时会启动一个线程连接到ZooKeeper服务,Watcher类用于获取ZooKeeper对象是否准备就绪的信息
connectedSignal.await(); //当锁存器的计数器变为0时,该方法的等待线程才结束,意思就是等到client和ZooKeeper服务器连接成功并返回连接成功事件信息后,才松开线程允许使用该ZooKeeper对象
}
public void process(WatchedEvent event) { //Watcher类中包含的唯一方法,当客户端与ZooKeeper服务建立连接后(SyncConnected事件),该方法会被触发,参数是触发Watcher的事件
if(event.getState()== Event.KeeperState.SyncConnected){ //当接收到一个已连接事件时,递减锁存器的计数器
connectedSignal.countDown(); //调用一次递减计数器的方法后,上面CountDownLatch计数器的值变为0,则await()方法结束并返回
}
}
public void close() throws InterruptedException{
zk.close();
}
}
有了辅助类,真正创建临时性znode并加入组的代码如下所示:
package ZooKeeper;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.ZooDefs;
public class JoinGroup extends ConnectionWatcher { //创建短暂znode作为组成员加入组
public void join(String groupName,String memberName) throws KeeperException,InterruptedException{
String path="/"+groupName+"/"+memberName;
//被创建的znode参数包括所在路径、znode的内容(字节数组,本例中为空值)、访问控制列表(简称ACL,本例中为完全开放ACL,允许任何client对znode读写)、创建znode的类型(临时性znode)
String createdPath=zk.create(path,null/*data*/, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
System.out.println("Created"+createdPath);
}
public static void main(String[] args) throws Exception{
JoinGroup joinGroup=new JoinGroup(); //创建继承Watcher类的类实例,Watcher对象可以接收各种事件的通知信息
joinGroup.connect(args[0]); //客户端连接到命令行的第一个参数中指定的ZooKeeper服务器
joinGroup.join(args[1],args[2]); //通过命令行的第二个和第三个参数指定znode所属的组名和自己的组成员名,创建该znode并加入特定的路径组
//通过线程休眠来模拟正在做某种工作,保持线程占用直到该进程被强行终止,进程被终止后,该短暂znode会被ZooKeeper删除
Thread.sleep(Long.MAX_VALUE);
}
}
7.查看组成员的代码如下所示:
package ZooKeeper;
import org.apache.zookeeper.KeeperException;
import java.util.List;
public class ListGroup extends ConnectionWatcher { //列出组成员
public void list(String groupName) throws KeeperException,InterruptedException{
String path="/"+groupName;
try{
//检索一个znode的子节点列表,传入参数为znode的路径和观察标志,如果watch标志为true,则一旦该znode状态改变,关联的Watcher会被触发(例如组成员加入、退出和组被删除的通知),这里没有使用
List<String> children=zk.getChildren(path,false);
if(children.isEmpty()){
System.out.printf("No members in group %s\n",groupName);
System.exit(1);
}
for(String child:children){
System.out.println(child);
}
}catch(KeeperException.NoNodeException e){
System.out.printf("Group %s does not exist\n",groupName);
System.exit(1);
}
}
public static void main(String[] args)throws Exception{
ListGroup listGroup=new ListGroup(); //创建继承Watcher类的类实例,Watcher对象可以接收各种事件的通知信息
listGroup.connect(args[0]); //客户端连接到命令行的第一个参数中指定的ZooKeeper服务器
listGroup.list(args[1]); //根据命令行第二个参数指定要列出的组名,并列出组内成员
listGroup.close();
}
}
将程序打包为list.jar后,用以下命令来执行该程序:
java -cp .:/mnt/sda6/list.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.ListGroup localhost zoo
执行的结果如下所示:
可以看到因为没有在组中添加任何成员,所以zoo组是空的。所以可以使用前面写的JoinGroup程序来向组中添加成员。因为程序中这些作为组成员的znode不会自己终止(Thread.sleep()),命令行默认前台执行进程而无法再输入下一条命令,所以要在Java命令后加上后台执行进程的符号“&”,如下所示:
java -cp .:/mnt/sda6/list.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.JoinGroup localhost zoo duck &
java -cp .:/mnt/sda6/list.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.JoinGroup localhost zoo cow &
java -cp .:/mnt/sda6/list.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.JoinGroup localhost zoo goat &
goat_pid=$!
其中“$!”符号表示shell最后运行的后台进程ID,所以最后一个命令保存了将goat添加到组中的Java进程的ID。通过ListGroup程序查看组成员的命令与结果如下所示:
java -cp .:/mnt/sda6/list.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.ListGroup localhost zoo
goat
duck
cow
杀死goat所对应的进程,会使该组成员znode被删除,如下所示:
kill $goat_pid
经过5秒钟后(程序中的SESSION_TIMEOUT),因为该进程的ZooKeeper会话由于超时已经结束,并且对应的短暂znode也已经被删除,所以goat会从组成员列表中消失,上面的ListGroup程序会只输出duck和cow。
ZooKeeper也可以使用脚本中的命令来与znode的命名空间进行交互,利用如下命令可以列出/zoo组下的znode列表,如下所示:
zkCli.sh -server localhost ls /zoo
[cow, duck]
8.删除一个组的代码如下所示:
package ZooKeeper;
import org.apache.zookeeper.KeeperException;
import java.util.List;
public class DeleteGroup extends ConnectionWatcher { //用于删除一个组及其所有成员的程序
public void delete(String groupName) throws KeeperException,InterruptedException{
String path="/"+groupName;
try{
//检索一个znode的子节点列表,传入参数为znode的路径和观察标志,如果watch标志为true,则一旦该znode状态改变,关联的Watcher会被触发(例如组成员加入、退出和组被删除的通知),这里没有使用
List<String> children=zk.getChildren(path,false);
for(String child:children){
//两个传入参数为节点路径和版本号,输入的版本号与实际存在的znode版本号一致才会删除。版本号是一种乐观锁机制,使客户端能检测出对znode的修改冲突,设置为-1则匹配所有版本都能删除
zk.delete(path+"/"+child,-1); //ZooKeeper不支持递归删除操作,所以删除父节点之前必须先删除子节点
}
zk.delete(path,-1); //删除组成员子节点后再删除组父节点
}catch(KeeperException.NoNodeException e){
System.out.printf("Group %s does not exist\n",groupName);
System.exit(1);
}
}
public static void main(String[] args) throws Exception{
DeleteGroup deleteGroup=new DeleteGroup(); //创建继承Watcher类的类实例,Watcher对象可以接收各种事件的通知信息
deleteGroup.connect(args[0]); //客户端连接到命令行的第一个参数中指定的ZooKeeper服务器
deleteGroup.delete(args[1]); //删除命令行第二个参数指定的组
deleteGroup.close();
}
}
将上面的程序打包为delete.jar后,用以下命令删除之前创建的/zoo组,该命令执行完后再执行上面ListGroup的命令会显示“Group zoo does not exist”:
java -cp .:/mnt/sda6/delete.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.DeleteGroup localhost zoo
三、ZooKeeper服务
9.ZooKeeper维护一个树形层次结构,树中节点被称为znode。znode可用于存储数据,并且有一个与之关联的ACL。ZooKeeper被设计用来协调各服务,这些服务通常使用小数据文件,因此一个znode能存储的数据被限制在1MB以内,数据过大会导致ZooKeeper性能明显下降。如果确实需要存储大量数据,一般是在另外的分布式数据库(例如redis)中保存数据,然后在znode中只保留这个数据库保存位置的索引。
ZooKeeper的数据访问具有原子性,即客户端在读取一个znode的数据时,要么读到所有数据,要么读操作失败,不会只读到部分数据,同样写操作将替换znode存储的所有数据,或者写操作失败,不会出现部分写的情况,即不会出现只保存客户端所写部分数据的情况。ZooKeeper也不支持HDFS那样的append增量操作。ZooKeeper中的znode路径必须是绝对路径,不能像其他文件系统一样可以用相对路径表示,即每个路径必须从一个斜杠“/”开始。所以,所有路径必须写全,不能用Unix系统中的“.”符号来表示当前路径。同时,路径中也不能出现“zookeeper”保留字。
10.znode的类型在创建时被确定而且之后不能再修改。短暂znode在创建它的客户端会话结束时就会被删除,且短暂znode不可以有任何子节点。而持久znode不依赖于通信会话,只有当某个客户端(不一定是创建它的那个)明确使用命令删除它时才会被删除。顺序(sequential)znode是指名称中包含ZooKeeper指定顺序号的znode。如果在创建znode时设置了顺序znode的标识,那么该znode名称后会附加一个单调递增的计数器值(如/a/b-3),计数器由父节点维护。在一个分布式系统中,顺序号可以被用于为所有事件进行全局排序,这样客户端可以通过顺序号来推断事件的顺序。
11.znode以某种方式发生变化时,“观察”(Watcher)机制可以让客户端得到通知。可以对ZooKeeper服务的操作来设置Watcher,该服务的其他操作可以触发Watcher返回变化信息。例如,客户端可以对一个znode调用exists操作,同时设置一个Watcher,如果该znode不存在,则客户端调用的exists操作会返回false;如果一段时间后另外一个客户端创建了这个znode,则该Watcher会被触发,通知前一个客户端该znode已被创建。
除了对连接事件回调的Watcher不需要重新注册以外,其他Watcher只能触发一次,所以为了多次收到Watcher监测到的通知,客户端需要反复重新注册所需的Watcher。例如客户端如果想收到更多znode是否存在的通知,则需要再次调用exists操作,设定一个新的Watcher。
12.ZooKeeper中有9种基本操作,如下所示:
其中ZooKeeper中的delete或setData等更新操作必须提供被更新znode的版本号(可通过exists操作获得)。如果版本号不匹配则更新操作会失败。这两个更新操作是非阻塞操作,即一个更新失败的客户端可以决定是否重试或执行其他操作,并不会因此而阻塞其他客户端操作该同一个znode的进程的执行。虽然ZooKeeper可以看作一个文件系统,但是出于简单性,其中的文件较小而且总是被整体读写,因此没有必要提供打开、关闭和查找操作。不过ZooKeeper允许客户端读到的数据滞后于ZooKeeper服务的最新状态,所以需要用sync操作来获取数据的最新状态。
13.ZooKeeper中有个被称为集合更新的操作(Multiupdate),用于将多个基本操作集合成一个操作单元,并确保这些基本操作同时被成功执行,或者同时失败,不会发生其中部分操作成功执行而另一些操作失败的情况。集合更新可以用于在ZooKeeper中构建需要保持全局一致性的数据结构,例如构建无向图。在ZooKeeper中用一个znode来表示无向图中的一个顶点。为了在两个顶点之间添加或删除一条边,要同时更新两个顶点对应的两个znode,因为每个znode中都有指向对方的引用。将针对两个znode的更新操作集合到一个Multiupdate操作中可以保证这组更新操作的原子性,也就保证了一对顶点之间不会出现不完整的连接。
14.在exists、getChildren和getData等这些读操作上可以设置Watcher,这些Watcher可以被写操作create、delete和setData触发。ACL相关操作不参与触发任何Watcher。当一个Watcher触发时会产生一个Watcher事件,这个Watcher和触发它的操作共同决定着Watcher事件的类型:
(1)当所观察的znode被创建、删除或其数据更新时,设置在exists操作上的Watcher会被触发。
(2)当所观察的znode被删除或其数据更新时,设置在getData操作上的Watcher将被触发。
(3)当所观察的znode的一个子节点被创建或删除时,或者所观察的znode自己被删除时,设置在getChildren操作上的Watcher会被触发。可以通过Watcher事件的类型来判断被删除的是znode本身还是其子节点:NodeDelete类代表znode被删除,NodeChildrenChanged类代表一个子节点被删除。下表中列出了Watcher及其触发操作所对应的事件类型:
一个Watcher事件中包含涉及该事件的znode路径,因此对于NodeCreated和NodeDeleted事件来说,可通过路径来判断哪一个节点被创建或删除。为了能够在NodeChildrenChanged事件发生之后判断是哪些子节点被修改,需要重新调用getChildren来获取新的子节点列表。与之类似,为了能够在NodeDataChanged事件之后获取新的数据,需要调用getData操作。不过,从收到Watcher事件到执行读操作(getChildren或getData)期间,znode的状态可能会发生改变。
15.每个znode创建时都会带有一个访问控制列表(Access Control List),用于决定谁可以对它执行何种操作。ACL依赖于ZooKeeper的客户端身份验证机制。ZooKeeper提供了以下几种身份验证方式:
(1)digest,通过用户名和密码识别客户端。
(2)sasl,通过Kerberos识别客户端。
(3)ip,通过客户端的IP地址来识别客户端。
在建立一个ZooKeeper会话后,客户端可以对自己进行身份验证,以支持对znode的访问。例如使用digest方式(用户名与密码)进行身份验证,如下所示:
zk.addAuthInfo(“digest”, “tom:secret”.getBytes());
每个ACL都是身份验证方式、符合该方式的身份和一组权限的组合。例如给IP地址为10.0.0.1的客户端对某个znode的读权限,可使用ip验证方式、10.0.0.1和READ权限在该znode上设置一个ACL。Java中可以如下创建该ACL对象:
new ACL(Perms.READ, new Id(“ip”, “10.0.0.1”));
在类ZooDefs.Ids中有一些预定义的ACL,其中OPEN_ACL_UNSAFE就是其中之一,它将除ADMIN权限以外的所有权限授予所有客户端。下表列出了一个完整的权限集合,其中exists操作没有ACL权限控制,所有客户端都可以调用它查询一个znode的状态或是否存在:
16.ZooKeeper服务有两种运行模式:
(1)独立模式(stand-alone mode),即只有一个ZooKeeper服务器,该模式较简单适合测试环境,但不能保证高可用性和可恢复性。
(2)复制模式(replicated mode),生产环境基本使用该模式,即运行于一个集群上,该集群称为一个集合体(ensemble)。ZooKeeper通过复制来实现高可用性,只要集合体中半数以上机器处于可用状态,就能提供服务,例如6个节点集合体允许最多2台机器出现故障,5台机器也是。因此一个集合体中通常包括奇数台机器。
ZooKeeper集群更新数据的思想较为简单,就是确保对znode树的每一个修改都会被复制到集合体中超过半数的机器上。如果少于半数的机器出现故障,则至少有一台机器会保存最新状态(即执行改动的机器),其余机器上的副本也会更新到这个状态。不过具体的实现比较复杂,ZooKeeper使用了Zab协议,该协议包括两个可以无限重复的阶段:
(1)leader选举,集合体中的所有机器通过一个选择过程来选出一台称为leader的机器,其他的机器为follower。一旦半数以上(或指定数量)的follower已经将其状态与leader同步,则表明该阶段已经完成。
(2)原子广播,客户端发出的所有写请求都会被转发给leader,再由leader将更新广播给follower。当半数以上的follower已经将修改存入自己的本地磁盘后,leader才会提交该更新,客户端才会收到一个更新成功的响应。该协议被设计成具有原子性(即不可进一步再被拆分),因此每个修改要么成功要么失败,没有更加细分的其他状态。这类似于数据库中的两阶段提交协议。
如果leader出现故障,其余机器会选出另外一个leader,并和新的leader一起继续提供服务。随后,如果先前的leader恢复正常,会成为一个follower。选举过程很快,一般只需要大约200毫秒,不会出现系统性能的明显降低。ZooKeeper在处理较新的数据时都存储在内存中,在更新内存中的znode树之前,集合体中所有机器都会先将更新写入磁盘。任何一台机器都可以为读请求提供服务,而且由于读请求会到机器内存中进行数据检索,因此速度非常快。
17.一个follower上的数据副本可能滞后于leader几个更新操作。因此才会说在一个修改被leader提交表示完成之前,只需要集合体中半数以上机器已经将该修改操作的结果存入本地磁盘即可。对于请求查询的客户端来说,最理想的情况就是连接到与leader状态一致的服务器上,但客户端对此是不能控制的,也无法知道自己连接的是不是leader。当然可以设置使leader不接受任何客户端的连接,此时leader的唯一任务就是协调集群中的更新操作同步。可以将leaderServes属性设置为no来实现,在三台以上服务器的集群中最好使用该设置。ZooKeeper集群中读写操作的示意图如下所示:
每一个对znode树的更新操作都被赋予一个全局唯一的ID,称为zxid(ZooKeeper Transaction ID)。ZooKeeper要求对所有的更新操作进行编号并排序,它决定了分布式系统的操作执行顺序,如果操作z1的zxid小于z2,则z1一定发生在z2之前。
18.以下几点保证了集群中各节点上数据同步的一致性:
(1)顺序一致性。来自任意客户端的更新操作请求都会按发送顺序完成并提交。例如一个客户端将znode z的值更新为a,在之后的操作中又将z的值更新为b,则没有客户端能够在看到z的只是b之后再看到值a。
(2)原子性。每个更新操作要么成功,要么失败,即如果一个更新操作失败,不会有客户端看到该操作的结果数据。
(3)唯一系统结构映像。一个客户端无论连接到哪一台服务器,它看到的都是同样的集群结构视图。这意味着如果一个客户端在同一个会话中连接到一台新服务器,它看到的系统状态不会比在之前服务器上所看到的更老。当一台服务器故障,原本连接它的客户端转向连接其他服务器时,所有状态滞后于该故障服务器的节点都不会接受连接请求,除非这些节点将状态更新到故障服务器的水平。
(4)持久性。一个更新操作一旦成功,更新的数据结果就会持久存在于磁盘上并且不会被撤销。所以更新操作不会受到服务器故障的影响。
(5)及时性。任何客户端看到的系统结构视图的滞后程度都是有限的,不会超过几十秒。
为了保证查询性能,所有读操作都是从ZooKeeper服务器的内存获得数据,它们不参与写操作的全局排序。如果客户端之间使用ZooKeeper以外的机制进行通信,则客户端可能会发现它们看到的ZooKeeper状态不一致。例如客户端A将znode z的值从a更新为b,接着A告诉B去读z的值,而B读到的值却是a。为了避免该情况发生,B应该在读z的值之前对z调用sync操作,sync操作会强制B连接的ZooKeeper服务器数据状态更新到与leader上相同的最新状态。sync操作只能以异步的方式被调用,因此不需要等待sync调用的返回,ZooKeeper会保证任何后续操作都在服务器的sync操作完成后才执行,即使这些操作是在sync操作完成前发出的。
19.每个ZooKeeper客户端配置文件中都包括集群中服务器的列表,在启动时客户端会尝试连接到列表中的一台服务器,如果连接失败,它会尝试连接另一台服务器,直到成功与一台服务器建立连接或所有服务器都连不上而失败。一旦客户端与一台ZooKeeper服务器建立连接,该服务器会为该客户端创建一个新会话,每个会话都有超时时间设定(由创建会话的节点设置)。如果服务器在超时时间内没有收到任何请求,则相应会话会过期,过期会话无法重新被打开,相关短暂znode也会丢失。
只要一个通信会话(session)超过一定时间,客户端会自动间隔性地发送心跳包保持会话不过期,心跳包时间间隔的设置需要足够低,以便能通过读超时检测到服务器故障,并在会话超时时间段内能重新连接到另外一台服务器,且另一台服务器接替故障服务器后所有会话和短暂znode依然有效,即ZooKeeper集群会把Client的Session信息持久化,在Session没超时之前,Client与ZooKeeper Server的连接可以在各个ZooKeeper Server之间透明地移动。
在故障切换过程中,客户端会收到断开连接和连接到服务器的通知,当client断开连接时,Watcher通知将无法发送,但是当客户端恢复连接后,这些延迟没能发送到的通知依然会送达。当然,客户端在重新连接到另一台服务器的过程中,如果试图执行一个操作,该操作会失败。
20.在代码中,ZooKeeper对象在生命周期中会经历几种不同状态,可以在任何时刻通过getState()方法查询对象的状态:
public States getState()
States被定义成代表ZooKeeper对象不同状态的枚举类型值,一个ZooKeeper对象实例在一个时刻只能处于一种状态。在与ZooKeeper服务建立连接的过程中,一个新建的ZooKeeper对象实例处于CONNECTING状态,一旦建立连接就会进入CONNECTED状态。各状态的转换示意图如下所示:
根据上图,ZooKeeper实例可以断开然后重连到ZooKeeper服务,此时它的状态会在CONNECTED和CONNECTING之间切换,如果断开连接,则Watcher会收到一个Disconnected事件。如果close()方法被调用或会话超时(Watcher事件的KeeperState值为Expired)时,ZooKeeper实例会转换到CLOSED状态,此时ZooKeeper对象实例不能再被调用,如果要重新连接到ZooKeeper服务只能通过new创建一个新的ZooKeeper对象实例。
通过注册Watcher对象,使用了ZooKeeper对象的客户端就可以收到状态转换通知,一旦进入CONNECTED状态,Watcher对象就会收到一个WatchedEvent通知,其中KeeperState的值就是上面代码中提到过的SyncConnected。ZooKeeper的Watcher对象有双重责任:
(1)用于获得ZooKeeper状态变化的相关通知。传递给ZooKeeper对象构造函数的默认Watcher用于监视其状态变化。
(2)用于获得znode变化的相关通知。监视znode的变化可以使用一个专用Watcher对象将其传递给适当的读操作,也可以通过读操作中的bool标识来设定是否共享使用默认的Watcher。
四、构建应用与服务
21.配置服务是分布式应用所需要的基本服务之一,它使集群中的机器可以共享配置信息中公共的部分。ZooKeeper可以作为一个具有高可用性的配置信息存储器,允许分布式应用各节点查询和更新配置文件,在其中使用Watcher机制,可以建立一个激活的配置服务,使客户端能获得配置信息修改的通知。例如下面的配置服务代码示例,该代码假设处于一个简单场景中,即配置数据是字符串,要被配置的键是znode的路径,所以在每个znode上存储一个键值对,而且在任何时候只有一个客户端会执行更新操作:
package ZooKeeper;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooDefs;
import org.apache.zookeeper.data.Stat;
import java.nio.charset.Charset;
public class ActiveKeyValueStore extends ConnectionWatcher { //存储键值对形式配置项数据的配置服务,ConfigUpdater的辅助类
private static final Charset CHARSET=Charset.forName("UTF-8");
public void write(String path,String value) throws InterruptedException, KeeperException { //将以znode路径为键的配置项和字符串为值的配置数据写入ZooKeeper
//确定指定路径是否存在特定znode的对象,如果watch标志为true,则一旦该znode状态改变,关联的Watcher会被触发(例如组成员加入、退出和组被删除的通知),这里没有使用
Stat stat=zk.exists(path,false);
if(stat==null){ //如果不存在指定路径上的znode,则在对应路径创建一个不限访问和修改的持久znode,并在znode中写入序列化后的字节数组形式的配置项属性值
zk.create(path,value.getBytes(CHARSET), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}else{ //如果指定路径上的znode存在,则在znode中更新序列化后的字节数组形式的配置项属性值,匹配所有同名的znode所有版本
zk.setData(path,value.getBytes(CHARSET),-1);
}
}
public String read(String path, Watcher watcher) throws InterruptedException,KeeperException{ //读取路径为/config的znode上的配置项属性值
byte[] data=zk.getData(path,watcher,null/*stat*/); //stat对象用于将状态信息元数据随着znode数据一起回传,这里不需要元数据则设为null
return new String(data,CHARSET); //通过指定的字符编码将字节数组还原为字符串数据返回,因为ZooKeeper中数据会序列化为字节数组后再存储以节省空间和便于网络传输
}
}
上面是一个辅助类,下面代码才是实际执行随机更新ZooKeeper中的配置项属性值:
package ZooKeeper;
import org.apache.zookeeper.KeeperException;
import java.io.IOException;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class ConfigUpdater { //随机更新ZooKeeper中的配置项属性值,与ActiveKeyValueStore类配合
public static final String PATH="/config";
private ActiveKeyValueStore store;
private Random random=new Random();
public ConfigUpdater(String hosts) throws IOException,InterruptedException{ //新建该对象实例时会自动调用该构造函数
store=new ActiveKeyValueStore(); //新建ActiveKeyValueStore对象,它继承ConnectionWatcher类中的方法
store.connect(hosts); //调用ConnectionWatcher类中的connect()方法连接指定的ZooKeeper对象
}
public void run() throws InterruptedException, KeeperException {
while(true){ //永远循环,在随机事件以随机值更新路径为/config的znode中存储的配置项属性值
String value=random.nextInt(100)+""; //产生0到100内的随机值
store.write(PATH,value); //将路径键和随机配置项属性值写入对应znode
System.out.printf("Set %s to %s\n",PATH,value);
TimeUnit.SECONDS.sleep(random.nextInt(10)); //随机休眠0到10秒之间
}
}
public static void main(String[] args) throws Exception{
ConfigUpdater configUpdater=new ConfigUpdater(args[0]); //新建ConfigUpdater对象并连接命令行第一个参数指定的ZooKeeper节点
configUpdater.run();
}
}
下面的代码是以用户的身份查看ZooKeeper中配置属性的更新情况,并打印到控制台:
package ZooKeeper;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import java.io.IOException;
public class ConfigWatcher implements Watcher { //观察ZooKeeper中配置项属性的更新情况,并将其打印到控制台
private ActiveKeyValueStore store;
public ConfigWatcher(String hosts) throws IOException,InterruptedException{
store=new ActiveKeyValueStore(); //新建ActiveKeyValueStore对象,它继承ConnectionWatcher类中的方法
store.connect(hosts); //调用ConnectionWatcher类中的connect()方法连接指定的ZooKeeper对象
}
public void displayConfig() throws InterruptedException, KeeperException { //打印读取到的配置项属性值
String value=store.read(ConfigUpdater.PATH,this); //调用ActiveKeyValueStore类的read()方法,并将ConfigWatcher自身作为Watcher,用于监控配置项属性值的改动并触发打印
System.out.printf("Read %s as %s\n",ConfigUpdater.PATH,value); //打印/config路径下znode的配置项属性值,每被Wathcer触发一次就打印一次
}
@Override
public void process(WatchedEvent event) { //Watcher类中包含的唯一方法,当znode的数据被更新改动后(NodeDataChanged事件),该方法会被触发,参数是触发Watcher的事件
if(event.getType()== Event.EventType.NodeDataChanged){ //当ConfigUpdate类更新znode时,会产生NodeDataChanged事件,从而触发Watcher,即触发process()
try{
displayConfig(); //读取并显示配置属性项被改动后的值
}catch(InterruptedException e){
System.err.println("Interrupted. Exiting.");
Thread.currentThread().interrupt();
}catch(KeeperException e){
System.err.printf("KeeperException: %s. Exiting.\n",e);
}
}
}
public static void main(String[] args) throws Exception{
ConfigWatcher configWatcher=new ConfigWatcher(args[0]); //新建ConfigWatcher对象并连接命令行第一个参数指定的ZooKeeper节点
configWatcher.displayConfig();
//通过线程休眠来模拟正在做某种工作,保持线程占用直到该进程被强行终止,进程被终止后,短暂znode会被ZooKeeper删除
Thread.sleep(Long.MAX_VALUE);
}
}
由于注册一次Watcher只会发送一次触发信号,所以每次调用ActiveKeyValueStore类的read()方法时,都再次注册一个新的Watcher告知ZooKeeper,以确保下一次依然可以收到触发事件。即使这样依然不能保证一定打印出每一次配置项属性值的更新,因为在收到Watcher事件通知与进行读取的时间段内,znode可能已经被快速更新过多次,由于客户端在这段时间内没有注册任何Watcher,因此不会收到触发通知。对于上面的例子倒不是问题,因为上面程序的应用场景用户只关心配置项属性的最新值,但是一般其他情况下这里可能会是个小bug。
写好程序后,将上面的几个程序一起打包为config.jar用于运行命令。为了配合运行上面更新配置项和读取配置项更新的两个程序,需要打开两个命令行终端,首先在一个命令行中运行执行ConfigUpdater的命令:
java -cp .:/mnt/sda6/config.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.ConfigUpdater localhost
紧接着在另一个终端窗口马上运行ConfigWatcher的命令:
java -cp .:/mnt/sda6/config.jar:$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/*:$ZOOKEEPER_HOME/conf ZooKeeper.ConfigWatcher localhost
两个终端窗口的命令运行后,分别会产生以下输出:
22.ZooKeeper在真实网络中运行时,由于网络环境复杂,往往会出现各种各样的故障。因此在程序编写中,每一个ZooKeeper操作方法都会在方法定义后的throws子句中声明两种类型的异常:
(1)InterruptedException异常:如果操作被中断,该异常会被抛出。Java中有一个取消长时间执行方法(如wait(),sleep()和join()等)的标准机制,即对运行长时间执行方法的线程调用interrupt()来中断执行过久的方法(实质上interrupt()没有自己真的去中断,只是改变了中断状态的值,而长时间方法的执行线程走到sleep()、wait()和join()等地方时,这几个方法的内部才会不断检查中断状态的值,从而自己抛出InterruptedException,如果方法的定义中没有调用这几个方法,或者没有在线程中自己检查中断状态的值,即使使用interrupt()也不会抛出InterruptedException)。
(2)KeeperException异常:如果ZooKeeper服务器发出一个错误信号或与服务器存在通信问题,抛出的则是KeeperException异常。针对不同错误情况,该异常有不同的子类,例如如果针对一个不存在的znode进行操作,就会抛出KeeperException.NoNodeException异常。每个KeeperException异常的子类都对应一个关于错误类型信息的码,例如KeeperException.NoNodeException异常的代码是KeeperException.Code.NONODE枚举值。
有两种方法可以用来处理KeeperException异常:
(1)捕捉该异常并且通过检测它的码来决定采取什么措施。
(2)捕捉等价的KeeperException子类并在每段捕捉代码中执行相应操作。
23.KeeperException异常分为三大类:
(1)状态异常。当一个操作不能被应用于znode树而失败时,就会出现该异常,产生原因通常是同一时间有另外一个进程正在修改znode。例如如果一个znode先被另外一个进程更新了,根据版本号执行setData()操作时进程就会失败,并收到KeeperException.BadVersionException异常,因为版本号不匹配。一些状态异常会指出程序中的错误,例如KeeperException.NoChildrenForEphemeralsException异常,如果试图在短暂znode下创建子节点就会抛出该异常。
(2)可恢复异常。指应用程序能够在同一个ZooKeeper会话中恢复的异常,用KeeperException.ConnectionLossException来表示,它意味着已经丢失了与ZooKeeper服务器的连接。ZooKeeper会尝试重新连接并在大多数情况下重连成功,并确保会话完整。
但是ZooKeeper不能判断与KeeperException.ConnectionLossException异常相关的重连之前的操作是否成功重新执行,此时就需要区分该操作是幂等(idempotent)还是非幂等(Nonidempotent)操作。幂等操作指多次执行都会产生相同结果的操作,例如读请求或无条件执行的setData()操作,对于这种操作只需要简单地重试即可。而非幂等操作就不能盲目再次重试,因为这种操作多次执行的结果完全不同,此时程序可以在znode的路径和它的数据中编码信息来检测是否非幂等操作的更新过程已完成。
(3)不可恢复异常。在一些情况下ZooKeeper通信会话会失效,比如因为超时或因为会话被关闭(这两种都会收到KeeperException.SessionExpiredException异常)、因为身份验证失败(KeeperException.AuthFailedException异常)等。上述情况下所有与会话相关联的短暂znode都会丢失,此时程序需要重连到服务器后重建它的状态。
24.在上面的ActiveKeyValueStore类中,write()方法是一个幂等操作,所以在服务器重连后可以直接进行操作重试,下面修改后的该方法代码能够循环执行重试:
public void write(String path,String value) throws InterruptedException, KeeperException { //将以znode路径为键的配置项和字符串为值的配置数据写入ZooKeeper
int retries=0;
int MAX_RETRIES=3; //最大重试次数
int RETRY_PERIOD_SECONDS=10; //每次重试的间隔时间
while(true){ //由于下面是幂等操作,在与服务器断开连接重连后,可以进行循环重试
try{
//确定指定路径是否存在特定znode的对象,如果watch标志为true,则一旦该znode状态改变,关联的Watcher会被触发(例如组成员加入、退出和组被删除的通知),这里没有使用
Stat stat=zk.exists(path,false);
if(stat==null){ //如果不存在指定路径上的znode,则在对应路径创建一个不限访问和修改的持久znode,并在znode中写入序列化后的字节数组形式的配置项属性值
zk.create(path,value.getBytes(CHARSET), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}else{ //如果指定路径上的znode存在,则在znode中更新序列化后的字节数组形式的配置项属性值,匹配所有同名的znode所有版本
zk.setData(path,value.getBytes(CHARSET),-1);
}
return;
}catch(KeeperException.SessionExpiredException e){
throw e;
}catch(KeeperException e){
if(retries++==MAX_RETRIES){ //如果重试次数达到最大次数,则抛出异常
throw e;
}
TimeUnit.SECONDS.sleep(RETRY_PERIOD_SECONDS); //每次重连并重试操作后都间隔一段时间
}
}
}
上面的代码没有在KeeperException.SessionExpiredException异常处进行重试,因为当一个会话过期时,ZooKeeper对象实例会进入CLOSED状态,此时不能再进行重连。所以这种情况只能抛出异常并且使程序重新创建一个新的ZooKeeper实例来重新执行整个write()方法。下面修改的ConfigUpdater的main()主函数代码通过创建新的ConfigUpdater类实例的方法来创建新ZooKeeper实例恢复已经过期关闭的会话:
public static void main(String[] args) throws Exception{
while(true){
try{
ConfigUpdater configUpdater=new ConfigUpdater(args[0]); //新建ConfigUpdater对象并连接命令行第一个参数指定的ZooKeeper节点
configUpdater.run();
}catch(KeeperException.SessionExpiredException e){ //会话过期被关闭将无法再重连该会话,只能新创建一个会话
//start a new session
}catch(KeeperException e){
//already retried, so exit
e.printStackTrace();
break;
}
}
}
除了上面抛出异常的方式,另一种方式是只使用一段用于捕捉KeeperException异常的代码,然后检测所捕获异常的值是否为KeeperException.Code.SESSIONEXPIRED。处理会话过期的另一种方式是在Watcher类中(例如上面的ConnectionWatcher类)监测类型为Expired的KeeperState,然后监测到是否创建一个连接,所以即使收到KeeperException.SessionExpiredException异常,由于连接可以通过这种方式重新建立,因此依然可以使用这种方式在write()方法内不断重试。
当ZooKeeper对象被创建时,它会尝试连接一个集群中的服务器,如果连接失败或超时,它会尝试连接另一台服务器,如果集群中所有服务器都无法连接,就抛出IOException异常。除了上面的重试策略,还有例如“指数退回”(exponential backoff)策略,每次将重试的间隔乘以一个常数变为新间隔。
25.分布式锁能够在一组进程之间提供互斥机制,在执行某个任务时使任何时候只有一个进程可以持有锁。分布式锁可以用于在大型分布式系统中实现leader选举,在任何时间点,持有锁的那个进程就是系统中针对某个应用程序的leader(这里的leader选举不是ZooKeeper集群节点的leader选举,而是针对特定计算与存储任务的znode而言)。为了使用ZooKeeper实现分布式锁服务,需要使用顺序znode来为竞争锁的进程强制排序,即首先指定一个作为锁的znode,用它来描述被锁定的实体,称为/leader;然后希望获得锁的客户端创建一些短暂顺序znode,作为具有锁的znode的子节点。在任何时间点,顺序号最小的客户端将持有锁。
通过删除顺序号最小的具有锁的znode(如/leader/lock-1)即可将锁释放,那么次小顺序号的znode会获得锁。通过创建一个关于znode删除的Watcher,可以使客户端在获得锁时收到通知。申请锁的步骤算法可以如下安排:
(1)在拥有锁的znode下创建一个名为lock-x的短暂顺序znode,并且记住它的实际路径名(create()操作返回值)。
(2)查询拥有锁的znode的子节点并设置一个Watcher。
(3)如果步骤(1)中创建的子znode在所有子节点中具有最小的顺序号,则获取锁。
(4)等待步骤(2)中的Watcher的通知,继续回到步骤(2)。
虽然该算法步骤正确,但依然存在羊群效应(herd effect)的问题,即集群客户端众多时,所有客户端都在尝试获得锁,所以每个客户端都会在拥有锁的znode上设置一个Watcher,用于捕捉子节点的变化。每次锁被释放或一个新进程开始申请锁时,所有的Watcher都会被触发而且每个客户端都会收到一个通知,此时实际上只有少部分的客户端需要处理该事件,也只有一个客户端能获得锁,但是通信过程以及向所有客户端发送Watcher事件会造成负载较高的峰值大流量,对服务器造成很大压力。
为了避免出现羊群效应,关键在于仅当前顺序号的子节点消失时才需要通知下一个顺序号znode的客户端,而不是删除或创建任何子节点时都要进行通知。例如只有当/leader/lock-2的znode消失时才需要通知/leader/lock-3的znode对应的客户端,而/leader/lock-1消失或/leader/lock-4加入时,不需要通知/leader/lock-3的客户端。
26.上面申请锁的算法的第二个问题,就是不知道连接丢失前执行的create()创建子znode操作是否成功。因为创建一个顺序znode是非幂等操作,每次创建的顺序号不同,所以不能直接重试该操作,否则如果实际上子znode创建成功了,重试create()会多创建一个在客户端会话结束前无法删除的孤儿znode,最不好的情况下还会发生死锁。解决该问题的方法是在znode名称中加入session的ID,如果客户端与服务器的连接丢失,重连后可以对拥有锁的znode的所有子znode进行检查,如果一个子znode的名称包含上次session的ID,说明连接断开前的create()操作已成功,不需要再重试create()操作,如果没有包含上次session的ID,客户端可以安全地通过create()创建一个新的顺序子znode。
客户端会话的ID是一个长整数,在ZooKeeper服务中是唯一的,所以很适合用于区分各会话创建的znode是否存在,可以调用ZooKeeper类中的getSessionId()方法来获得session ID。在创建短暂顺序znode时应采用lock-<sessionId>-<sequenceNumber>的方式。这种方式可以让子节点在保持创建顺序的同时能够确定自己在哪个session被创建。
27.如果一个客户端的ZooKeeper会话过期,它所创建的短暂znode将会被删除,已持有的锁会被释放,或者是放弃了申请锁的位置。先前持有锁的程序应当意识到它已经不再持有锁,应当清理自己的状态,然后创建并尝试申请一个新的锁对象来重新启动。该过程必须由程序自己设计好,锁本身不能预知程序需要如何清理自己的状态。ZooKeeper带有一个锁实现,名为WriteLock。同时ZooKeeper本身也有一些分布式数据结构和协议的实现(如锁、leader选举和队列),在$ZOOKEEPER_HOME/recipes目录下。
28.在安放ZooKeeper所用机器时,应考虑尽量减少机器和网络故障可能带来的影响,一般会跨机架、电源和交换机来安放服务器,这样设备中的任何一个出现故障都不会导致集群损失半数以上的服务器。而对于需要低延迟(毫秒级别)服务的应用来说,最好将所有服务器都放在同一个数据中心的集群中,一些不需要低延迟服务的应用例如leader选举和分布式粗粒度锁等可以跨数据中心(每个数据中心至少两台)安放服务器来获得更好的可恢复性,因为这两种应用的状态改变都相对较少,所以数据中心之间传递状态改变消息所需的几十毫秒开销是可以承受的。
Zookeeper集群中有个观察节点(observer node),指没有投票权的follower节点。由于观察节点不参与写请求过程中的投票,因此使用观察节点可以使集群在不影响写性能的情况下提高读操作的性能。使用观察节点可以使集群跨越多个数据中心,同时不会增加正常投票节点的延迟,可以通过将投票节点放在一个数据中心,观察节点放在另一个数据中心来实现。ZooKeeper应当运行在专用机器上,如果有其他应用程序一起竞争硬件资源,可能会导致ZooKeeper性能明显下降。
29.通过对ZooKeeper的配置文件进行配置,可以使它的事务日志和内存数据快照分别保存在不同的磁盘目录下。默认情况下两者都保存在.cfg文件中的dataDir配置项指定的目录中,但是如果另外指定dataLogDir属性到另一个目录,就可以将事务日志写在指定的位置。通过指定一个单独的磁盘,可以用最大写入速率将日志记录写到磁盘,因为写日志是顺序写,没有寻址操作。由于所有的写操作都是leader完成的,增加服务器不能提高写操作的吞吐量,所以提高性能的关键是写操作的速度。如果写操作的进程被swap到磁盘上,性能会受到不利影响,避免该情况可以将Java堆的大小设置为小于机器上空闲的物理内存即可。ZooKeeper可以从它的配置文件目录中找到一个名为java.env的文件,该文件被用来设置JVMFLAGS环境变量,包括设置Java堆的大小和其他的JVM参数。
30.ZooKeeper服务器的集群中,每个服务器都有一个数值型的ID,服务器ID在集群中是唯一的,并且取值范围在1到255之间,可以在dataDir指定的目录中放置一个名为myid的文本文件来写入该服务器的ID,正如一开始安装配置时那样。当一个ZooKeeper服务器启动时,它读取myid文件用于确定自己的服务器ID,然后通过读取配置文件来确定自己应当在哪个端口进行客户端监听,同时确定集群中其他服务器的网络地址。