本节书摘来自华章计算机《ZooKeeper:分布式过程协同技术详解》一书中的第2章,第2.3节,作者:Flavio Junqueira, Benjamin Reed 更多章节内容可以访问云栖社区“华章计算机”公众号查看。
2.3 开始使用ZooKeeper
开始之前,需要下载ZooKeeper发行包。通过下载链接,你会下载到一个名字类似zookeepe-3.4.5.tar.gz的压缩TAR格式文件。在Linux、Mac OS X或任何其他类UNIX系统上,可以通过一下命令解压缩发行包:
# tar -xvzf zookeeper-3.4.5.tar.gz
如果使用Windows,可以使用如WinZip等解压缩工具来解压发行包。
在发行包(distribution)的目录中,你会发现在bin目录中有启动ZooKeeper的脚本。以.sh结尾的脚本运行于UNIX平台(Linux、Mac OS X等),以.cmd结尾的脚本则用于Windows。在conf目录中保存配置文件。lib目录包括Java的JAR文件,它们是运行ZooKeeper所需要的第三方文件。稍后我们需要引用ZooKeeper解压缩的目录。我们以{PATH_TO_ZK}方式来引用该目录。
2.3.1 第一个ZooKeeper会话
首先我们以独立模式运行ZooKeeper并创建一个会话。要做到这一点,使用ZooKeeper发行包中bin/目录下的zkServer和zkCli工具。有经验的管理员常常使用这两个工具来进行调试和管理,同时也非常适合初学者熟悉和了解ZooKeeper。
假设你已经下载并解压了ZooKeeper发行包,进入shell,变更目录(cd)到项目根目录下,重命名配置文件:
# mv conf/zoo_sample.cfg conf/zoo.cfg
虽然是可选的,最好还是把data目录移出/tmp目录,以防止ZooKeeper填满了根分区(root partition)。可以在zoo.cfg文件中修改这个目录的位置。
dataDir=/users/me/zookeeper
最后,为了启动服务器,执行以下命令:
# bin/zkServer.sh start
JMX enabled by default
Using config: ../conf/zoo.cfg
Starting zookeeper ... STARTED
#
这个服务器命令使得ZooKeeper服务器在后台中运行。如果在前台中运行以便查看服务器的输出,可以通过以下命令运行:
# bin/zkServer.sh start-foreground
这个选项提供了大量详细信息的输出,以便允许查看服务器发生了什么。
现在我们准备启动客户端。在另一个shell中进入项目根目录,运行以下命令:
# bin/zkCli.sh
.
.
.
<some omitted output>
.
.
.
2012-12-06 12:07:23,545 [myid:] - INFO [main:ZooKeeper@438] -
Initiating client connection, connectString=localhost:2181
sessionTimeout=30000 watcher=org.apache.zookeeper.
ZooKeeperMain$MyWatcher@2c641e9a
Welcome to ZooKeeper!
2012-12-06 12:07:23,702 [myid:] - INFO [main-SendThread
(localhost:2181):ClientCnxn$SendThread@966] - Opening
socket connection to server localhost/127.0.0.1:2181.
Will not attempt to authenticate using SASL (Unable to
locate a login configuration)
JLine support is enabled
2012-12-06 12:07:23,717 [myid:] - INFO [main-SendThread
(localhost:2181):ClientCnxn$SendThread@849] - Socket
connection established to localhost/127.0.0.1:2181, initiating
session [zk: localhost:2181(CONNECTING) 0]
2012-12-06 12:07:23,987 [myid:] - INFO [main-SendThread
(localhost:2181):ClientCnxn$SendThread@1207] - Session
establishment complete on server localhost/127.0.0.1:2181,
sessionid = 0x13b6fe376cd0000, negotiated timeout = 30000
WATCHER::
WatchedEvent state:SyncConnected type:None path:null
客户端启动程序来建立一个会话。
客户端尝试连接到localhost/127.0.0.1:2181。
客户端连接成功,服务器开始初始化这个新会话。
会话初始化成功完成。
服务器向客户端发送一个SyncConnected事件。
让我们来看一看这些输出。有很多行告诉我们各种各样的环境变量的配置以及客户端使用了什么JAR包。我们在例子中忽略这些信息,关注会话的建立,但你可以花时间来分析所有屏幕的输出信息。
在输出的结尾,我们看到会话建立的日志消息。第一处提到 “Initiating client connection.”。消息本身说明到底发生了什么,而额外的重要细节说明了客户端尝试连接到客户端发送的连接串localhost/127.0.0.1:2181中的一个服务器。这个例子中,字符串只包含了localhost,因此指明了具体连接的地址。之后我们看到关于SASL的消息,我们暂时忽略这个消息,随后一个确认信息说明客户端与本地的ZooKeeper服务器建立了TCP连接。后面的日志信息确认了会话的建立,并告诉我们会话ID为:0x13b6fe376cd0000。最后客户端库通过SyncConncted事件通知了应用。应用需要实现Watcher对象来处理这个事件。下一节将详细说明事件。
为了更加了解ZooKeeper,让我们列出根(root)下的所有znode,然后创建一个znode。首先我们要确认此刻znode树为空,除了节点/zookeeper之外,该节点内标记了ZooKeeper服务所需的元数据树。
WATCHER::
WatchedEvent state:SyncConnected type:None path:null
[zk: localhost:2181(CONNECTED) 0] ls /
[zookeeper]
现在发生了什么?我们执行ls /后看到这里只有/zookeeper节点。现在我们创建一个名为/workers的znode,确保如下所示:
WATCHER::
WatchedEvent state:SyncConnected type:None path:null
[zk: localhost:2181(CONNECTED) 0]
[zk: localhost:2181(CONNECTED) 0] ls /
[zookeeper]
[zk: localhost:2181(CONNECTED) 1] create /workers ""
Created /workers
[zk: localhost:2181(CONNECTED) 2] ls /
[workers, zookeeper]
[zk: localhost:2181(CONNECTED) 3]
注意: Znode数据
当创建/workers节点后,我们指定了一个空字符串(""),说明我们此刻不希望在这个znode中保存数据。然而,该接口中的这个参数可以使我们保存任何字符串到ZooKeeper的节点中。比如,可以替换""为"workers"。
为了完成这个练习,删除znode,然后退出:
[zk: localhost:2181(CONNECTED) 3] delete /workers
[zk: localhost:2181(CONNECTED) 4] ls /
[zookeeper]
[zk: localhost:2181(CONNECTED) 5] quit
Quitting...
2012-12-06 12:28:18,200 [myid:] - INFO [main-EventThread:ClientCnxn$
EventThread@509] - EventThread shut down
2012-12-06 12:28:18,200 [myid:] - INFO [main:ZooKeeper@684] - Session:
0x13b6fe376cd0000 closed
观察到znode /workers已经被删除,并且会话现在也关闭。为了完成最后的清理,退出ZooKeeper服务器:
# bin/zkServer.sh stop
JMX enabled by default
Using config: ../conf/zoo.cfg
Stopping zookeeper ... STOPPED
#
2.3.2 会话的状态和声明周期
会话的生命周期(lifetime)是指会话从创建到结束的时期,无论会话正常关闭还是因超时而导致过期。为了讨论在会话中发生了什么,我们需要考虑会话可能的状态,以及可能导致会话状态改变的事件。
一个会话的主要可能状态大多是简单明了的:CONNECTING、CONNECTED、CLOSED和NOT_CONNECTED。状态的转换依赖于发生在客户端与服务之间的各种事件(见图2-6)。
https://yqfile.alicdn.com/ed5fe9330f4328ddf7add2f5129770e06df5725e.png" >
一个会话从NOT_CONNECTED状态开始,当ZooKeeper客户端初始化后转换到CONNECTING状态(图2-6中的箭头1)。正常情况下,成功与ZooKeeper服务器建立连接后,会话转换到CONNECTED状态(箭头2)。当客户端与ZooKeeper服务器断开连接或者无法收到服务器的响应时,它就会转换回CONNECTING状态(箭头3)并尝试发现其他ZooKeeper服务器。如果可以发现另一个服务器或重连到原来的服务器,当服务器确认会话有效后,状态又会转换回CONNECTED状态。否则,它将会声明会话过期,然后转换到CLOSED状态(箭头4)。应用也可以显式地关闭会话(箭头4和箭头5)。
注意: 发生网络分区时等待CONNECTING
如果一个客户端与服务器因超时而断开连接,客户端仍然保持CONNECTING状态。如果因网络分区问题导致客户端与ZooKeeper集合被隔离而发生连接断开,那么其状态将会一直保持,直到显式地关闭这个会话,或者分区问题修复后,客户端能够获悉ZooKeeper服务器发送的会话已经过期。发生这种行为是因为ZooKeeper集合对声明会话超时负责,而不是客户端负责。直到客户端获悉ZooKeeper会话过期,否则客户端不能声明自己的会话过期。然而,客户端可以选择关闭会话。
创建一个会话时,你需要设置会话超时这个重要的参数,这个参数设置了ZooKeeper服务允许会话被声明为超时之前存在的时间。如果经过时间t之后服务接收不到这个会话的任何消息,服务就会声明会话过期。而在客户端侧,如果经过t/3的时间未收到任何消息,客户端将向服务器发送心跳消息。在经过2t/3时间后,ZooKeeper客户端开始寻找其他的服务器,而此时它还有t/3时间去寻找。
注意:客户端会尝试连接哪一个服务器?
在仲裁模式下,客户端有多个服务器可以连接,而在独立模式下,客户端只能尝试重新连接单个服务器。在仲裁模式中,应用需要传递可用的服务器列表给客户端,告知客户端可以连接的服务器信息并选择一个进行连接。
当尝试连接到一个不同的服务器时,非常重要的是,这个服务器的ZooKeeper状态要与最后连接的服务器的ZooKeeper状态保持最新。客户端不能连接到这样的服务器:它未发现更新而客户端却已经发现的更新。ZooKeeper通过在服务中排序更新操作来决定状态是否最新。ZooKeeper确保每一个变化相对于所有其他已执行的更新是完全有序的。因此,如果一个客户端在位置i观察到一个更新,它就不能连接到只观察到i'图2-7描述了在重连情况下事务标识符(zkid)的使用。当客户端因超时与s1断开连接后,客户端开始尝试连接s2,但s2延迟于客户端所知的变化。然而,s3对这个变化的情况与客户端保持一致,所以s3可以安全连接。
2.3.3 ZooKeeper与仲裁模式
到目前为止,我们一直基于独立模式配置的服务器端。如果服务器启动,服务就启动了,但如果服务器故障,整个服务也因此而关闭。这非常不符合可靠的协作服务的承诺。出于可靠性,我们需要运行多个服务器。
幸运的是,我们可以在一台机器上运行多个服务器。我们仅仅需要做的便是配置一个更复杂的配置文件。
为了让服务器之间可以通信,服务器间需要一些联系信息。理论上,服务器可以使用多播来发现彼此,但我们想让ZooKeeper集合支持跨多个网络而不是单个网络,这样就可以支持多个集合的情况。
为了完成这些,我们将要使用以下配置文件:
tickTime=2000
initLimit=10
syncLimit=5
dataDir=./data
clientPort=2181
server.1=127.0.0.1:2222:2223
server.2=127.0.0.1:3333:3334
server.3=127.0.0.1:4444:4445
我们主要讨论最后三行对于server.n项的配置信息。其余配置参数将会在第10章中进行说明。
每一个server.n项指定了编号为n的ZooKeeper服务器使用的地址和端口号。每个server.n项通过冒号分隔为三部分,第一部分为服务器n的IP地址或主机名(hostname),第二部分和第三部分为TCP端口号,分别用于仲裁通信和群首选举。因为我们在同一个机器上运行三个服务器进程,所以我们需要在每一项中使用不同的端口号。通常,我们在不同的服务器上运行每个服务器进程,因此每个服务器项的配置可以使用相同的端口号。
我们还需要分别设置data目录,我们可以在命令行中通过以下命令来操作:
mkdir z1
mkdir z1/data
mkdir z2
mkdir z2/data
mkdir z3
mkdir z3/data
当启动一个服务器时,我们需要知道启动的是哪个服务器。一个服务器通过读取data目录下一个名为myid的文件来获取服务器ID信息。可以通过以下命令来创建这些文件:
echo 1 > z1/data/myid
echo 2 > z2/data/myid
echo 3 > z3/data/myid
当服务器启动时,服务器通过配置文件中的dataDir参数来查找data目录的配置。它通过mydata获得服务器ID,之后使用配置文件中server.n对应的项来设置端口并监听。当在不同的机器上运行ZooKeeper服务器进程时,它们可以使用相同的客户端端口和相同的配置文件。但对于这个例子,在一台服务器上运行,我们需要自定义每个服务器的客户端端口。
因此,首先使用本章之前讨论的配置文件,创建z1/z1.cfg。之后通过分别改变客户端端口号为2182和2183,创建配置文件z2/z2.cfg和z3/z3.cfg。
现在可以启动服务器,让我们从z1开始:
$ cd z1
$ {PATH_TO_ZK}/bin/zkServer.sh start ./z1.cfg
服务器的日志记录为zookeeper.out。因为我们只启动了三个ZooKeeper服务器中的一个,所以整个服务还无法运行。在日志中我们将会看到以下形式的记录:
... [myid:1] - INFO [QuorumPeer[myid=1]/...:2181:QuorumPeer@670] - LOOKING
... [myid:1] - INFO [QuorumPeer[myid=1]/...:2181:FastLeaderElection@740] -
New election. My id = 1, proposed zxid=0x0
... [myid:1] - INFO [WorkerReceiver[myid=1]:FastLeaderElection@542] -
Notification: 1 ..., LOOKING (my state)
... [myid:1] - WARN [WorkerSender[myid=1]:QuorumCnxManager@368] - Cannot
open channel to 2 at election address /127.0.0.1:3334
Java.net.ConnectException: Connection refused
at java.net.PlainSocketImpl.socketConnect(Native Method)
at java.net.PlainSocketImpl.doConnect(PlainSocketImpl.java:351)
这个服务器疯狂地尝试连接到其他服务器,然后失败,如果我们启动另一个服务器,我们可以构成仲裁的法定人数:
$ cd z2
$ {PATH_TO_ZK}/bin/zkServer.sh start ./z2.cfg
如果我们观察第二个服务器的日志记录zookeeper.out,我们将会看到:
... [myid:2] - INFO [QuorumPeer[myid=2]/...:2182:Leader@345] - LEADING
- LEADER ELECTION TOOK - 279
... [myid:2] - INFO [QuorumPeer[myid=2]/...:2182:FileTxnSnapLog@240] -
Snapshotting: 0x0 to ./data/version-2/snapshot.0
该日志指出服务器2已经被选举为群首。如果我们现在看看服务器1的日志,我们会看到:
... [myid:1] - INFO [QuorumPeer[myid=1]/...:2181:QuorumPeer@738] -
FOLLOWING
... [myid:1] - INFO [QuorumPeer[myid=1]/...:2181:ZooKeeperServer@162] -
Created server ...
... [myid:1] - INFO [QuorumPeer[myid=1]/...:2181:Follower@63] - FOLLOWING
- LEADER ELECTION TOOK - 212
服务器1作为服务器2的追随者被激活。我们现在具有了符合法定仲裁(三分之二)的可用服务器。
在此刻服务开始可用。我们现在需要配置客户端来连接到服务上。连接字符串需要列出所有组成服务的服务器host:port对。对于这个例子,连接串为"127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183"(我们包含第三个服务器的信息,即使我们永远不启动它,因为这可以说明ZooKeeper一些有用的属性)。
我们使用zkCli.sh来访问集群:
$ {PATH_TO_ZK}/bin/zkCli.sh -server 127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183
当连接到服务器后,我们会看到以下形式的消息:
[myid:] - INFO [...] - Session establishment
complete on server localhost/127.0.0.1:2182 ...
注意日志消息中的端口号,在本例中的2182。如果通过Ctrl-C来停止客户端并重启多次它,我们将会看到端口号在218102182之间来回变化。我们也许还会注意到尝试2183端口后连接失败的消息,之后为成功连接到某一个服务器端口的消息。
注意: 简单的负载均衡
客户端以随机顺序连接到连接串中的服务器。这样可以用ZooKeeper来实现一个简单的负载均衡。不过,客户端无法指定优先选择的服务器来进行连接。例如,如果我们有5个ZooKeeper服务器的一个集合,其中3个在美国西海岸,另外两个在美国东海岸,为了确保客户端只连接到本地服务器上,我们可以使在东海岸客户端的连接串中只出现东海岸的服务器,在西海岸客户端的连接串中只有西海岸的服务器。
这个连接尝试说明如何通过运行多个服务器来达到可靠性(当然,在生产环境中,你需要在不同的主机上进行这些操作)。对于本书大部分,包括后续几章,我们一直以独立模式的服务器进行开发,因为启动和管理多个服务器非常简单,实现这个例子也非常简单。除了连接串外,客户端不用关心ZooKeeper服务由多少个服务器组成,这也是ZooKeeper的优点之一。
2.3.4 实现一个原语:通过ZooKeeper实现锁
关于ZooKeeper的功能,一个简单的例子就是通过锁来实现临界区域。我们知道有很多形式的锁(如:读/写锁、全局锁),通过ZooKeeper来实现锁也有多种方式。这里讨论一个简单的方式来说明应用中如何使用ZooKeeper,我们不再考虑其他形式的锁。
假设有一个应用由n个进程组成,这些进程尝试获取一个锁。再次强调,ZooKeeper并未直接暴露原语,因此我们使用ZooKeeper的接口来管理znode,以此来实现锁。为了获得一个锁,每个进程p尝试创建znode,名为/lock。如果进程p成功创建了znode,就表示它获得了锁并可以继续执行其临界区域的代码。不过一个潜在的问题是进程p可能崩溃,导致这个锁永远无法释放。在这种情况下,没有任何其他进程可以再次获得这个锁,整个系统可能因死锁而失灵。为了避免这种情况,我们不得不在创建这个节点时指定/lock为临时节点。
其他进程因znode存在而创建/lock失败。因此,进程监听/lock的变化,并在检测到
/lock删除时再次尝试创建节点来获得锁。当收到/lock删除的通知时,如果进程p还需要继续获取锁,它就继续尝试创建/lock的步骤,如果其他进程已经创建了,就继续监听节点。