这部分内容帮助开发者学习ZooKeeper编程技巧,掌握在分布式程序中使用ZooKeeper进行协同的正确方法。ZooKeeper API包括Java和C版本。两个版本的基本结构和方法签名都是类似的。Java版最流行,最易于使用,所以我们用它讲解示例(第七章介绍C版本)。Master-Worker例子的源码位于GitHub Repository。
前一章使用zkCli介绍了ZooKeeper的基本操作。本章开始在应用程序中使用ZooKeeper API,我们会介绍通过API创建会话和设置wather的方法,也会开始编码实现Master-Worker示例。
运行和编译ZooKeeper的Java代码都需要正确设置CLASSPATH。除了ZooKeeper自身Jar包,ZooKeeper还使用了一系列第三方库。为了减少输入,增加可读性,我们用环境变量CLASSPATH代表这些库。ZooKeeper分发包bin目录下的zkEnv.sh脚本中设置了CLASSPATH环境变量,执行:
ZOOBINDIR="/bin"
source "$ZOOBINDIR"/zkEnv.sh
ZooKeeperAPI都围绕着ZooKeeper句柄,句柄代表一次ZooKeeper会话。如图3-1所示,当连接中断时,和某台ZooKeeper服务器建立的会话会自动重连到另外的服务器上。只要会话还存活,这个句柄就有效,而且ZooKeeper客户端库会持续维护和服务器的活动连接来保证会话的存活性。如果句柄被关闭,ZooKeeper客户端库会告诉服务器终止会话。如果ZooKeeper服务端认为客户端已经挂掉,它也会废弃会话。如果客户端再使用这个废弃会话对应的句柄重新连接服务器,服务器会告知客户端会话已经被废弃,所有的句柄操作都返回错误。
Figure 3-1. Sessionmigration between two servers
创建一个ZooKeeper句柄的构造函数是:
ZooKeeper(
String connectString,
int sessionTimeout,
Watcher watcher)
其中:
connectString:
ZooKeeper服务器的主机名和端口号。之前我们用zkCli连接ZooKeeper服务时列出了服务器信息。
sessionTimeout:
ZooKeeper声明会话废弃之前没有接收到客户端消息的时间,以毫秒计算。我们使用15000,也就是15s。这意味着如果ZooKeeper超过15s都没有连接上客户端,它就会终止客户端会话。注意这个时间偏大,适用于我们的实验性程序。典型的ZooKeeper会话超时时间是5-10s。
watcher:
watcher对象用于接收会话事件。因为Watcher是接口,所以我们需要实现它,并将实例化对象传递给ZooKeeper构造函数。客户端使用这个watcher监控会话状态,和某台ZooKeeper服务器连接建立或连接丢失时都会触发事件。Watcher也能用于监控ZooKeeper数据变化。最重要的是,当会话过期时,客户端会收到通知事件。
实现watcher,就能收到ZooKeeper发出的通知事件。进一步查看Watcher接口:
public interface Watcher {
void process(WatchedEvent event);
}
接口很简单。我们以后会重度使用它,现在仅仅打印event。开始实现我们的示例Master:
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.Watcher;
public class Master implements Watcher {
ZooKeeper zk;
String hostPort;
Master(String hostPort) {
this.hostPort= hostPort;1
}
void startZK(){
zk = new ZooKeeper(hostPort, 15000, this);2
}
public void process(WatchedEvent e) {
System.out.println(e);3
}
public static void main(String args[])throws Exception {
Master m = new Master(args[0]);
m.startZK();
// wait for a bit
Thread.sleep(60000);4
}
}
1. 构造函数中没有实例化ZooKeeper对象,仅仅保存了端口号。Java的最佳实践是在某个对象的构造函数完成之前,不应该去调用这个对象的其他方法。因为Master实现了Watcher接口,而实例化ZooKeeper对象时,Watcher的回调方法会被调用,所以我们必须在Mater构造函数返回之后再构造ZooKeeper对象。
2. 使用Master对象作为Watcher构造ZooKeeper对象。
3. 这个简单示例不做复杂事件处理,仅仅打印出事件。
4. 连接上ZooKeeper后,会话由一个后台线程维持。这个线程是守护线程,如果程序退出,线程也会立刻退出。我们调用sleep方法,才能在程序退出之前观察到发生的事件。
编译程序:
$ javac -cp $CLASSPATH Master.java
运行编译好的Master类:
$ java -cp $CLASSPATH Master 127.0.0.1:2181
... - INFO [...] - Client environment:zookeeper.version=3.4.5-1392090, ...1
...
... - INFO [...] - Initiating client connection,
connectString=127.0.0.1:2181 ...
... - INFO [...] - Opening socket connection to server
localhost/127.0.0.1:2181. ...2
... - INFO [...] - Socket connection established tolocalhost/127.0.0.1:2181,
initiating session
... - INFO [...] - Session establishment complete on server
localhost/127.0.0.1:2181, ...3
WatchedEvent state:SyncConnected type:Nonepath:null 4
ZooKeeper客户端API会输出各种日志消息帮助用户理解正在发生的事情。尽管日志相当冗长,可以通过修改配置文件停用,但这些日志在开发过程中是非常有用的,甚至在部署到正式环境之后,如果出现异常,也非常有价值。
1. 一开始的少量日志输出ZooKeeper客户端版本和环境变量。
2. 这部分日志产生于客户端连接上某台ZooKeeper服务器,包括初始连接和失败重连。
3. 这部分日志产生于连接建立之后,含有客户端连接上的主机和端口,以及协商后的会话超时时间。如果客户端设置的会话超时时间太短或太长,服务器端都会作出调整。
4. 最后一行不是来自于ZooKeeper库,而是我们在Wacher.process(WatchedEvent e)中打印的WatchEvent对象。
这个例子假设所有需要的库都在lib目录下,log4j.conf配置文件在conf目录下。如果你在日志中发现:
log4j:WARN No appenders could be foundfor logger
(org.apache.zookeeper.ZooKeeper).
log4j:WARN Please initialize the log4jsystem properly.
这代表log4j.conf文件没有被放在CLASSPATH下。
让我们试试看在没有启动ZooKeeper服务的情况下运行Master会发生什么?先停止服务,然后运行Master。之前日志中输出的WatchedEvent事件没有出现,因为ZooKeeper库连接不上服务器。
现在启动ZooKeeper服务,运行Master,然后在Master运行时停止服务。你会在SyncConnected事件之后发现Disconnected事件。
当一些开发者看到Disconnected事件,认为需要再创建一个ZooKeeper句柄重新连接服务。千万不要这样做!再次启动ZooKeeper服务,运行Master,然后在保持Master运行时停止服务,仍然是SyncConnected事件之后出现Disconnected事件,不仅如此,还会再出现一次SyncConnected事件,因为ZooKeeper客户端库会自动重连。虽然断网和服务器崩溃时时有发生,但ZooKeeper通常都能处理这些异常。
需要重点关注的是,ZooKeeper服务本身也会出现异常,比如ZooKeeper服务器会崩溃或者断网,这和我们模拟的停止服务场景效果类似。只要ZooKeeper服务由三台以上的服务器组成,一台服务器的崩溃就不会导致整个服务停止。客户端会观察到Disconnected事件和紧随其后的SycConnected事件。
表面上看起来,客户端除了sleeping之外没有太多其他行为,但从我们观察到的event事件来看,幕后发生了不少事情。可以通过ZooKeeper提供的两个主要管理接口:JMX和四字母命令(four-letter words)来查看服务端情况。这些接口将在第十章深入讨论。我们先试用下stat和dump。
使用这些命令,首先要telnet远程登陆到2181端口,然后输入命令回车运行。比如,如果我们运行Master,使用stat命令:
$ telnet 127.0.0.1 2181
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
stat
ZooKeeper version: 3.4.5-1392090, built on 09/30/2012 17:52 GMT
Clients:
/127.0.0.1:39470[1](queued=0,recved=3,sent=3)
/127.0.0.1:39471[0](queued=0,recved=1,sent=0)
Latency min/avg/max: 0/5/48
Received: 34
Sent: 33
Connections: 2
Outstanding: 0
Zxid: 0x17
Mode: standalone
Node count: 4
Connection closed by foreign host.
从输出中可以看到,两个客户端连接上了ZooKeeper。其中之一是Master,另外一个是Telnet连接本身。
使用dump命令:
$ telnet 127.0.0.1 2181
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
dump
SessionTracker dump:
Session Sets (3):
0 expire at Wed Nov 28 20:34:00 PST2012:
0 expire at Wed Nov 28 20:34:02 PST2012:
1 expire at Wed Nov 28 20:34:04 PST 2012:
0x13b4a4d22070006
ephemeral nodes dump:
Sessions with Ephemerals (0):
Connection closed by foreign host.
可以看到其中有一个属于Master的活动会话,还能够看到该会话的过期时间,这个过期时间基于创建ZooKeeper对象时指定的会话超时时间计算得来。
终止Master进程,然后反复用dump观察活动会话。你会发现一段时间之后会话才消失。这是因为服务端在会话超时之前不会终止会话。另外,只要客户端还维护着和ZooKeeper服务器之间的活动连接,就会不停延后会话的过期时间。
当Master结束,最好通过调用ZooKeeper.close()方法,立即停止会话。调用close方法后,ZooKeeper对象代表的会话被清理。
在示例程序中加入close方法:
void stopZK() throws Exception { zk.close();}
public static void main(String args[])throws Exception {
Master m = new Master(args[0]);
m.startZK();
// wait for a bit
Thread.sleep(60000);
m.stopZK();
}
再次运行Master,然后使用dump命令来观察会话是否还处于活动状态。因为Master显式关闭了会话,所以ZooKeeper不必等到会话超时才关闭它。
Master创建会话后,需要获取控制权。务必要小心的是,只能同时存在一个活动Master。而且还需要有多个备用Master进程运行,在活动Master崩溃时能接管控制权。
为了确保在同一时间只有一个活动Master进程,我们使用第二章“Master角色”中描述的简单Leader选举算法:所有潜在的Master都试图创建/master节点,只有唯一的一个进程能够成功,成为活动Master。
创建/master节点需要做两件事情。首先是初始化节点数据,通常初始化数据中都包含Master进程的相关信息。我们用随机的ServerID标记每个进程,用作初始化数据。其次,我们需要指定新节点的ACL(访问控制列表)。ZooKeeper一般运行在可信环境中,可以使用开放的ACL。
ZooDefs.Ids.OPEN_ACL_UNSAFE常量表示任何用户都对节点拥有所有的权限。(顾名思义,在非可信环境下,这是极不安全的。)
ZooKeeper提供了节点粒度的可插拔式ACL认证机制,如果有必要,可以具体限定到谁能访问某个节点,能对某个节点做什么类型的操作。在我们的简单例子中,使用OPEN_ACL_UNSAFE就足够了。 我们还希望如果活动Master挂掉,/master节点也被删除。在第二章“持久化和临时节点”章节中介绍了临时节点。将/master节点设置成临时节点,这样会话被关闭或者会话失效后,/master节点会被ZooKeeper自动删除。
增加以下代码:
String serverId = Integer.toHexString(random.nextInt());
void runForMaster() {
zk.create("/master",1
serverId.getBytes(),2
OPEN_ACL_UNSAFE,3
CreateMode.EPHEMERAL);4
}
1. 如果/master节点已经存在,create操作会失败。我们还把代表服务器的唯一ID存储为/master节点的数据。
2. 节点数据只能是字节数组类型,所以把整数转换成字节数据。
3. 如前所述,使用开放ACL。
4. 创建临时节点。
我们还需要处理create操作抛出的两种异常:KeeperException和InterrupttedException。特别是要正确处理ConnectionLossException(KeepException的子类)和InterrupttedException。对于其他异常,可以选择放弃该操作或继续,但在这两种异常情形下,create操作可能是成功的,实际上我们已经成为Master。
ConnectionLossException发生在客户端和ZooKeeper服务器失去连接情况下。通常都是由网络错误引起的,比如网络分割或某台ZooKeeper服务器异常。当异常发生时,客户端不知道请求丢失是发生在ZooKeeper服务器处理之前,还是处理之后但客户端没有收到响应。前面提到过,ZooKeeper客户端库会为后续请求重建连接,但是进程必须判断未决请求是否已经被处理,是否需要重新发起请求。
InterrupttedException是被客户端线程调用Thread.interrupt触发。这通常是程序关闭动作中的一部分,但也可能不是,这取决于应用的具体实现。异常会中断本地客户端请求处理过程,使请求处于未知状态。
因为这两种异常都打断了正常的请求处理过程,开发者不能对请求处理状态作出任何假设。在处理异常时,必须先确定系统当前状态。比如在Leader选举中,不能实际上已经被选中却不自知。也就是说,如果进程create操作成功了,但是它自身却不知道已经取得控制权,那么除非该进程死亡,其他进程都不能成为Master,这回导致没有进程充当Master角色。
处理ConnectionLossException时,先要找出是否已经有进程成功创建了/master节点。如果该进程碰巧是自己,就开始充当Leader。我们使用getData方法:
byte[] getData(
String path,
bool watch,
Stat stat)
其中:
path
和大部分ZooKeeper方法一样,第一个参数都是节点路径,我们从这个节点查询数据。
watch
表示是否监视节点数据变化。如果设置为true,会通过创建ZooKeeper句柄时设置的Watcher对象接收事件。getData还有一个支持设置Watcher对象参数的重载方法。在下一章中我们会介绍如何监视数据变化,现在仅仅想获取当前数据,所以将watch参数设置为false。
stat
用于填充节点的元数据
返回值
如果返回成功(没有抛出异常),节点数据以字节数组格式返回。
在runForMaster方法代码中加入异常处理片段:
String serverId = Integer.toString(Random.nextLong());
boolean isLeader = false;
// returns true if thereis a master
boolean checkMaster() {
while (true) {
try {
Stat stat = new Stat();
byte data[] = zk.getData("/master", false, stat);1
isLeader = new String(data).equals(serverId));2
return true;
} catch (NoNodeException e) {
// no master, so trycreate again
return false;
} catch (ConnectionLossException e) {
}
}
}
void runForMaster() throws InterruptedException {3
while (true) {
try {4
zk.create("/master", serverId.getBytes(),
OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);5
isLeader = true;
break;
} catch (NodeExistsException e) {
isLeader = false;
break;
} catch (ConnectionLossException e) {6
}
if (checkMaster()) break;7
}
}
4.使用try…catch块捕获zk.create抛出的ConnectionLossException。
5.如果create操作成功,进程成为Master
6.catch到ConnectionLossException异常后不做任何处理,继续运行下一行代码
7.检查是否存在活动master,如果不存在,重试create操作。
1.获取/master节点数据,检查当前活动Master。
2.这行代码解释了我们为什么在创建/master节点时使用serverID数据:如果/master节点存在,通过查看其数据就可以判断谁是Leader。如果当前进程触发了ConnectionLossException,它可能实际上已经成为Master;可能create操作成功了,丢失的只是响应消息。
3.直接抛出InterruptedException异常给调用者。
在这个例子中,直接将InterruptedException抛给调用者,并继续往上传递。不幸的是,Java中对如何处理线程中断、中断代表的含义都没有明确的指导原则。有时候,中断用来向线程发送信号,告诉它需要在程序关闭之前进行清理动作。另外一些场景下,中断用来控制某个具体线程的动作,而程序继续保持运行。
如何处理InterruptedException异常取决于上下文。如果InterruptedException沿堆栈往上传递,所有清理动作正常进行,最终关闭zk句柄,是可行的。如果zk句柄没有被关闭,我们需要在重新抛出异常或者继续异步操作之前,判断进程是否已经是Master。这种情况比较棘手,要求仔细设计处理好。
Master类的main方法修改为:
public static void main(String args[])throws Exception {
Master m = new Master(args[0]);
m.startZK();
m.runForMaster();1
if (isLeader) {
System.out.println("I'm the leader");
// wait for a bit
Thread.sleep(60000);
} else {
System.out.println("Someone else is the leader");2
}
m.stopZK();
}
1. 调用之前实现的runForMaster方法,结果要么是当前进程成为Master,要么是其他进程已经是Master。
2. 如果已经实现了Master的应用逻辑,那么在此开始运行。现在只能先发表“胜利宣言”自我满足一下,sleep60s之后退出main方法。
由于没有直接处理InterruptedException,当异常发生后,程序会退出(ZooKeeper句柄会被自动关闭)。当然,现在Master在关闭之前什么都没干,我们先关注其他部分。在下一章中,Master会实现管理系统中的任务队列。
ZooKeeper为所有的同步操作都提供了对应的异步实现版本。这有利于实现在单线程程序中发起多次请求,能够简化程序实现。本节用异步方法调用重新实现获得上一节的例子。
以下是create方法的异步版本:
void create(String path,
byte[] data,
Listacl,
CreateMode createMode,
AsyncCallback.StringCallback cb,1
Object ctx)2
异步版本的create方法比同步版本增加了两个参数:
1. 回调方法对象
2. 用户自定义上下文(这个对象作为参数传递给回调方法)
异步调用通常在create请求传递给服务器之前就立即返回了。在回调方法中通常会使用我们传递的ctx上下文参数。当create请求收到服务器的响应时,ctx参数传递给被调用的回调方法。
注意create方法不会抛出异常,这能够简化编程。因为方法调用不用等待操作完成之前就返回了,所以不用担心InterruptedException;而且因为请求异常被编码为回调方法的第一个参数,所以也不用担心KeepException。
回调对象需要实现StringCallback方法:
void processResult(int rc, String path, Object ctx, String name)
异步方法请求排成队列,通过另外一个单独线程传输到服务端。客户端收到的响应,也被一个专门的回调线程处理。单独的回调线程能保证收到的回调被顺序处理。
rc
异步调用的返回结果。OK或者是和各种KeepException对应的错误码。
path
create操作中使用的path路径参数。
ctx
create操作中使用的ctx上下文参数。
name
被创建的节点名称。
如果创建成功,path和name参数相等,除非创建的是CreateMode.SEQUENTIAL顺序节点。
回调处理
因为一个独立线程处理所有回调,如果某次回调阻塞,随后的回调都会被阻塞。所以你最好不要在回调方法中做密集操作或阻塞操作。通常情况下都应该避免在回调方法中调用同步API,这样后续的回调操作才能被迅速处理。
开始用异步方法实现Master功能。创建一个masterCreateCallback对象接收create操作的结果:
static boolean isLeader;
static StringCallback masterCreateCallback = new StringCallback() {
void processResult(int rc, String path, Object ctx, String name) {
switch(Code.get(rc)) {1
case CONNECTIONLOSS:2
checkMaster();
return;
case OK:3
isLeader = true;
break;
default:4
isLeader = false;
}
System.out.println("I'm " + (isLeader? "": "not") +
"the leader");
}
};
void runForMaster(){
zk.create("/master", serverId.getBytes(), OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL, masterCreateCallback, null);5
}
1. 得到create操作的结果rc,转换成Code枚举变量。不等于0的rc值代表不同的KeeperException异常。
2. 如果由于连接丢失造成create操作失败,会得到CONNECTIONLOSS错误码而不是抛出ConnectionLossException异常。当连接丢失,需要检查系统状态,找出要做的恢复动作,这些操作通过后面实现的checkMaster方法来完成。
3. Woohoo!我们成为了Leader,将isLeader设置为true。
4. 如果发生其他问题,竞争Leader失败。
5. 在runForMaster启动方法中,将masterCreateCallback对象传递给create方法。ctx参数设置为null,因为我们不需要从runForMaster方法传递信息到masterCreateCallback.processResult方法。
现在实现checkMaster方法。这个方法看起来和同步版本有一些区别,我们从调用getData异步方法开始,把原来checkMaster中的一连串处理逻辑都挪到了回调方法中。当getData操作返回后,会在DataCallback回调方法中继续进行下一步处理:
DataCallbackmasterCheckCallback = new DataCallback() {
void processResult(int rc, String path, Object ctx, byte[] data,
Statstat) {
switch(Code.get(rc)) {
case CONNECTIONLOSS:
checkMaster();
return;
case NONODE:
runForMaster();
return;
}
}
}
void checkMaster() {
zk.getData("/master", false, masterCheckCallback, null);
}
异步版本的基本逻辑和同步版一致,在异步版中, while循环被回调方法以及新的异步操作所取代。
现在来看,同步版的实现要比异步版简单,但是在下一章会看到,如果程序被异步通知消息驱动,那么把所有操作都修改为异步调用会更简洁一些。异步调用还不会阻塞程序,其他动作,包括新的ZooKeeper操作都能同时进行。
继续使用异步API来设置元数据目录。Master-Worker示例依赖三个目录:/tasks,/assign和/workers。可以依赖某种初始化机制来确保这几个目录在系统运行之前准备好,或者Master在每次启动时负责创建这些目录。接下去的代码片段展示了创建这些目录的过程,除了处理连接丢失之外其他错误都忽略掉了:
public void bootstrap(){
createParent("/workers", new byte[0]);1
createParent("/assign", new byte[0]);
createParent("/tasks", new byte[0]);
createParent("/status", new byte[0]);
}
void createParent(String path, byte[] data) {
zk.create(path,
data,
Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT,
createParentCallback,
data);2
}
StringCallback createParentCallback = new StringCallback() {
public void processResult(int rc, String path, Object ctx, String name) {
switch (Code.get(rc)) {
case CONNECTIONLOSS:
createParent(path, (byte[]) ctx);3
break;
case OK:
LOG.info("Parentcreated");
break;
case NODEEXISTS:
LOG.warn("Parent alreadyregistered: " + path);
break;
default:
LOG.error("Something wentwrong: ",
KeeperException.create(Code.get(rc), path));
}
}
};
1. 这些节点都不用包含数据,所以使用空字节数组。
2. 一般情况下,回调方法中使用ctx上下文对象来跟踪被创建节点的数据。看起来有点奇怪,create方法的第二个参数和第四个参数都是data数据对象,其实第二个参数用于创建节点,第四个参数则在createParentCallback回调方法中使用。
3. 如果遇上CONNECTIONLOSS返回码,回调函数直接调用createParent方法进行重试。因为ctx上下文对象和回调对象是独立的,而create操作中的第四个ctx上下文参数正好是节点的数据,所以一个回调方法可以处理所有的create操作。
可以注意到,ZooKeeper中的文件(含有数据的节点)和目录(含有子节点的节点)没有什么区别,一个节点可以同时是文件和目录。
Master启动后,需要注册好worker才能发号施令。根据设计,每个worker都在/work节点下创建一个临时子节点。使用以下代码轻松实现,并用节点中的数据来代表worker的状态:
import java.util.*;
import org.apache.zookeeper.AsyncCallback.DataCallback;
import org.apache.zookeeper.AsyncCallback.StringCallback;
import org.apache.zookeeper.AsyncCallback.VoidCallback;
import org.apache.zookeeper.*;
import org.apache.zookeeper.ZooDefs.Ids;
import org.apache.zookeeper.AsyncCallback.ChildrenCallback;
import org.apache.zookeeper.KeeperException.Code;
import org.apache.zookeeper.data.Stat;
import org.slf4j.*;
public class Worker implements Watcher {
private static final Logger LOG = LoggerFactory.getLogger(Worker.class);
ZooKeeper zk;
String hostPort;
String serverId = Integer.toHexString(random.nextInt());
Worker(String hostPort) {
this.hostPort= hostPort;
}
void startZK() throws IOException {
zk = new ZooKeeper(hostPort, 15000, this);
}
public void process(WatchedEvent e) {
LOG.info(e.toString() + "," + hostPort);
}
void register(){
zk.create("/workers/worker-" + serverId,
"Idle".getBytes(),1
Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL,2
createWorkerCallback, null);
}
StringCallback createWorkerCallback = new StringCallback() {
public void processResult(int rc, String path, Object ctx,
String name) {
switch (Code.get(rc)) {
case CONNECTIONLOSS:
register();3
break;
case OK:
LOG.info("Registered successfully: " + serverId);
break;
case NODEEXISTS:
LOG.warn("Already registered: " + serverId);
break;
default:
LOG.error("Something went wrong: "
+ KeeperException.create(Code.get(rc), path));
}
}
};
public static void main(String args[]) throws Exception {
Worker w = new Worker(args[0]);
w.startZK();
w.register();
Thread.sleep(30000);
}
}
1. 使用节点数据表示相应Worker的状态。
2. 如果节点对应的Worker进程挂掉,临时节点会被清理。所以只要查看/workers的子节点就知道有哪些woker可用。
3. 因为只有当前进程负责创建临时节点,所以如果发生CONNECTIONLOSS,简单重试即可。
如前所述,如果Worker进程挂掉,注册的临时节点会被清理,这就实现了Worker端的组成员管理。
同时,我们把Worker的状态信息存储在节点中,查询ZooKeeper就可以知道Worker的状态。Worker的初始状态为idle,执行任务后,会被设置成其他状态值。
以下是setStatus的实现。这个方法的工作机制与之前的其他方法略有不同。为了不延迟其他操作,我们通过异步方法设置状态:
StatCallbackstatusUpdateCallback = new StatCallback() {
public void processResult(int rc, String path, Object ctx, Stat stat) {
switch(Code.get(rc)) {
case CONNECTIONLOSS:
updateStatus((String)ctx);1
return;
}
}
};
synchronized private void updateStatus(String status) {
if (status == this.status) {2
zk.setData("/workers/" + name, status.getBytes(), -1,
statusUpdateCallback, status);3
}
}
public void setStatus(String status) {
this.status = status;4
updateStatus(status);5
}
4.本地缓存status值,用于更新失败时重试。
5.在setStatus中不直接进行更新操作,封装成updateStatus方法,以便于处理失败重试逻辑
2.处理连接丢失时发起重试有个潜在问题:破坏系统的有序性。ZooKeeper在保证请求和响应的有序性方面做得相当好,但失败重试是则不尽然,因为发起的是一次新的请求。所以我们做了两点:a)检查更新操作是否与当前保存的状态相当。b)在同步方法中检查和重试。
3.执行无条件更新操作(第三个参数为-1,禁用版本检查),将status作为上下文参数传递给回调方法。
1.如果发生CONNECTIONLOSS,简单重试updateStatus就可以了。updateStaus还会负责检查状态竞争条件。
考虑以下场景,进一步理解CONNECTIONLOSS问题:
1. Worker运行task-1任务,所以状态为“working”。
2. 客户端尝试执行setData操作时遇到网络问题
3. 客户端判断发生了CONNECTIONLOSS,然后在statusUpdateCallback调用之前,Worker完成了task-1任务,状态变为空闲。
4. Worker执行setData,尝试设置状态为“idle”。
5. 客户端开始处理CONNECTIONLOSS,如果updateStatus不检查当前状态的话,会尝试把状态设置成“working”。
6. 连接重建,客户端库会老老实实地按照先后顺序发送这两次setData操作,最终Worker的状态变为“working”。
通过在setData操作之前检查当前状态,可以避免以上场景。
系统最后一个组件是负责提交任务队列的客户端。客户端在/tasks下增加任务节点。任务使用顺序节点,这有两方面好处:首先,序列号代表任务排队顺序。其次,序列号简化了创建唯一任务的工作量。客户端代码如下所示:
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.Watcher;
public class Client implements Watcher {
ZooKeeper zk;
String hostPort;
Client(String hostPort) {this.hostPort= hostPort; }
void startZK() throws Exception {
zk = new ZooKeeper(hostPort, 15000, this);
}
String queueCommand(Stringcommand) throws KeeperException {
while (true) {
try {
String name = zk.create("/tasks/task-",1
command.getBytes(), OPEN_ACL_UNSAFE,
CreateMode.SEQUENTIAL);2
return name;3
break;
} catch (NodeExistsExceptione) {
throw new Exception(name + " already appears to be running");
} catch (ConnectionLossExceptione) {4
}
}}
public void process(WatchedEvent e) { System.out.println(e); }
public static void main(String args[]) throws Exception {
Client c = new Client(args[0]);
c.start();
String name = c.queueCommand(args[1]);
System.out.println("Created " + name);
}
}
1. 在/tasks下创建任务节点,任务名称前缀是task-。
2. 使用CreateMode.SEQUENTIAL,任务名称后缀是单调递增的序列号。ZooKeeper保证了任务名称唯一性和任务有序性。
3. 使用CreateMode.SEQUENTIAL时,无法提前知道任务序列号,需要等待create方法返回新节点名称
4. 如果create操作时发生CONNECTIONLOSS,简单重试可能会导致创建多个任务节点。对于遵循“至少执行一次”策略的应用来说,这可以正常工作。如果应用遵循“至多执行一次”策略,那么需要做一些额外工作:使用唯一ID(比如session ID)创建任务节点。如果发生CONNECTIONLOSS,检查相应任务节点不存在才进行重试。
运行客户端程序,传入命令参数,会在/tasks下创建任务节点。任务节点不是临时类型,所以即使客户端程序结束,它们还会继续存在。
最后,我们编写一个简单的AdminClient来展示系统状态。你可以使用zkCli工具来查看ZooKeeper状态,但通常来讲,编写自己的管理端,能更加方便和快捷地管理系统。在下面的例子中,我们使用getData和getChildren方法获取Master-Worker系统的状态。
这些方法用起来很简单,而且因为它们不会改变系统状态,所以遇到错误时无需清理,抛出错误就行。
管理端使用同步操作。我们只对系统当前状态感兴趣,不监视系统变化,所以将watch参数设置为false(下一章会介绍使用watch参数跟踪系统变化)。以下是管理端代码:
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.Watcher;
public class AdminClient implements Watcher {
ZooKeeper zk;
String hostPort;
AdminClient(String hostPort) {this.hostPort= hostPort; }
void start() throws Exception {
zk = new ZooKeeper(hostPort, 15000, this);
}
void listState() throws KeeperException {
try {
Stat stat = new Stat();
byte masterData[]= zk.getData("/master", false, stat);1
Date startDate = new Date(stat.getCtime());2
System.out.println("Master: " + new String(masterData) +
" since " + startDate);
} catch (NoNodeExceptione) {
System.out.println("NoMaster");
}
System.out.println("Workers:");
for (Stringw: zk.getChildren("/workers", false)) {
byte data[]= zk.getData("/workers/" + w, false, null);3
String state = new String(data);
System.out.println("\t" + w + ": " + state);
}
System.out.println("Tasks:");
for (Stringt: zk.getChildren("/assign", false)) {
System.out.println("\t"+ t);
}
}
public void process(WatchedEvent e) {System.out.println(e); }
public static void main(String args[])throws Exception {
AdminClient c = new AdminClient(args[0]);
c.start();
c.listState();
}
}
1. 获取/master节点的数据,即当前Master名。这里对其变化不感兴趣,所以第二个参数设置为false。
2. Stat结构中的数据能告诉我们Master运行时间。Ctime是节点从创建到现在的秒数。细节请参考java.lang.System.currentTimeMillis()方法。
3. 这些临时节点包含两部分信息:其存在表示Worker正在运行,其数据代表Worker的状态。
管理端程序非常简单:浏览Master-Worker示例中的ZooKeeper数据。试试启动、停止Master和Worker,运行客户端提交一些任务,管理端会显示出系统状态的变化。
你可能想知道管理端使用异步API会不会更好一些?系统中存在各种延迟,最严重的是硬盘访问和网络传输,为了提高带宽利用率,它们都使用了队列。虽然getData方法不会访问硬盘,但它会使用网络。而且ZooKeeper内部通过流水线式设计能处理数千个并发请求,每个同步方法请求都会走完整个流水线。所以如果你是在一个只有数十个Worker和数百个任务的小型系统环境中,可能还没什么问题,如果增大数量级,那么延迟就很明显了。
假设一次请求的往返时间是1毫秒,如果需要读取10000个节点,会有10秒钟的网络延迟。而使用异步API,延迟时间能缩短到接近1秒。
在本章中,我们开始接触Master-Worker系统,实现了基本的Master、Worker和客户端,但实际上要做的事情还很多:当任务进入队列,Master需要被唤醒,分配任务给Worker;Worker需要找到被分配给它的任务;客户端需要知道任务何时运行完;如果Master崩溃,备用Master需要接管任务;如果Worker崩溃,任务需要被重新分配。接下来的章节中会覆盖这些概念,实现必需功能。