[Zookeeper入門](http://blog.leanote.com/post/holynull/Zookeeper)
[TOC]
Zookeeper是Hadoop分布式調度服務,用來構建分布式應用系統。構建一個分布式應用是一個很復雜的事情,主要的原因是我們需要合理有效的處理分布式集群中的部分失敗的問題。例如,集群中的節點在相互通信時,A節點向B節點發送消息。A節點如果想知道消息是否發送成功,只能由B節點告訴A節點。那么如果B節點關機或者由于其他的原因脫離集群網絡,問題就出現了。A節點不斷的向B發送消息,并且無法獲得B的響應。B也沒有辦法通知A節點已經離線或者關機。集群中其他的節點完全不知道B發生了什么情況,還在不斷的向B發送消息。這時,你的整個集群就發生了部分失敗的故障。
Zookeeper不能讓部分失敗的問題徹底消失,但是它提供了一些工具能夠讓你的分布式應用安全合理的處理部分失敗的問題。
## 目錄
* [Zookeeper 入門](http://blog.leanote.com/post/holynull/Zookeeper#p)
* [目錄](http://blog.leanote.com/post/holynull/Zookeeper#title)
* [安裝和運行Zookeeper](http://blog.leanote.com/post/holynull/Zookeeper#p-1)
* [Zookeeper開發實例](http://blog.leanote.com/post/holynull/Zookeeper#p-2)
* [ZooKeeper中的組和成員](http://blog.leanote.com/post/holynull/Zookeeper#p-3)
* [創建組](http://blog.leanote.com/post/holynull/Zookeeper#title-1)
* [加入組](http://blog.leanote.com/post/holynull/Zookeeper#title-2)
* [成員列表](http://blog.leanote.com/post/holynull/Zookeeper#title-3)
* [Zookeeper的命令行工具](http://blog.leanote.com/post/holynull/Zookeeper#p-4)
* [刪除分組](http://blog.leanote.com/post/holynull/Zookeeper#title-4)
* [Zookeeper 服務](http://blog.leanote.com/post/holynull/Zookeeper#p-5)
* [數據模型 Data Model](http://blog.leanote.com/post/holynull/Zookeeper#title-5)
* [Ephemeral znodes](http://blog.leanote.com/post/holynull/Zookeeper#p-6)
* [Znode的序號](http://blog.leanote.com/post/holynull/Zookeeper#title-6)
* [觀察模式 Watches](http://blog.leanote.com/post/holynull/Zookeeper#title-7)
* [操作 Operations](http://blog.leanote.com/post/holynull/Zookeeper#p-7)
* [批量更新 Multiupdate](http://blog.leanote.com/post/holynull/Zookeeper#p-8)
* [APIs](http://blog.leanote.com/post/holynull/Zookeeper#p-9)
* [觀察模式觸發器 Watch triggers](http://blog.leanote.com/post/holynull/Zookeeper#title-8)
* [ACLs 訪問控制操作](http://blog.leanote.com/post/holynull/Zookeeper#title-9)
* [實現 Implementation](http://blog.leanote.com/post/holynull/Zookeeper#p-10)
* [數據一致性 Consistency](http://blog.leanote.com/post/holynull/Zookeeper#title-10)
* [會話 Sessions](http://blog.leanote.com/post/holynull/Zookeeper#title-11)
* [時間 Time](http://blog.leanote.com/post/holynull/Zookeeper#title-12)
* [狀態 States](http://blog.leanote.com/post/holynull/Zookeeper#title-13)
* [ZooKeeper應用程序 Building Applications with ZooKeeper](http://blog.leanote.com/post/holynull/Zookeeper#p-pp-p)
* [配置服務 Configuration Service](http://blog.leanote.com/post/holynull/Zookeeper#title-14)
* [堅韌的ZooKeeper應用 The Resilient ZooKeeper Application](http://blog.leanote.com/post/holynull/Zookeeper#p-p-pp)
* [InterrupedException](http://blog.leanote.com/post/holynull/Zookeeper#pp)
* [KeeperException](http://blog.leanote.com/post/holynull/Zookeeper#pp-1)
* [狀態異常 State Exception](http://blog.leanote.com/post/holynull/Zookeeper#p-11)
* [重新獲取異常 Recoverable Exception](http://blog.leanote.com/post/holynull/Zookeeper#p-12)
* [不能重新獲取異常 Unrecoverable exceptions](http://blog.leanote.com/post/holynull/Zookeeper#p-13)
* [一個穩定的配置服務 A reliable configuration service](http://blog.leanote.com/post/holynull/Zookeeper#title-15)
* [鎖服務 A Lock Service](http://blog.leanote.com/post/holynull/Zookeeper#title-16)
* [羊群效應](http://blog.leanote.com/post/holynull/Zookeeper#title-17)
* [重新獲取異常 Recoverable Exception](http://blog.leanote.com/post/holynull/Zookeeper#p-14)
* [不能恢復異常 Unrecoverable Exception](http://blog.leanote.com/post/holynull/Zookeeper#p-15)
* [實現 Implementation](http://blog.leanote.com/post/holynull/Zookeeper#p-16)
* [更多的分布式數據結構和協議 More Distribute Data Structures and Protocols](http://blog.leanote.com/post/holynull/Zookeeper#p-17)
* [生產環境中的ZooKeeper ZooKeeper in Production](http://blog.leanote.com/post/holynull/Zookeeper#p-p-p)
* [韌性和性能 Resilience and Performance](http://blog.leanote.com/post/holynull/Zookeeper#p-18)
* [配置](http://blog.leanote.com/post/holynull/Zookeeper#title-18)
## 安裝和運行Zookeeper
我們采用standalone模式,安裝運行一個單獨的zookeeper服務。安裝前請確認您已經安裝了Java運行環境。
我們去[Apache ZooKeeper releases page](http://zookeeper.apache.org/releases.html)下載zookeeper安裝包,并解壓到本地:
~~~
% tar xzf zookeeper-x.y.z.tar.gz
~~~
ZooKeeper提供了一些可執行程序的工具,為了方便起見,我們將這些工具的路徑加入到PATH環境變量中:
~~~
% export ZOOKEEPER_HOME=~/sw/zookeeper-x.y.z
% export PATH=$PATH:$ZOOKEEPER_HOME/bin
~~~
運行ZooKeeper之前我們需要編寫配置文件。配置文件一般在安裝目錄下的`conf/zoo.cfg`。我們可以把這個文件放在`/etc/zookeeper`下,或者放到其他目錄下,并在環境變量設置`ZOOCFGDIR`指向這個個目錄。下面是配置文件的內容:
~~~
tickTime=2000
dataDir=/Users/tom/zookeeper
clientPort=2181
~~~
tickTime是zookeeper中的基本時間單元,單位是毫秒。datadir是zookeeper持久化數據存放的目錄。clientPort是zookeeper監聽客戶端連接的端口,默認是2181.
啟動命令:
~~~
% zkServer.sh start
~~~
我們通過`nc`或者`telnet`命令訪問`2181`端口,通過執行ruok(Are you OK?)命令來檢查zookeeper是否啟動成功:
~~~
% echo ruok | nc localhost 2181
imok
~~~
那么我看見zookeeper回答我們“I’m OK”。下表中是所有的zookeeper的命名,都是由4個字符組成。
| Category | Command | Description |
| --- | --- | --- |
| Server status | ruok | Prints imok if the server is running and not in an error state. |
| | conf | Prints the server configuration (from zoo.cfg). |
| | envi | Prints the server environment, including ZooKeeper version, Java version, and other system properties. |
| | srvr | Prints server statistics, including latency statistics, the number of znodes, and the server mode (standalone, leader, or follower). |
| | stat | Prints server statistics and connected clients. |
| | srst | Resets server statistics. |
| | isro | Shows whether the server is in read-only (ro) mode (due to a network partition) or read/write mode (rw). |
| Client connections | dump | Lists all the sessions and ephemeral znodes for the ensemble. You must connect to the leader (see srvr) for this command. |
| | cons | Lists connection statistics for all the server’s clients. |
| | crst | Resets connection statistics. |
| Watches | wchs | Lists summary information for the server’s watches. |
| | wchc | Lists all the server’s watches by connection. Caution: may impact server performance for a large number of watches. |
| | wchp | Lists all the server’s watches by znode path. Caution: may impact server performance for a large number of watches. |
| Monitoring | mntr | Lists server statistics in Java properties format, suitable as a source for monitoring systems such as Ganglia and Nagios. |
3.5.0以上的版本會有一個內嵌的web服務,通過訪問`http://localhost:8080/commands`來訪問以上的命令列表。
## Zookeeper開發實例
這一節我們將講解如何編寫Zookeeper客戶端的程序,來控制zookeeper上的數據,以達到管理客戶端所在集群的成員關系。
### ZooKeeper中的組和成員
我們可以把Zookeeper理解為一個高可用的文件系統。但是它沒有文件和文件夾的概念,只有一個叫做znode的節點概念。那么znode即是數據的容器,也是其他節點的容器。(其實znode就可以理解為文件或者是文件夾)我們用父節點和子節點的關系來表示組和成員的關系。那么一個節點代表一個組,組節點下的子節點代表組內的成員。如下圖所示:

### 創建組
我們使用zookeeper的Java API來創建一個`/zoo`的組節點:
~~~
public class CreateGroup implements Watcher {
private static final int SESSION_TIMEOUT = 5000;
private ZooKeeper zk;
private CountDownLatch connectedSignal = new CountDownLatch(1);
public void connect(String hosts) throws IOException, InterruptedException {
zk = new ZooKeeper(hosts, SESSION_TIMEOUT, this);
connectedSignal.await();
}
@Override
public void process(WatchedEvent event) { // Watcher interface
if (event.getState() == KeeperState.SyncConnected) {
connectedSignal.countDown();
}
}
public void create(String groupName) throws KeeperException,
InterruptedException {
String path = "/" + groupName;
String createdPath = zk.create(path, null/*data*/, Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
System.out.println("Created " + createdPath);
}
public void close() throws InterruptedException {
zk.close();
}
public static void main(String[] args) throws Exception {
CreateGroup createGroup = new CreateGroup();
createGroup.connect(args[0]);
createGroup.create(args[1]);
createGroup.close();
}
}
~~~
當`main()`執行時,首先創建了一個`CreateGroup`的對象,然后調用`connect()`方法,通過zookeeper的API與zookeeper服務器連接。創建連接我們需要3個參數:一是服務器端主機名稱以及端口號,二是客戶端連接服務器session的超時時間,三是Watcher接口的一個實例。Watcher實例負責接收Zookeeper數據變化時產生的事件回調。
在連接函數中創建了zookeeper的實例,然后建立與服務器的連接。建立連接函數會立即返回,所以我們需要等待連接建立成功后再進行其他的操作。我們使用CountDownLatch來阻塞當前線程,直到zookeeper準備就緒。這時,我們就看到Watcher的作用了。我們實現了Watcher接口的一個方法:
~~~
public void process(WatchedEvent event);
~~~
當客戶端連接上了zookeeper服務器,Watcher將由`process()`函數接收一個連接成功的事件。我們接下來調用CountDownLatch,釋放之前的阻塞。
連接成功后,我們調用`create()`方法。我們在這個方法中調用zookeeper實例的`create()`方法來創建一個znode。參數包括:一是znode的path;二是znode的內容(一個二進制數組),三是一個access control list(ACL,訪問控制列表,這里使用完全開放模式),最后是znode的性質。
znode的性質分為ephemeral和persistent兩種。ephemeral性質的znode在創建他的客戶端的會話結束,或者客戶端以其他原因斷開與服務器的連接時,會被自動刪除。而persistent性質的znode就不會被自動刪除,除非客戶端主動刪除,而且不一定是創建它的客戶端可以刪除它,其他客戶端也可以刪除它。這里我們創建一個persistent的znode。
`create()`將返回znode的path。我們講新建znode的path打印出來。
我們執行如上程序:
~~~
% export CLASSPATH=ch21-zk/target/classes/:$ZOOKEEPER_HOME/*:\
$ZOOKEEPER_HOME/lib/*:$ZOOKEEPER_HOME/conf
% java CreateGroup localhost zoo
Created /zoo
~~~
### 加入組
接下來我們實現如何在一個組中注冊成員。我們將使用ephemeral znode來創建這些成員節點。那么當客戶端程序退出時,這些成員將被刪除。
我們創建一個ConnetionWatcher類,然后繼承實現一個JoinGroup類:
~~~
public class ConnectionWatcher implements Watcher {
private static final int SESSION_TIMEOUT = 5000;
protected ZooKeeper zk;
private CountDownLatch connectedSignal = new CountDownLatch(1);
public void connect(String hosts) throws IOException, InterruptedException {
zk = new ZooKeeper(hosts, SESSION_TIMEOUT, this);
connectedSignal.await();
}
@Override
public void process(WatchedEvent event) {
if (event.getState() == KeeperState.SyncConnected) {
connectedSignal.countDown();
}
}
public void close() throws InterruptedException {
zk.close();
}
}
~~~
~~~
public class JoinGroup extends ConnectionWatcher {
public void join(String groupName, String memberName) throws KeeperException,
InterruptedException {
String path = "/" + groupName + "/" + memberName;
String createdPath = zk.create(path, null/*data*/, Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL);
System.out.println("Created " + createdPath);
}
public static void main(String[] args) throws Exception {
JoinGroup joinGroup = new JoinGroup();
joinGroup.connect(args[0]);
joinGroup.join(args[1], args[2]);
// stay alive until process is killed or thread is interrupted
Thread.sleep(Long.MAX_VALUE);
}
}
~~~
加入組與創建組非常相似。我們加入了一個ephemeral znode后,讓線程阻塞住。然后我們可以使用命令行查看zookeeper中我們創建的znode。當我們將阻塞的程序強行關閉后,我們會發現我們創建的znode會自動消失。
### 成員列表
下面我們實現一個程序來列出一個組中的所有成員。
~~~
public class ListGroup extends ConnectionWatcher {
public void list(String groupName) throws KeeperException,
InterruptedException {
String path = "/" + groupName;
try {
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();
listGroup.connect(args[0]);
listGroup.list(args[1]);
listGroup.close();
}
}
~~~
我們在`list()`方法中通過調用`getChildren()`方法來獲得某一個path下的子節點,然后打印出來。我們這里會試著捕獲KeeperException.NoNodeException,當znode不存在時會拋出這個異常。我們運行程序,會看見如下結果,說明我們還沒在zoo組中添加任何成員幾點:
~~~
% java ListGroup localhost zoo
No members in group zoo
~~~
我們可以運行之前的`JoinGroup`來添加成員。在后臺運行一些JoinGroup程序,這些程序添加節點后都處于sleep狀態:
~~~
% java JoinGroup localhost zoo duck &
% java JoinGroup localhost zoo cow &
% java JoinGroup localhost zoo goat &
% goat_pid=$!
~~~
最后一行命令的作用是將最后一個啟動的java程序的pid記錄下來,我們好在列出zoo下面的成員后,將該進程kill掉。
下面我們將zoo下的成員打印出來:
~~~
% java ListGroup localhost zoo
goat
duck
cow
~~~
然后我們將kill掉最后啟動的JoinGroup客戶端:
~~~
% kill $goat_pid
~~~
過幾秒后,我們發現goat節點不見了。因為之前我們創建的goat節點是一個ephemeral節點,而創建這個節點的客戶端在ZooKeeper上的會話已經被終結了,因為這個回話在5秒后失效了(我們設置了會話的超時時間為5秒):
~~~
% java ListGroup localhost zoo
duck
cow
~~~
讓我們回過頭來看看,我們到底都做了一些什么?我們首先創建了一個節點組,這些節點的創建者都在同一個分布式系統中。這些節點的創建者之間互相都不知情。一個創建者想使用這些節點數據進行一些工作,例如通過znode節點是否存在來判斷節點的創建者是否存在。
最后一點,我們不能只依靠組成員關系來完全解決在與節點通信時的網絡錯誤。當與一個集群組成員節點進行通信時,發生了通信失敗,我們需要使用重試或者試驗與組中其他的節點通信,來解決這次通信失敗。
#### Zookeeper的命令行工具
Zookeeper有一套命令行工具。我們可以像如下使用,來查找zoo下的成員節點:
~~~
% zkCli.sh -server localhost ls /zoo
[cow, duck]
~~~
你可以不加參數運行這個工具,來獲得幫助。
### 刪除分組
下面讓我們來看一下如何刪除一個分組?
ZooKeeper的API提供一個`delete()`方法來刪除一個znode。我們通過輸入znode的path和版本號(version number)來刪除想要刪除的znode。我們除了使用path來定位我們要刪除的znode,還需要一個參數是版本號。只有當我們指定要刪除的本版號,與znode當前的版本號一致時,ZooKeeper才允許我們將znode刪除掉。這是一種optimistic locking機制,用來處理znode的讀寫沖突。我們也可以忽略版本號一致檢查,做法就是版本號賦值為-1。
刪除一個znode之前,我們需要先刪除它的子節點,就下如下代碼中實現的那樣:
~~~
public class DeleteGroup extends ConnectionWatcher {
public void delete(String groupName) throws KeeperException,
InterruptedException {
String path = "/" + groupName;
try {
List<String> children = zk.getChildren(path, false);
for (String child : children) {
zk.delete(path + "/" + child, -1);
}
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();
deleteGroup.connect(args[0]);
deleteGroup.delete(args[1]);
deleteGroup.close();
}
}
~~~
最后我們執行如下操作來刪除zoo group:
~~~
% java DeleteGroup localhost zoo
% java ListGroup localhost zoo
Group zoo does not exist
~~~
## Zookeeper 服務
ZooKeeper 是一個高可用的高性能調度服務。這一節我們將講述他的模型、操作和接口。
### 數據模型 Data Model
ZooKeeper包含一個樹形的數據模型,我們叫做znode。一個znode中包含了存儲的數據和ACL(Access Control List)。ZooKeeper的設計適合存儲少量的數據,并不適合存儲大量數據,所以znode的存儲限制最大不超過1M。
數據的訪問被定義成原子性的。什么是原子性呢?一個客戶端訪問一個znode時,不會只得到一部分數據;客戶端訪問數據要么獲得全部數據,要么讀取失敗,什么也得不到。相似的,寫操作時,要么寫入全部數據,要么寫入失敗,什么也寫不進去。ZooKeeper能夠保證寫操作只有兩個結果,成功和失敗。絕對不會出現只寫入了一部分數據的情況。與HDFS不同,ZooKeeper不支持字符的append(連接)操作。原因是HDFS是被設計成支持數據流訪問(streaming data access)的大數據存儲,而ZooKeeper則不是。
我們可以通過path來定位znode,就像Unix系統定位文件一樣,使用斜杠來表示路徑。但是,znode的路徑只能使用絕對路徑,而不能想Unix系統一樣使用相對路徑,即Zookeeper不能識別`../`和`./`這樣的路徑。
節點的名稱是由Unicode字符組成的,除了`zookeeper`這個字符串,我們可以任意命名節點。為什么不能使用`zookeeper`命名節點呢?因為ZooKeeper已經默認使用`zookeeper`來命名了一個根節點,用來存儲一些管理數據。
請注意,這里的path并不是URIs,在Java API中是一個String類型的變量。
#### Ephemeral znodes
我們已經知道,znode有兩種類型:ephemeral和persistent。在創建znode時,我們指定znode的類型,并且在之后不會再被修改。當創建znode的客戶端的session結束后,ephemeral類型的znode將被刪除。persistent類型的znode在創建以后,就與客戶端沒什么聯系了,除非主動去刪除它,否則他會一直存在。Ephemeral znode沒有任何子節點。
雖然Ephemeral znode沒有綁定到客戶端的session,但是任何一個客戶端都可以訪問它,當然是在他們的ACL策略下允許訪問的情況下。我們在創建分布式系統時,需要知道分布式資源是否可用。Ephemeral znode就是為這種場景應運而生的。正如我們之前講述的例子中,使用Ephemeral znode來實現一個成員關系管理,任何一個客戶端進程任何時候都可以知道其他成員是否可用。
#### Znode的序號
如果在創建znode時,我們使用排序標志的話,ZooKeeper會在我們指定的znode名字后面增加一個數字。我們繼續加入相同名字的znode時,這個數字會不斷增加。這個序號的計數器是由這些排序znode的父節點來維護的。
如果我們請求創建一個znode,指定命名為`/a/b-`,那么ZooKeeper會為我們創建一個名字為`/a/b-3`的znode。我們再請求創建一個名字為`/a/b-`的znode,ZooKeeper會為我們創建一個名字`/a/b-5`的znode。ZooKeeper給我們指定的序號是不斷增長的。Java API中的`create()`的返回結果就是znode的實際名字。
那么序號用來干什么呢?當然是用來排序用的!后面《A Lock Service》中我們將講述如何使用znode的序號來構建一個share lock。
#### 觀察模式 Watches
觀察模式可以使客戶端在某一個znode發生變化時得到通知。觀察模式有ZooKeeper服務的某些操作啟動,并由其他的一些操作來觸發。例如,一個客戶端對一個znode進行了`exists`操作,來判斷目標znode是否存在,同時在znode上開啟了觀察模式。如果znode不存在,這`exists`將返回`false`。如果稍后,另外一個客戶端創建了這個znode,觀察模式將被觸發,將znode的創建事件通知之前開啟觀察模式的客戶端。我們將在以后詳細介紹其他的操作和觸發。
觀察模式只能被觸發一次。如果要一直獲得znode的創建和刪除的通知,那么就需要不斷的在znode上開啟觀察模式。在上面的例子中,如果客戶端還繼續需要獲得znode被刪除的通知,那么在獲得創建通知后,客戶端還需要繼續對這個znode進行`exists`操作,再開啟一次觀察模式。
在《A Configuration Service》中,有一個例子將講述如何使用觀察模式在集群中更新配置。
### 操作 Operations
下面的表格中列出了9種ZooKeeper的操作。
| 操作 | 說明 |
| --- | --- |
| create | Creates a znode (the parent znode must already exist) |
| delete | Deletes a znode (the znode must not have any children) |
| exists | Tests whether a znode exists and retrieves its metadata |
| getACL, setACL | Gets/sets the ACL for a znode |
| getChildren | Gets a list of the children of a znode |
| getData, setData | Gets/sets the data associated with a znode |
| sync | Synchronizes a client’s view of a znode with ZooKeeper |
調用`delete`和`setData`操作時,我們必須指定一個znode版本號(version number),即我們必須指定我們要刪除或者更新znode數據的哪個版本。如果版本號不匹配,操作將會失敗。失敗的原因可能是在我們提交之前,該znode已經被修改過了,版本號發生了增量變化。那么我們該怎么辦呢?我可以考慮重試,或者調用其他的操作。例如,我們提交更新失敗后,可以重新獲取znode當前的數據,看看當前的版本號是什么,再做更新操作。
ZooKeeper雖然可以被看作是一個文件系統,但是由于ZooKeeper文件很小,所以沒有提供像一般文件系統所提供的`open`、`close`或者`seek`操作。
| 注意 |
| --- |
| 這里的`sync`操作與POSIX文件系統的`fsync()`操作是不同的。就像我們早前講過的,ZooKeeper的寫操作是原子性的,一個成功的寫操作只保證數據被持久化到大多數ZooKeeper的服務器存儲上。所以讀操作可能會讀取不到最新狀態的數據,`sync`操作用來讓client強制所訪問的ZooKeeper服務器上的數據狀態更新到最新狀態。我們會在《一致性 Consistentcy》一節中詳細介紹。 |
#### 批量更新 Multiupdate
ZooKeeper支持將一些原始的操作組合成一個操作單元,然后執行這些操作。那么這種批量操作也是具有原子性的,只可能有兩種執行結果,成功和失敗。批量操作單元中的操作,不會出現一些操作執行成功,一些操作執行失敗的情況,即要么都成功,要么都失敗。
Multiupdate對于綁定一些結構化的全局變量很有用處。例如綁定一個無向圖(undirected graph)。無向圖的頂點(vertex)由znode來表示。添加和刪除邊(edge)的操作,由修改邊的兩個關聯znode來實現。如果我們使用ZooKeeper的原始的操作來實現對邊(edge)的操作,那么就有可能產生兩個znode修改不一致的情況(一個修改成功,一個修改失敗)。那么我們將修改兩個znode的操作放入到一個Multi修改單元中,就能夠保證兩個znode,要么都修改成功,要么都修改失敗。這樣就能夠避免修改無向圖的邊時產生修改不一致的現象。
#### APIs
ZooKeeper客戶端使用的核心編程語言有JAVA和C;同時也支持Perl、Python和REST。執行操作的方式呢,分為同步執行和異步執行。我們之前已經見識過了同步的Java API中的`exists`。
~~~
public Stat exists(String path, Watcher watcher) throws KeeperException,
InterruptedException
~~~
下面代碼則是異步方式的`exists`:
~~~
public void exists(String path, Watcher watcher, StatCallback cb, Object ctx)
~~~
Java API中,異步的方法的返回類型都是`void`,而操作的返回的結果將傳遞到回調對象的回調函數中。回調對象將實現`StatCallback`接口中的一個回調函數,來接收操作返回的結果。函數接口如下:
~~~
public void processResult(int rc, String path, Object ctx, Stat stat);
~~~
參數`rc`表示返回碼,請參考`KeeperException`中的定義。在`stat`參數為null的情況下,非0的值表示一種異常。參數`path`和`ctx`與客戶端調用的`exists`方法中的參數相等,這兩個參數通常用來確定回調中獲得的響應是來至于哪個請求的。參數`ctx`可以是任意對象,只有當`path`參數不能消滅請求的歧義時才會用到。如果不需要參數`ctx`,可以設置為null。
| 應該使用同步API還是異步API呢? |
| --- |
| 兩種API提供了相同的功能,需要使用哪種API取決于你程序的模式。例如,你設計的程序模式是一個事件驅動模式的程序,那么你最好使用異步API。異步API也可以被用在追求一個比較好的數據吞吐量的場景。想象一下,如果你需要得去大量的znode數據,并且依靠獨立的進程來處理他們。如果使用同步API,每次讀取操作都會被阻塞住,直到返回結果。不如使用異步API,讀取操作可以不必等待返回結果,繼續執行。而使用另外的線程來處理返回結果。 |
#### 觀察模式觸發器 Watch triggers
讀操作,例如:`exists`、`getChildren`、`getData`會在znode上開啟觀察模式,并且寫操作會觸發觀察模式事件,例如:`create`、`delete`和`setData`。ACL(Access Control List)操作不會啟動觀察模式。觀察模式被觸發時,會生成一個事件,這個事件的類型取決于觸發他的操作:
* `exists`啟動的觀察模式,由創建znode,刪除znode和更新znode操作來觸發。
* `getData`啟動的觀察模式,由刪除znode和更新znode操作觸發。創建znode不會觸發,是因為`getData`操作成功的前提是znode必須已經存在。
* `getChildren`啟動的觀察模式,由子節點創建和刪除,或者本節點被刪除時才會被觸發。我們可以通過事件的類型來判斷是本節點被刪除還是子節點被刪除:`NodeChildrenChanged`表示子節點被刪除,而`NodeDeleted`表示本節點刪除。
Watch trigger |
| --- | --- | --- | --- | --- | --- |
| Watch creation | create znode | create child | delete znode | delete child | setData |
| exists | NodeCreated | - | NodeDeleted | - | NodeDataChanged |
| getData | - | - | NodeDeleted | - | NodeDataChanged |
| getChildren | - | getChildren | NodeDeleted | NodeChildrenChanged | - |
事件包含了觸發事件的znode的path,所以我們通過`NodeCreated`和`NodeDeleted`事件就可以知道哪個znode被創建了或者刪除了。如果我們需要在`NodeChildrenChanged`事件發生后知道哪個子節點被改變了,我們就需要再調用一次`getChildren`來獲得一個新的子節點列表。與之類似,在`NodeDataChanged`事件發生后,我們需要調用`getData`來獲得新的數據。我們在編寫程序時,會在接收到事件通知后改變znode的狀態,所以我們一定要清楚的記住znode的狀態變化。
#### ACLs 訪問控制操作
znode的創建時,我們會給他一個ACL(Access Control List),來決定誰可以對znode做哪些操作。
ZooKeeper通過鑒權來獲得客戶端的身份,然后通過ACL來控制客戶端的訪問。鑒權方式有如下幾種:
* digest?
使用用戶名和密碼方式
* sasl?
使用Kerberos鑒權
* ip?
使用客戶端的IP來鑒權
客戶端可以在與ZooKeeper建立會話連接后,自己給自己授權。授權是并不是必須的,雖然znode的ACL要求客戶端必須是身份合法的,在這種情況下,客戶端可以自己授權來訪問znode。下面的例子,客戶端使用用戶名和密碼為自己授權:
~~~
zk.addAuthInfo("digest", "tom:secret".getBytes());
~~~
ACL是由鑒權方式、鑒權方式的ID和一個許可(permession)的集合組成。例如,我們想通過一個ip地址為10.0.0.1的客戶端訪問一個znode。那么,我們需要為znode設置一個ACL,鑒權方式使用IP鑒權方式,鑒權方式的ID為10.0.0.1,只允許讀權限。使用JAVA我們將像如下方式創建一個ACL對象:
~~~
new ACL(Perms.READ,new Id("ip", "10.0.0.1"));
~~~
所有的許可權限將在下表中列出。請注意,`exists`操作不受ACL的控制,所以任何一個客戶端都可以通過`exists`操作來獲得任何znode的狀態,從而得知znode是否真的存在。
| ACL permission | Permitted operations |
| --- | --- |
| CREATE | create (a child znode) |
| READ | getChildren,getData |
| WRITE | setData |
| DELETE | delete (a child znode) |
| ADMIN | setACL |
在`ZooDefs.Ids`類中,有一些ACL的預定義變量,包括`OPEN_ACL_UNSAFE`,這個設置表示將賦予所有的許可給客戶端(除了ADMIN的許可)。
另外,我們可以使用ZooKeeper鑒權的插件機制,來整合第三方的鑒權系統。
### 實現 Implementation
ZooKeeper服務可以在兩種模式下運行。在standalone模式下,我們可以運行一個單獨的ZooKeeper服務器,我們可以在這種模式下進行基本功能的簡單測試,但是這種模式沒有辦法體現ZooKeeper的高可用特性和快速恢復特性。在生產環境中,我們一般采用replicated(復制)模式安裝在多臺服務器上,組建一個叫做ensemble的集群。ZooKeeper在他的副本之間實現高可用性,并且只要ensemble集群中能夠推舉出主服務器,ZooKeeper的服務就可以一直不終斷。例如,在一個5個節點的ensemble中,容忍有2個節點脫離集群,服務還是可用的。因為剩下的3個節點投票,可以產生超過集群半數的投票,來推選一臺主服務器。而6個節點的ensemble中,也只能容忍2個節點的服務器死機。因為如果3個節點脫離集群,那么剩下的3個節點無論如何不能產生超過集群半數的投票來推選一個主服務器。所以,一般情況下ensemble中的服務器數量都是奇數。
從概念上來看,ZooKeeper其實是很簡單的。他所做的一切就是保證每一次對znode樹的修改,都能夠復制到ensemble的大多數服務器上。如果非主服務器脫離集群,那么至少有一臺服務器上的副本保存了最新狀態。剩下的其他的服務器上的副本,會很快更新這個最新的狀態。
為了實現這個簡單而不平凡的設計思路,ZooKeeper使用了一個叫做Zab的協議。這個協議分為兩階段,并且不斷的運行在ZooKeeper上:
* 階段 1:領導選舉(Leader election)
Ensemble中的成員通過一個程序來選舉出一個首領成員,我們叫做leader。其他的成員就叫做follower。在大多數(quorum)follower完成與leader狀態同步時,這個階段才結束。
* 階段 2: 原子廣播(Atomic broadcast)
所有的寫入請求都會發送給leader,leader在廣播給follower。當大多數的follower已經完成了數據改變,leader才會將更新提交,客戶端就會隨之得到leader更新成功的消息。協議中的設計也是具有原子性的,所以寫入操作只有成功和失敗兩個結果。
如果leader脫離了集群,剩下的節點將選舉一個新的leader。如果之前的leader回到了集群中,那么將被視作一個follower。leader的選舉很快,大概200ms就能夠產生結果,所以不會影響執行效率。
Ensemble中的所有節點都會在更新內存中的znode樹的副本之前,先將更新數據寫入到硬盤上。讀操作可以請求任何一臺ZooKeeper服務器,而且讀取速度很快,因為讀取是內存中的數據副本。
### 數據一致性 Consistency
理解了ZooKeeper的實現原理,有助于理解ZooKeeper如何保證數據的一致性。就像字面上理解的“leader”和“follower”的意思一樣,在ensemble中follower的update操作會滯后于leader的update完成。事實的結果使我們在提交更新數據之前,不必在每一臺ZooKeeper服務器上執行持久化變更數據,而是僅需在主服務器上執行持久化變更數據。ZooKeeper客戶端的最佳實踐是全部鏈接到follower上。然而客戶端是有可能連接到leader上的,并且客戶端控制不了這個選擇,甚至客戶端并不知道連接到了follower還是leader。下圖所示,讀操作向follower請求即可,而寫操作由leader來提交。

每一個對znode樹的更新操作,都會被賦予一個全局唯一的ID,我們稱之為zxid(ZooKeeper Transaction ID)。更新操作的ID按照發生的時間順序升序排序。例如,z1z1z1" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">大于z2z2z2" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">,那么z1z1z1" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">的操作就早于z2z2z2" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">操作。
ZooKeeper在數據一致性上實現了如下幾個方面:
* 順序一直性
從客戶端提交的更新操作是按照先后循序排序的。例如,如果一個客戶端將一個znode z賦值為a,然后又將z的值改變成b,那么在這個過程中不會有客戶端在z的值變為b后,取到的值是a。
* 原子性
更新操作的結果不是失敗就是成功。即,如果更新操作失敗,其他的客戶端是不會知道的。
* 系統視圖唯一性
無論客戶端連接到哪個服務器,都將看見唯一的系統視圖。如果客戶端在同一個會話中去連接一個新的服務器,那么他所看見的視圖的狀態不會比之前服務器上看見的更舊。當ensemble中的一個服務器宕機,客戶端去嘗試連接另外一臺服務器時,如果這臺服務器的狀態舊于之前宕機的服務器,那么服務器將不會接受客戶端的連接請求,直到服務器的狀態趕上之前宕機的服務器為止。
* 持久性
一旦更新操作成功,數據將被持久化到服務器上,并且不能撤銷。所以服務器宕機重啟,也不會影響數據。
* 時效性
系統視圖的狀態更新的延遲時間是有一個上限的,最多不過幾十秒。如果服務器的狀態落后于其他服務器太多,ZooKeeper會寧可關閉這個服務器上的服務,強制客戶端去連接一個狀態更新的服務器。
從執行效率上考慮,讀操作的目標是內存中的緩存數據,并且讀操作不會參與到寫操作的全局排序中。這就會引起客戶端在讀取ZooKeeper的狀態時產生不一致。例如,A客戶端將znode z的值由aaa" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">改變成a′a′a&#x2032;" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">,然后通知客戶端B去讀取z的值,但是B讀取到的值是aaa" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">,而不是修改后的a′a′a&#x2032;" role="presentation" style="box-sizing: border-box; line-height: 30px; word-wrap: break-word; display: inline; word-spacing: normal; white-space: nowrap; float: none; direction: ltr; max-width: none; max-height: none; min-width: 0px; min-height: 0px; border: 0px; padding: 0px; margin: 0px; position: relative;">。為了阻止這種情況出現,B在讀取z的值之前,需要調用`sync`方法。`sync`方法會強制B連接的服務器狀態與leader的狀態同步,這樣B在讀取z的值就是A重新更改過的值了。
| 注意 |
| --- |
| `sync`操作只在異步調用時才可用,原因是你不需要等待操作結束再去執行其他的操作。因此,ZooKeeper保證所有的子操作都會在`sync`結束后再執行,甚至在`sync`操作之前發出的操作請求也不例外。 |
### 會話 Sessions
ZooKeeper的客戶端中,配置了一個ensemble服務器列表。當啟動時,首先去嘗試連接其中一個服務器。如果嘗試連接失敗,那么會繼續嘗試連接下一個服務器,直到連接成功或者全部嘗試連接失敗。
一旦連接成功,服務器就會為客戶端創建一個會話(session)。session的過期時間由創建會話的客戶端應用來設定,如果在這個時間期間,服務器沒有收到客戶端的任何請求,那么session將被視為過期,并且這個session不能被重新創建,而創建的ephemeral znode將隨著session過期被刪除掉。在會話長期存在的情況下,session的過期事件是比較少見的,但是應用程序如何處理好這個事件是很重要的。(我們將在《The Resilient ZooKeeper Application》中詳細介紹)
在長時間的空閑情況下,客戶端會不斷的發送ping請求來保持session。(ZooKeeper的客戶端開發工具的liberay實現了自動發送ping請求,所以我們不必去考慮如何維持session)ping請求的間隔被設置成足夠短,以便能夠及時發現服務器失敗(由讀操作的超時時長來設置),并且能夠及時的在session過期前連接到其他服務器上。
容錯連接到其他服務器上,是由ZooKeeper客戶端自動完成的。重要的是在連接到其他服務器上后,之前的session以及epemeral節點還保持可用狀態。
在容錯的過程中,應用將收到與服務斷開連接和連接的通知。Watch模式的通知在斷開鏈接時,是不會發送斷開連接事件給客戶端的,斷開連接事件是在重新連接成功后發送給客戶端的。如果在重新連接到其他節點時,應用嘗試一個操作,這個操作是一定會失敗的。對于這一點的處理,是一個ZooKeeper應用的重點。(我們將在《The Resilient ZooKeeper Application》中講述)
### 時間 Time
在ZooKeeper中有一些時間的參數。`tick`是ZooKeeper的基礎時間單位,用來定義ensemble中服務器上運行的程序的時間表。其他時間相關的配置都是以`tick`為單位的,或者以`tick`的值為最大值或者最小值。例如,session的過期時間在2 ticks到20 ticks之間,那么你再設置時選擇的session過期時間必須在2和20之間的一個數。
通常情況1 tick等于2秒。那么就是說session的過期時間的設置范圍在4秒到40秒之間。在session過期時間的設置上有一些考慮。過期時間太短會造成加快物理失敗的監測頻率。在組成員關系的例子中,session的過期時間與從組中移除失敗的成員花費的時間相等。如果設置過低的session過期時間,那么網絡延遲就有可能造成非預期的session過期。這種情況下,就會出現在短時間內一臺機器不斷的離開組,然后又從新加入組中。
如果應用需要創建比較復雜的臨時狀態,那么就需要較長的session過期時間,因為重構花費的時間比較長。有一些情況下,需要在session的生命周期內重啟,而且要保證重啟完后session不過期(例如,應用維護和升級的情況)。服務器會給每一個session一個ID和密碼,如果在連接創建時,ZooKeeper驗證通過,那么session將被恢復使用(只要session沒過期就行)。所以應用程序可以實現一個優雅的關機動作,在重啟之前,將session的ID和密碼存儲在一個穩定的地方。重啟之后,通過ID和密碼恢復session。
這僅僅是在一些特殊的情況下,我們需要使用這個特性來使用比較長的session過期時間。大多數情況下,我們還是要考慮當出現非預期的異常失敗時,如何處理session過期,或者僅需要優雅的關閉應用,在session過期前不用重啟應用。
通常情況也越大規模的ensemble,就需要越長的session過期時間。Connetction Timeout、Read Timeout和Ping Periods都由一個以服務器數量為參數的函數計算得到,當ensemble的規模擴大,這些值需要逐漸減小。如果為了解決經常失去連接而需要增加timeout的時長,建議你先監控一下ZooKeeper的metrics,再去調整。
### 狀態 States
ZooKeeper對象在他的生命周期內會有不同的狀態,我們通過`getState()`來獲得當前的狀態。
~~~
public States getState()
~~~
狀態是一個枚舉類型的數據。新構建的ZooKeeper對象在嘗試連接ZooKeeper服務時的狀態是`CONNECTING`,一旦與服務建立了連接那么狀態就變成了`CONNECTED`。

客戶端可以通過注冊一個觀察者對象來接收ZooKeeper對象狀態的遷移。當通過`CONNECTED`狀態后,觀察者將接收到一個WatchedEvent事件,他的屬性KeeperState的值是`SyncConnected`。
| 注意 |
| --- |
| 觀察者有兩個職能:一是接收ZooKeeper的狀態改變通知;二是接收znode的改變通知。ZooKeeper對象構造時傳遞進去的watcher對象,默認是用來接收狀態改變通知的,但是znode的改變通知也可能會共享使用默認的watcher對象,或者使用一個專用的watcher。我們可以通過一個Boolean變量來指定是否使用共享默認watcher。 |
ZooKeeper實例會與服務連接斷開或者重新連接,狀態會在`CONNECTING`和`CONNECTED`之間轉換。如果連接斷開,watcher會收到一個斷開連接事件。請注意,這兩個狀態都是ZooKeeper實例自己初始化的,并且在斷開連接后會自動進行重連接。
如果調用了`close()`或者session過期,ZooKeeper實例會轉換為第三個狀態`CLOSED`,此時在接受事件的KeeperState屬性值為`Expired`。一旦ZooKeeper的狀態變為`CLOSED`,說明實例已經不可用(可以通過`isAlive()`來判斷),并且不能再被使用。如果要重新建立連接,就需要重新構建一個ZooKeeper實例。
## ZooKeeper應用程序 Building Applications with ZooKeeper
在對ZooKeeper有了一個深入的了解以后,我們來看一下用ZooKeeper可以實現哪些應用。
### 配置服務 Configuration Service
一個基本的ZooKeeper實現的服務就是“配置服務”,集群中的服務器可以通過ZooKeeper共享一個通用的配置數據。從表面上,ZooKeeper可以理解為一個配置數據的高可用存儲服務,為應用提供檢索和更新配置數據服務。我們可以使用ZooKeeper的觀察模式實現一個活動的配置服務,當配置數據發生變化時,可以通知與配置相關客戶端。
接下來,我們來實現一個這樣的活動配置服務。首先,我們設計用znode來存儲key-value對,我們在znode中存儲一個String類型的數據作為value,用znode的path來表示key。然后,我們實現一個client,這個client可以在任何時候對數據進行跟新操作。那么這個設計的ZooKeeper數據模型應該是:master來更新數據,其他的worker也隨之將數據更新,就像HDFS的namenode那樣。
我們在一個叫做ActiveKeyValueStore的類中編寫代碼如下:
~~~
public class ActiveKeyValueStore extends ConnectionWatcher {
private static final Charset CHARSET = Charset.forName("UTF-8");
public void write(String path, String value) throws InterruptedException,
KeeperException {
Stat stat = zk.exists(path, false);
if (stat == null) {
zk.create(path, value.getBytes(CHARSET), Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
} else {
zk.setData(path, value.getBytes(CHARSET), -1);
}
}
}
~~~
`write()`方法主要實現將給定的key-value對寫入到ZooKeeper中。這其中隱含了創建一個新的znode和更新一個已存在的znode的實現方法的不同。那么操作之前,我們需要根據`exists()`來判斷znode是否存在,然后再根據情況進行相關的操作。其他值得一提的就是String類型的數據在轉換成`byte[]`時,使用的字符集是UTF-8。
我們為了說明`ActiveKeyValueStore`怎么使用,我們考慮實現一個`ConfigUpdater`類來實現更新配置。下面代碼實現了一個在一些隨機時刻更新配置數據的應用。
~~~
public class ConfigUpdater {
public static final String PATH = "/config";
private ActiveKeyValueStore store;
private Random random = new Random();
public ConfigUpdater(String hosts) throws IOException, InterruptedException {
store = new ActiveKeyValueStore();
store.connect(hosts);
}
public void run() throws InterruptedException, KeeperException {
while (true) {
String value = random.nextInt(100) + "";
store.write(PATH, value);
System.out.printf("Set %s to %s\n", PATH, value);
TimeUnit.SECONDS.sleep(random.nextInt(10));
}
}
public static void main(String[] args) throws Exception {
ConfigUpdater configUpdater = new ConfigUpdater(args[0]);
configUpdater.run();
}
}
~~~
上面的代碼很簡單。在`ConfigUpdater`的構造函數中,`ActiveKeyValueStore`對象連接到ZooKeeper服務。然后`run()`不斷的循環運行,使用一個隨機數不斷的隨機更新`/config`znode上的值。
下面我們來看一下,如何讀取`/config`上的值。首先,我們在`ActiveKeyValueStore`中實現一個讀方法。
~~~
public String read(String path, Watcher watcher) throws InterruptedException,
KeeperException {
byte[] data = zk.getData(path, watcher, null/*stat*/);
return new String(data, CHARSET);
}
~~~
ZooKeeper的`getData()`方法的參數包含:path,一個Watcher對象和一個Stat對象。Stat對象中含有從`getData()`返回的值,并且負責接收回調信息。這種方式下,調用者不僅可以獲得數據,還能夠獲得znode的metadata。
做為服務的consumer,`ConfigWatcher`以觀察者身份,創建一個`ActiveKeyValueStore`對象,并且在啟動以后調用`read()`函數(在`dispalayConfig()`函數中)獲得相關數據。
下面的代碼實現了一個以觀察模式獲得ZooKeeper中的數據更新的應用,并將值到后臺中。
~~~
public class ConfigWatcher implements Watcher {
private ActiveKeyValueStore store;
public ConfigWatcher(String hosts) throws IOException, InterruptedException {
store = new ActiveKeyValueStore();
store.connect(hosts);
}
public void displayConfig() throws InterruptedException, KeeperException {
String value = store.read(ConfigUpdater.PATH, this);
System.out.printf("Read %s as %s\n", ConfigUpdater.PATH, value);
}
@Override
public void process(WatchedEvent event) {
if (event.getType() == EventType.NodeDataChanged) {
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.displayConfig();
// stay alive until process is killed or thread is interrupted
Thread.sleep(Long.MAX_VALUE);
}
}
~~~
當`ConfigUpadater`更新znode時,ZooKeeper將觸發一個`EventType.NodeDataChanged`的事件給觀察者。`ConfigWatcher`將在他的`process()`函數中獲得這個時間,并將顯示讀取到的最新的版本的配置數據。
由于觀察模式的觸發是一次性的,所以每次都要調用`ActiveKeyValueStore`的`read()`方法,這樣才能獲得未來的更新數據。我們不能確保一定能夠接受到更新通知事件,因為在接受觀察事件和下一次讀取之間的窗口期內,znode可能被改變了(有可能很多次),但是client可能沒有注冊觀察模式,所以client不會接到znode改變的通知。在配置服務中這不是一個什么問題,因為client只關心配置數據的最新版本。然而,建議讀者關注一下這個潛在的問題。
讓我們來看一下控制臺打印的`ConfigUpdater`運行結果:
~~~
% java ConfigUpdater localhost
Set /config to 79
Set /config to 14
Set /config to 78
~~~
然后立即在另外的控制臺終端窗口中運行`ConfigWatcher`:
~~~
% java ConfigWatcher localhost
Read /config as 79
Read /config as 14
Read /config as 78
~~~
### 堅韌的ZooKeeper應用 The Resilient ZooKeeper Application
分布式計算設計的第一謬誤就是認為“網絡是穩定的”。我們所實現的程序目前都是假設網絡穩定的情況下實現的,所以當我們在一個真實的網絡環境下,會有很多原因可以使程序執行失敗。下面我們將闡述一些可能造成失敗的場景,并且講述如何正確的處理這些失敗,讓我們的程序在面對這些異常時更具韌性。
在ZooKeeper的API中,每一個ZooKeeper的操作都會聲明拋出連個異常:InterruptedException和KeeperException。
#### InterrupedException
當一個操作被中斷時,會拋出一個InterruptedException。在JAVA中有一個標準的阻塞機制用來取消程序的執行,就是在需要阻塞的的地方調用`interrupt()`。如果取消執行成功,會以拋出一個InterruptedException作為結果。ZooKeeper堅持了這個標準,所以我們可以用這種方式來取消client的對ZooKeeper的操作。用到ZooKeeper的類和庫需要向上拋出InterruptedException,才能使我們的client實現取消操作。
InterruptedException并不意味著程序執行失敗,可能是人為設計中斷的,所以在上面配置應用的例子中,當向上拋出InterruptedException時,會引起應用終止。
### KeeperException
當ZooKeeper服務器出現錯誤信號,或者出現了通信方面的問題,就會拋出一個KeeperException。由于錯誤的不同原因,所以KeeperException有很多子類。例如,`KeeperException.NoNodeException`當操作一個znode時,而這個znode并不存在,就會拋出這個異常。
每一個之類都有一個異常碼作為異常的類型。例如,`KeeperException.NoNodeException`的異常碼就是`KeeperException.Code.NONODE`(一個枚舉值)。
有兩種方法來處理KeeperException。一種是直接捕獲KeeperException,然后根據異常碼進行不同類型異常處理。另一種是捕獲具體的子類,然后根據不同類型的異常進行處理。
KeeperException包含了3大類異常。
#### 狀態異常 State Exception
當無法操作znode樹造成操作失敗時,會產生狀態異常。通常引起狀態異常的原因是有另外的程序在同時改變znode。例如,一個`setData()`操作時,會拋出`KeeperException.BadVersionException`。因為另外的一個程序已經在`setData()`操作之前修改了znode,造成`setData()`操作時版本號不匹配了。程序員必須了解,這種情況是很有可能發生的,我們必須靠編寫處理這種異常的代碼來解決他。
有的一些異常是編寫代碼時的疏忽造成的,例如`KeeperException.NoChildrenForEphemeralsException`。這個異常是當我們給一個enphemeral類型的znode添加子節點時拋出的。
#### 重新獲取異常 Recoverable Exception
重新獲取異常來至于那些能夠獲得同一個ZooKeeper session的應用。伴隨的表現是拋出`KeeperException.ConnectionLossException`,表示與ZooKeeper的連接丟失。ZooKeeper將會嘗試重新連接,大多數情況下重新連接都會成功并且能夠保證session的完整性。
然而,ZooKeeper無法通知客戶端操作由于`KeeperException.ConnectionLossException`而失敗。這就是一個部分失敗的例子。只能依靠程序員編寫代碼來處理這個不確定性。
在這點上,冪等操作和非冪等操作的差別就會變得非常有用了。一個冪等操作是指無論運行一次還是多次結果都是一樣的,例如一個讀請求,或者一個不設置任何值得setData操作。這些操作可以不斷的重試。
一個非冪等操作不能被不分青紅皂白的不停嘗試執行,就像一些操作執行一次的效率和執行多次的效率是不同。我們將在之后會討論如何利用非冪等操作來處理Recovreable Exception。
#### 不能重新獲取異常 Unrecoverable exceptions
在一些情況下,ZooKeeper的session可能會變成不可用的——比如session過期,或者因為某些原因session被close掉(都會拋出KeeperException.SessionExpiredException),或者鑒權失敗(KeeperException.AuthFailedException)。無論何種情況,ephemeral類型的znode上關聯的session都會丟失,所以應用在重新連接到ZooKeeper之前都需要重新構建他的狀態。
### 一個穩定的配置服務 A reliable configuration service
回過頭來看一下`ActiveKeyValueStore`中的`write()`方法,其中調用了`exists()`方法來判斷znode是否存在,然后決定是創建一個znode還是調用setData來更新數據。
~~~
public void write(String path, String value) throws InterruptedException,
KeeperException {
Stat stat = zk.exists(path, false);
if (stat == null) {
zk.create(path, value.getBytes(CHARSET), Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
} else {
zk.setData(path, value.getBytes(CHARSET), -1);
}
}
~~~
從整體上來看,`write()`方法是一個冪等方法,所以我們可以不斷的嘗試執行它。我們來修改一個新版本的`write()`方法,實現在循環中不斷的嘗試write操作。我們為嘗試操作設置了一個最大嘗試次數參數(`MAX_RETRIES`)和每次嘗試間隔的休眠(`RETRY_PERIOD_SECONDS`)時長:
~~~
public void write(String path, String value) throws InterruptedException,
KeeperException {
int retries = 0;
while (true) {
try {
Stat stat = zk.exists(path, false);
if (stat == null) {
zk.create(path, value.getBytes(CHARSET), Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
} else {
zk.setData(path, value.getBytes(CHARSET), stat.getVersion());
}
return;
} catch (KeeperException.SessionExpiredException e) {
throw e;
} catch (KeeperException e) {
if (retries++ == MAX_RETRIES) {
throw e;
}
// sleep then retry
TimeUnit.SECONDS.sleep(RETRY_PERIOD_SECONDS);
}
}
}
~~~
細心的讀者可能會發現我們并沒有在捕獲`KeeperException.SessionExpiredException`時繼續重新嘗試操作,這是因為當session過期后,ZooKeeper會變為`CLOSED`狀態,就不能再重新連接了。我們只是簡單的拋出一個異常,通知調用者去創建一個新的ZooKeeper實例,所以`write()`方法可以不斷的嘗試執行。一個簡單的方式來創建一個ZooKeeper實例就是重新new一個`ConfigUpdater`實例。
~~~
public static void main(String[] args) throws Exception {
while (true) {
try {
ResilientConfigUpdater configUpdater =
new ResilientConfigUpdater(args[0]);
configUpdater.run();
} catch (KeeperException.SessionExpiredException e) {
// start a new session
} catch (KeeperException e) {
// already retried, so exit
e.printStackTrace();
break;
}
}
}
~~~
另一個可以替代處理session過期的方法就是使用watcher來監控`Expired`的`KeeperState`,然后重新建立一個連接。這種方法下,我們只需要不斷的嘗試執行`write()`,如果我們得到了KeeperException.SessionExpiredException`異常,連接最終也會被重新建立起來。那么我們拋開如何從一個過期的session中恢復問題,我們的重點是連接丟失的問題也可以這樣解決,只是處理方法不同而已。
| 注意 |
| --- |
| 我們這里忽略了另外一種情況,在zookeeper實例不斷的嘗試連接了ensemble中的所有節點后發現都無法連接成功,就會拋出一個IOException,說明所有的集群節點都不可用。而有一些應用被設計為不斷的嘗試連接,直到ZooKeeper服務恢復可用為止。 |
這只是一個重復嘗試的策略。還有很多的策略,比如指數補償策略,每次嘗試之間的間隔時間會被乘以一個常數,間隔時間會逐漸變長,直到與集群建立連接為止間隔時間才會恢復到一個正常值,來預備一下次連接異常使用。
> 譯者:*為什么要使用指數補償策略呢?這是為了避免反復的嘗試連接而消耗資源。在一次較短的時間后第二次嘗試連接不成功后,延長第三次嘗試的等待時間,這期間服務恢復的幾率可能會更大。第四次嘗試的機會就變小了,從而達到減少嘗試的次數。*
### 鎖服務 A Lock Service
分布式鎖用來為一組程序提供互斥機制。任意一個時刻僅有一個進程能夠獲得鎖。分布式鎖可以用來實現大型分布式系統的leader選舉算法,即leader就是獲取到鎖的那個進程。
| 注意 |
| --- |
| 不要把ZooKeeper的原生leader選舉算法和我們這里所說的通用leader選舉服務搞混淆了。ZooKeeper的原生leader選舉算法并不是公開的算法,并不能向我們這里所說的通用leader選舉服務那樣,為一個分布式系統提供主進程選舉服務。 |
為了使用ZooKeeper實現分布式鎖,我們使用可排序的znode來實現進程對鎖的競爭。思路其實很簡單:首先,我們需要一個表示鎖的znode,獲得鎖的進程就表示被這把鎖給鎖定了(命名為,/leader)。然后,client為了獲得鎖,就需要在鎖的znode下創建ephemeral類型的子znode。在任何時間點上,只有排序序號最小的znode的client獲得鎖,即被鎖定。例如,如果兩個client同時創建znode?`/leader/lock-1`和`/leader/lock-2`,所以創建`/leader/lock-1`的client獲得鎖,因為他的排序序號最小。ZooKeeper服務被看作是排序的權威管理者,因為是由他來安排排序的序號的。
鎖可能因為刪除了`/leader/lock-1`znode而被簡單的釋放。另外,如果相應的客戶端死掉,使用ephemeral znode的價值就在這里,znode可以被自動刪除掉。創建`/leader/lock-2`的client就獲得了鎖,因為他的序號現在最小。當然客戶端需要啟動觀察模式,在znode被刪除時才能獲得通知:此時他已經獲得了鎖。
獲得鎖的偽代碼如下:
1. 在lock的znode下創建名字為`lock-`的ephemeral類型znode,并記錄下創建的znode的path(會在創建函數中返回)。
2. 獲取lock znode的子節點列表,并開啟對lock的子節點的watch模式。
3. 如果創建的子節點的序號最小,則再執行一次第2步,那么就表示已經獲得鎖了。退出。
4. 等待第2步的觀察模式的通知,如果獲得通知,則再執行第2步。
#### 羊群效應
雖然這個算法是正確的,但是還是有一些問題。第一個問題是羊群效應。試想一下,當有成千成百的client正在試圖獲得鎖。每一個client都對lock節點開啟了觀察模式,等待lock的子節點的變化通知。每次鎖的釋放和獲取,觀察模式將被觸發,每個client都會得到消息。那么羊群效應就是指像這樣,大量的client都會獲得相同的事件通知,而只有很小的一部分client會對事件通知有響應。我們這里,只有一個client將獲得鎖,但是所有的client都得到了通知。那么這就像在網絡公路上撒了把釘子,增加了ZooKeeper服務器的壓力。
為了避免羊群效應,通知的范圍需要更精準。我們通過觀察發現,只有當序號排在當前znode之前一個znode離開時,才有必要通知創建當前znode的client,而不必在任意一個znode刪除或者創建時都通知client。在我們的例子中,如果client1、client2和client3創建了znode`/leader/lock-1`、`/leader/lock-2`和`leader/lock-3`,client3僅在`/leader/lock-2`消失時,才獲得通知。而不需要在`/leader/lock-1`消失時,或者新建`/leader/lock-4`時,獲得通知。
#### 重新獲取異常 Recoverable Exception
這個鎖算法的另一個問題是沒有處理當連接中斷造成的創建失敗。在這種情況下,我們根本就不知道之前的創建是否成功了。創建一個可排序的znode是一個非等冪操作,所以我們不能簡單重試,因為如果第一次我們創建成功了,那么第一次創建的znode就成了一個孤立的znode了,將永遠不會被刪除直到會話結束。
那么問題的關鍵在于,在重新連接以后,client不能確定是否之前創建過lock節點的子節點。我們在znode的名字中間嵌入一個client的ID,那么在重新連接后,就可以通過檢查lock znode的子節點znode中是否有名字包含client ID的節點。如果有這樣的節點,說明之前創建節點操作成功了,就不需要再創建了。如果沒有這樣的節點,那就重新創建一個。
Client的會話ID是一個長整型數據,并且在ZooKeeper中是唯一的。我們可以使用會話的ID在處理連接丟失事件過程中作為client的id。在ZooKeeper的JAVA API中,我們可以調用`getSessionId()`方法來獲得會話的ID。
那么Ephemeral類型的可排序znode不要命名為`lock-<sessionId>-`,所以當加上序號后就變成了`lock-<sessionId>-<sequenceNumber>`。那么序號雖然針對上一級名字是唯一的,但是上一級名字本身就是唯一的,所以這個方法既可以標記znode的創建者,也可以實現創建的順序排序。
#### 不能恢復異常 Unrecoverable Exception
如果client的會話過期,那么他創建的ephemeral znode將被刪除,client將立即失去鎖(或者至少放棄獲得鎖的機會)。應用需要意識到他不再擁有鎖,然后清理一切狀態,重新創建一個鎖對象,并嘗試再次獲得鎖。注意,應用必須在得到通知的第一時間進行處理,因為應用不知道如何在znode被刪除事后判斷是否需要清理他的狀態。
#### 實現 Implementation
考慮到所有的失敗模式的處理的繁瑣,所以實現一個正確的分布式鎖是需要做很多細微的設計工作。好在ZooKeeper為我們提供了一個?
產品級質量保證的鎖的實現,我們叫做**WriteLock**。我們可以輕松的在client中應用。
### 更多的分布式數據結構和協議 More Distribute Data Structures and Protocols
我們可以用ZooKeeper來構建很多分布式數據結構和協議,例如,barriers,queues和two-phase commit。有趣的是我們注意到這些都是同步協議,而我們卻使用ZooKeeper的原生異步特征(比如通知機制)來構建他們。
在ZooKeeper官網上提供了一些數據結構和協議的偽代碼。并且提供了實現這些的數據結構和協議的標準教程(包括locks、leader選舉和隊列);你可以在**recipes**目錄中找到。
**Apache Curator project**也提供了一些簡單客戶端的教程。
## 生產環境中的ZooKeeper ZooKeeper in Production
在生產環境中,你需要在replicated模式下運行ZooKeeper。這里我們將介紹一些運行ZooKeeper服務器集群的注意事項。這里我們只做一個簡單介紹,如果你需要了解更詳細的內容,請參考《ZooKeeper Administrator’s Guide》。
### 韌性和性能 Resilience and Performance
ZooKeeper應用應該被定位用于減少機器和網絡對系統的影響。在實踐中這意味著我將隔離機架、電源供應和路由,使得我們不會因為他們的故障而導致失去我們的大多數服務器。
低延遲服務應用的重點是要求所有的服務器都在一個數據中心里。然而一些不要求低延遲應答的場景,為了獲得額外的韌性,將服務器部署在不同的數據中心(至少每兩臺在一個數據中心)。本節中的例子是一個leader選舉算法和一個分布式鎖算法,兩者都不具有頻繁的狀態改變的特征,幾十毫秒的開銷對于系統并不會造成重要的影響。
| 注意 |
| --- |
| ZooKeeper的概念中有一類不參加leader選舉投票的follower。由于在眾多的讀請求過程中,這種觀察者節點并不參加投票,所以可以提高ZooKeeper集群的讀取性能,而不去傷害到寫入性能。觀察者節點可以部署在跨數據中心的環境下,而不會像參加投票的follower那樣在跨數據中心的環境中會對集群產生潛在的影響。那么我們可以將參加投票的follower部署在同一個數據中心,而將不參加投票的follower部署在另外一個數據中心。 |
ZooKeeper是一個高可用的系統,他的重點是能夠及時運行它的功能。因此,建議ZooKeeper服務器最好專注于運行ZooKeeper。如果運行了其他的應用程序,可能會降低ZooKeeper的性能。
配置保證ZooKeeper的事務日志在與他的快照不同的硬盤上。默認情況下,都在`dataDir`指定的目錄下,我們可以通過額外設置`dataLogDir`來指定日志的目錄。日志被指定寫到專門的硬盤設備,ZooKeeper就可以對大化寫日志的速率。
我們在配置文件夾下的`java.env`中可以配置JVM參數。
### 配置
集群中的ZooKeeper服務器都有一個數值ID,范圍在1~255之間。這個ID存在`dataDir`目錄下的`myid`文件中。
每一個server必須知道其他的ZooKeeper server在網絡中的位置,所以我們需要將所有的server都配置在文件中:
~~~
server.n=hostname:port:port
~~~
下面是一個配置例子:
~~~
tickTime=2000
dataDir=/disk1/zookeeper
dataLogDir=/disk2/zookeeper
clientPort=2181
initLimit=5
syncLimit=2
server.1=zookeeper1:2888:3888
server.2=zookeeper2:2888:3888
server.3=zookeeper3:2888:3888
~~~
replicated模式下有兩個額外參數:
initLimit:follower連接和同步leader的時長。如果大多數follower這個時長內同步失敗,將重新選舉一個leader代替之前的leader。如果經常發生這種情況,說明這個值設置的太低。
syncLimit:folloer同步leader的時長。如果follower在這個時長內同步失敗,follower將自動重啟。連接他的client將連接到其他的follower上。
- 誰能舉個通俗易懂的例子告訴我IAAS,SAAS,PAAS的區別?
- 服務器與容器
- 常見NIO框架
- Nginx/Apache 和Apache Tomcat 的區別
- tomcat結合nginx使用小結
- java nio框架netty 與tomcat的關系
- Nginx、Lighttpd與Apache的區別
- Apache vs Lighttpd vs Nginx對比
- 數據庫
- mybatis
- MyBatis傳入多個參數的問題
- MS
- JMS(Java消息服務)入門教程
- ActiveMQ
- JMS簡介與ActiveMQ實戰
- JMS-使用消息隊列優化網站性能
- 深入淺出JMS(一)--JMS基本概念
- 深入淺出JMS(二)--ActiveMQ簡單介紹以及安裝
- 深入淺出JMS(三)--ActiveMQ簡單的HelloWorld實例
- RabbitMq、ActiveMq、ZeroMq、kafka之間的比較,資料匯總
- kafka
- zookeeper
- 集群與負載
- 單機到分布式集群
- 日志
- 從Log4j遷移到LogBack的理由
- 角色權限
- shiro
- Shiro的認證和權限控制
- Spring 整合 Apache Shiro 實現各等級的權限管理
- 安全
- basic
- Servlet、Filter、Listener深入理解
- filter與servlet的比較
- Servlet Filter