1. 背景
對于zookeeper,大家都比較熟悉,在Kafka、HBase、Dubbo中都有看到過他的身影,那zookeeper到底是什么,都能做些什么呢? 今天我們一起來了解下。
2. ZooKeeper簡介
2.1 概述
簡單理解,ZooKeeper 是分布式應用程序的分布式開源協調服務,封裝了分布式架構中核心和主流的需求和功能,并提供一系列簡單易用的接口提供給用戶使用。 分布式應用程序可以根據zookeeper實現分布式鎖、分布式集群的集中式元數據存儲、Master選舉、分布式協調和通知等。下圖是官網上zookeeper的架構簡圖:

從zk的架構圖中也可以了解到,zk本身就是一個分布式的、高可用的系統。有多個server節點,其中有一個是leader節點,其他的是follower節點,他們會從leader節點中復制數據。客戶端節點連接單個zk服務器,并維護一個TCP連接,通過該連接發送請求、獲取響應以及監聽事件并發送心跳,如果與服務器的連接中斷,就會連接到另外的服務器。
2.2 技術特點
zookeeper有以下幾個特點:
- 集群部署:一般是3~5臺機器組成一個集群,每臺機器都在內存保存了zk的全部數據,機器之間互相通信同步數據,客戶端連接任何一臺機器都可以。
- 順序一致性:所有的寫請求都是有序的;集群中只有leader機器可以寫,所有機器都可以讀,所有寫請求都會分配一個zk集群全局的唯一遞增編號:zxid,用來保證各種客戶端發起的寫請求都是有順序的。
- 原子性:要么全部機器成功,要么全部機器都不成功。
- 數據一致性:無論客戶端連接到哪臺節點,讀取到的數據都是一致的;leader收到了寫請求之后都會同步給其他機器,保證數據的強一致,你連接到任何一臺zk機器看到的數據都是一致的。
- 高可用:如果某臺機器宕機,會保證數據不丟失。集群中掛掉不超過一半的機器,都能保證集群可用。比如3臺機器可以掛1臺,5臺機器可以掛2臺。
- 實時性:一旦數據發生變更,其他節點會實時感知到。
- 高性能:每臺zk機器都在內存維護數據,所以zk集群絕對是高并發高性能的,如果將zk部署在高配置物理機上,一個3臺機器的zk集群抗下每秒幾萬請求是沒有問題的。
- 高并發:高性能決定的,主要是基于純內存數據結構來處理,并發能力是很高的,只有一臺機器進行寫,但是高配置的物理機,比如16核32G,可以支撐幾萬的寫入QPS。所有機器都可以讀,選用3臺高配機器的話,可以支撐十萬+的QPS。可參考官網「基于3.2版本做的性能壓測【1】」
2.3 技術本質

zookeeper的技術本質決定了,一些開源項目為什么要使用zk來實現自己系統的高可用。zk是基于zab協議來實現的分布式一致性的,他的核心就是圖中的Atomic Broadcast,通過原子廣播的能力,保持所有服務器的同步,來實現了分布式一致性。
zookeeper算法即zab算法或者叫zab協議,需注意zab并不是paxos,zab的核心是為了實現primary-back systems這種架構模式,而paxos實際上是叫 state machine replication,就是狀態復制機。這里簡單對比下。
- primary-back systems:leader節點接受寫請求,先寫到自己的本地機器,然后通過原子協議或其他的方式,將結果復制到其他的系統。
- state machine replication:沒有一個明顯的leader節點。寫的時候,把接收到的命令記錄下來,然后把命令復制給各個節點,然后各個節點再去執行命令,應用到自己的狀態機里面。
關于一致性算法這里不做具體對比,后續會詳細說下常見的分布式集群算法。
2.3.1 ZAB工作流程
ZAB協議,即ZooKeeper Atomic Broadcas。 集群啟動自動選舉一個Leader出來,只有Leader是可以寫的,Follower是只能同步數據和提供數據的讀取,Leader掛了,Follower可以繼續選舉出來Leader。這里可以看下zab的工作流程:

Leader收到寫請求,就把請求廣播給所有的follower,每一個消息廣播的時候,都是按照2PC思想,先是發起事務Proposal的廣播,就是事務提議,在發起一個事務proposal之前,leader會分配一個全局唯一遞增的事務id,zxid,通過這個可以嚴格保證順序。每個follower收到一個事務proposal之后,就立即寫入本地磁盤日志中,寫入成功之后返回一個ack給leader,然后過半的follower都返回了ack之后,leader會推送commit消息給全部follower,Leader自己也會進行commit操作,commit之后,數據才會寫入znode,然后這個數據可以被讀取到了。
如果突然leader宕機了,會進入恢復模式,重新選舉一個leader,只要過半的機器都承認你是leader,就可以選舉出來一個新的leader,所以zookeeper很重要的一點是宕機的機器數量小于一半,他就可以正常工作。這里不具體展開詳細的選舉和消息丟棄的邏輯。
從上述的工作過程,可以看出zookeeper并不是強制性的,leader并不是保證一條數據被全部follower都commit了才會讓客戶端讀到,過程中可能會在不同的follower上讀取到不一致的數據,但是最終所有節點都commit完成后會一致性的。zk官方給自己的定義是:順序一致性。
從上述的這些技術特點上,我們也可以看出,為什么zookeeper只能是小集群部署,而且是適合讀多寫少的場景。想象一下,如果集群中有幾十個節點,其中1臺是leader,每次寫請求,都要跟所有節點同步,并等過半的機器ack之后才能提交成功,這個性能肯定是很差的。舉個例子,如果使用zookeeper做注冊中心,大量的服務上線、注冊、心跳的壓力,如果達到了每秒幾萬甚至上十萬的場景,zookeeper的單節點寫入是扛不住那么大壓力的。如果這是kafka或者其他中間件共用了一個zookeeper集群,那基本就都癱瘓了。
2.3.2 ZooKeeper角色
上述 的場景中已經提到了leader和follower,既然提到了性能問題,就額外說下,除了leader和follower,zookeeper還有一個角色是:observer。
observer節點是不參與leader選舉的;他也不參與zab協議同步時,過半follower ack的環節。他只是單存的接收數據,同步數據,提供讀服務。這樣zookeeper集群,可以通過擴展observer節點,線性提升高并發查詢的能力 。
2.4 Znode數據模型
如 果想使用好zk的話,必須要了解下他的數據模型。雖然zookeeper的實現比較復雜,但是數據結構還是比較簡單的。如下圖所示,zookeeper的數據結構是一個類型文件系統的樹形目錄分層結構,它是純內存保存的。基于這個目錄結構,可以根據自己的需要,設計的具體的概念含義。ZooKeeper 的層次模型稱作 Data Tree,Data Tree 的每個節點叫作 znode。如下圖所示:

ZNode在我們應用中是主要訪問的實體,有些特點這里提出來,說一下:
- 【Watches】 監聽
zookeeper支持 watch 的概念。客戶端可以在 znode 上設置監聽,當 znode 發生變化時,watch 將被觸發并移除。當 watch 被觸發時,客戶端會收到一個數據包,說明 znode 已更改,這個在分布式系統的協調中是很有用的一個功能。
- 【Data Access】 數據訪問
存儲在命名空間中每個 znode 的數據的讀取和寫入都是原子的。讀取操作獲取與 znode 關聯的所有數據,寫入操作會替換所有數據。每個節點都有一個訪問控制列表 (ACL),用于限制誰可以做什么。
- 【Ephemeral Nodes】臨時節點
ZooKeeper 也有臨時節點的概念。只要創建 znode 的會話處于活動狀態,這些 znode 就存在。當會話結束時,znode 被刪除。臨時 znode 不允許有子節點。
- 持久節點
zooKeeper除了臨時節點還有持久節點,客戶端斷開連接,或者集群宕機,節點也會一直存在。
- 【Sequence Nodes】 順序節點
創建 znode 時,可以 在path路徑末尾附加一個單調遞增的計數器。這個計數器對于父 znode 是唯一的,是全局遞增的序號。
3. ZooKeeper的應用
對zookeeper有了一定的了解之后,我們看下他有哪些應用場景。前面的背景中也提到過,在一些常見的開源項目中,都看到過zk的身影,那zk在這些開源項目中是如何使用的呢?
3.1 典型的應用場景
(1)元數據管理:Kafka、Canal,本身都是分布式架構,分布式集群在運行,本身他需要一個地方集中式的存儲和管理分布式集群的核心元數據,所以他們都選擇把核心元數據放在zookeeper中。
Dubbo:使用zookeeper作為注冊中心、分布式集群的集中式元數據存儲
HBase:使用zookeeper做分布式集群的集中式元數據存儲
(2)分布式協調:如果有節點對zookeeper中的數據做了變更,然后zookeeper會反過來去通知其他監聽這個數據的節點,告訴它這個數據變更了。
kafka: 通過zookeeper解決controller的競爭問題。kafka有多個broker,多個broker會競爭成為一個controller的角色。如果作為controller的broker掛掉了,此時他在zk里注冊的一個節點會消失,其他broker瞬間會被zookeeper反向通知這個事情,繼續競爭成為新的controller,這個就是非常經典的一個分布式協調的場景。
(3)Master選舉 -> HA架構
Canal:通過zookeeper解決Master選舉問題,來實現HA架構
HDFS:Master選舉實現HA架構,NameNode HA架構,部署主備兩個NameNode,只有一個人可以通過zookeeper選舉成為Master,另外一個作為backup。
(4)分布式鎖
一般用于分布式的業務系統中,控制并發寫操作。不過實際使用中,使用zookeeper做分布式鎖的案例比較少,大部分都是使用的redis.
3.2 開源產品使用zk的情況
下圖是有關Paxos Systems Demystified的論文中,常見的開源產品使用的一致性算法系統,可以看到除了google系的產品用paxos算法或他們自己的chubby服務外,開源的系統大部分都使用zookeeper來實現高可用的。

4. 實踐-通過ZooKeeper來實現高可用
了解了zookeeper的一些技術特性及常見的使用場景后,考慮一個問題:我們平時在工作中,大部分是使用一些開源的成熟的產品,如果出現部分產品是不能滿足我們的業務需求時,我們是需要根據自己的業務場景去改造或重新設計的,但一般不會從頭開始,也就是我們說的不會重復造輪子。
自
己實現一個產品時,一般都是分布式集群部署的,那就要考慮,是否需要一個地方集中式存儲分布式集群的元數據?
是否需要進行Master選舉實現HA架構?是否需要進行分布式協調通知?如果有這些需求,zookeeper作為一個久經考驗的產品,是可以考慮直接拿來使用的,這大大提高我們的效率以及提升產品的穩定性。
基于以上對zookeeper技術特點的了解,如果使用zookeeper來實現系統的高可用時,一般需要考慮4個問題,或者理解為4個步驟:
- 如何設計znode的path
- znode的類型如何選擇?比如是臨時節點,還是順序節點?
- znode中存儲什么數據?如何表達自己的業務含義
- 如何設計watch,客戶端需要關注什么事件,事件發生后需要如何處理?
下面我們通過兩個案例,看下zookeeper是如何實現主備切換、集群選舉的。
4.1 主備切換
主備切換在我們日常用到的分布式系統很常見,那我們自己如何通過zookeeper的接口來實現呢?
簡單回顧下主備切換架構:

主備架構的工作流程:
正常階段的話,業務數據讀寫在主機,數據會復制到備機。當主機故障了,數據沒辦法復制到備機,原來的備機自動升級為主機,業務請求到新的主機。原來的主機恢復后,成為新的備機,將數據從新的主機同步到備機上
4.1.1 實現方式

(1)設計 Path
由于只有2個角色,因此直接設置兩個 znode 即可,master和slave,樣例:
-
- /com/dewu/order/operate/master
- /com/dewu/order/operate/slave。
(2)選擇節點類型
當 master 節點掛掉的時候,原來的 slave 升級為 master 節點,因此用 ephemeral 類型的 znode。
因為當一個節點成為master時,需要zk創建master節點,一旦這臺主機掛掉了,它到zk的連接就斷掉了,這個master節點會在超時之后,被zk自動刪除,這樣的話,就知道原來的主機宕機了,所以選擇使用ephemeral類型的節點。
(3)設計 節點數據
由于 slave 成為 master 后,會成為新的復制源,可能出現數據沖突,因此 slave 成為 master 后,節點需要寫入成為 master 的時間,這樣方便修復沖突數據。還可以寫入slave上最新的事務id,這里可以根據自己的業務靈活設計,znode節點中應該寫入什么數據。
(4)設計 Watch
節點啟動的時候,嘗試創建 master znode,創建成功則切換為master,否則創建 slave znode,成為 slave,并監聽master節點; 如果 slave 節點收到 master znode 刪除的事件,就自己去嘗試創建 master znode,創建成功,則自己成為 master,刪除自己創建的slave znode。
4.1.2 示意代碼
public class Node {
public static final Node INSTANCE = new Node();
private volatile String role = "slave";
private CuratorFramework curatorFramework;
private Node(){
}
public void start(String connectString) throws Exception{
if(connectString == null || connectString.isEmpty()){
throw new Exception("connectString is null or empty");
}
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(connectString).sessionTimeoutMs(5000).connectionTimeoutMs(3000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
String groupNodePath = "/com/dewu/order/operate";
String masterNodePath = groupNodePath + "/master";
String slaveNodePath = groupNodePath + "/slave";
//watch master node
PathChildrenCache pathChildrenCache = new PathChildrenCache(client, groupNodePath, true);
pathChildrenCache.getListenable().addListener(new PathChildrenCacheListener() {
@Override
public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {
if(event.getType().equals(PathChildrenCacheEvent.Type.CHILD_REMOVED)){
String childPath = event.getData().getPath();
System.out.println("child removed: " + childPath);
//如果是master節點刪除了,則自己嘗試變成master
if(masterNodePath.equals(childPath)){
switchMaster(client, masterNodePath, slaveNodePath);
}
} else if(event.getType().equals(PathChildrenCacheEvent.Type.CONNECTION_LOST)) {
System.out.println("connection lost, become slave");
role = "slave";
} else if(event.getType().equals(PathChildrenCacheEvent.Type.CONNECTION_RECONNECTED)) {
System.out.println("connection connected……");
if(!becomeMaster(client, masterNodePath)){
becomeSlave(client, slaveNodePath);
}
}
else{
System.out.println("path changed: " + event.getData().getPath());
}
}
});
client.start();
pathChildrenCache.start();
}
public String getRole(){
return role;
}
private boolean becomeMaster(CuratorFramework client, String masterNodePath){
//try to become master
try {
client.create().creatingParentContainersIfNeeded().withMode(CreateMode.EPHEMERAL)
.forPath(masterNodePath, Longs.toByteArray(System.currentTimeMillis()));
System.out.println("succeeded to become master");
role = "master";
return true;
} catch (Exception e) {
System.out.println("failed to become master: " + e.getMessage());
return false;
}
}
private boolean becomeSlave(CuratorFramework client, String slaveNodePath) throws Exception {
//try to become slave
try {
client.create().creatingParentContainersIfNeeded().withMode(CreateMode.EPHEMERAL)
.forPath(slaveNodePath, Longs.toByteArray(System.currentTimeMillis()));
System.out.println("succeeded to become slave");
role = "slave";
return true;
} catch (Exception e) {
System.out.println("failed to become slave: " + e.getMessage());
throw e;
}
}
private void switchMaster(CuratorFramework client, String masterNodePath, String slaveNodePath){
if(becomeMaster(client, masterNodePath)){
try {
client.delete().forPath(slaveNodePath);
} catch (Exception e) {
System.out.println("failed to delete slave node when switch master: " + slaveNodePath);
}
}
}
}
參考 【代碼樣例】 【2】
4.2 實現集群選舉
集群選舉的方式比較多,主要是根據自己的業務場景,考慮選舉時的一些具體算法。
4.2.1 最小節點獲勝
就是在共同的父節點下創建znode,誰的編號最小,誰是leader。

(1)設計 Path
集群共用父節點 parent znode,也就是上圖中的operate,集群中的每個節點在 parent 目錄下創建自己的 znode。如上圖中,假如有5個節點,編號是從
node0000000001~node0000000005。
(2)選擇節點類型
當 Leader 節點掛掉的時候,持有最小編號 znode 的集群節點成為新的 Leader,因此用ephemeral_sequential 類型 znode。使用ephemeral類型的目的是,leader掛掉的時候,節點能自動刪除,使用sequential類型的目的是,讓這些節點都是有序的,我們選擇最小節點的時候就比較簡單。
(3)設計 節點數據
可以根據業務需要靈活寫入各種數據。
(4)設計 Watch
-
- 節點啟動或者重連后,在 parent 目錄下創建自己的 ephemeral_sequntial znode;
- 創建成功后掃描 parent 目錄下所有 znode,如果自己的 znode 編號是最小的,則成為 Leader,否則 監聽 parent整個目錄;
- 當 parent 目錄有節點刪除的時候,首先判斷其是否是 Leader 節點,然后再看其編號是否正好比自己小1,如果是則自己成為 Leader,如果不是繼續 watch。
Curator 的 LeaderLatch、LeaderSelector 采用這種策略,可以參考【Curator】【3】看下對應的代碼 。
4.2.2 搶建唯一節點
集群共用父節點 parent znode,也就是operate,集群中的每個節點在 parent 目錄下創建自己的 znode。也就是集群中只有一個節點,誰創建成功誰就是leader。

(1)設計 Path
集群所有節點只有一個 leader znode,其實本質上就是一個分布式鎖。
(2)選擇 znode 類型
當 Leader 節點掛掉的時候,剩余節點都來創建 leader znode,看誰能最終搶到 leader znode,因此用ephemeral 類型。
(3)設計 節點數據
可以根據業務需要靈活寫入各種數據。
(4)設計 Watch
-
- 節點啟動或者重連后,嘗試創建 leader znode,嘗試失敗則監聽 leader znode;
- 當收到 leader znode 被刪除的事件通知后,再次嘗試創建leader znode,嘗試成功則成為leader ,失敗則繼續監聽leader znode。
4.2.3 法官判決
整體實現比較復雜的一個方案,通過創建節點,判斷誰是法官節點。 法官節點可以根據一定的邏輯算法來判斷誰是leader,比如看誰的數據最新等等。

(1) 設計 Path
集群共用父節點 parent znode,集群中的每個節點在 parent 目錄下創建自己的 znode。
-
- parent znode:圖中的 operate,代表一個集群,選舉結果寫入到operate節點,比如寫入的內容可以是:leader=server6。
- 法官 znode:圖中的橙色 znode,最小的 znode,持有這個 znode 的節點負責選舉算法/規則。
-
-
- 例如:實現Redis 存儲集群的選舉,各個 slave 節點可以將自己存儲的數據最新的 trxId寫入到 znode,然后法官節點將 trxID 最大的節點選為新的 Leader。
-
- 成員 znode:圖中的綠色 znode,每個集群節點對應一個,在選舉期間將選舉需要的信息寫入到自己的 znode。
- Leader znode:圖中的紅色 znode,集群里面只有一個,由法官選出來。
(2)選擇節點類型
當 Leader 節點掛掉的時候,持有最小編號 znode 的集群節點成為“法官”,因此用 ephemeral_sequential 類型 znode。
(3)設計 節點數據
可以根據業務需要靈活寫入各種數據,比如寫入當前存儲的最新的數據對應的事務 ID。
(4)設計 Watch
-
- 節點啟動或者重連后,在 parent 目錄下創建自己的 ephemeral_sequntial znode,并 監聽 parent 目錄;
- 當 parent 目錄有節點刪除的時候,所有節點更新自己的 znode 里面和選舉相關的數據;
- “法官”節點讀取所有 znode的數據,根據規則或者算法選舉新的 Leader,將選舉結果寫入parent znode;
- 所有節點監聽 parent znode,收到變更通知的時候讀取 parent znode 的數據,判斷自己是否成為 Leader。
4.2.4 集群選舉模式對比
實現方式 |
實現復雜度 |
選舉靈活性 |
應用場景 |
最小節點獲勝 |
低 |
低 |
計算集群 |
搶建唯一節點 |
低 |
低 |
計算集群 |
法官判決 |
高 |
高,可以設計滿足業務需求的復雜選舉算法和規則 |
存儲集群 |
這里簡單說下計算集群和存儲集群的應用場景:
計算集群
無狀態,不存在存儲集群里所有的數據覆蓋問題,計算集群只要選出一個leader或主節點就行,對業務沒什么影響。
存儲集群
需要考慮比較復雜的選舉邏輯,考慮數據一致性,考慮數據盡量不要丟失不要沖突等等,所以需要一個復雜的選舉邏輯。
這里也可以看到,并不是功能越強大越好,實際上需要考慮不同的應用場景,特性,基于不同的業務要求選擇合適的方案。一切脫離業務場景的方案都是耍流氓。
5. Etcd
這里再跟大家簡答提一下etcd,主要是zookeeper與etcd的應用場景類似,在實際的落地選型時也會拿來對比,如何選擇。
5.1 etcd概述
除了zookeeper,etcd也是最近幾年比較火的一個分布式協調框架。
etcd是一種強一致性的分布式鍵值存儲,它提供了一種可靠的方式來存儲需要由分布式系統或機器集群訪問的數據。 它優雅地處理網絡分區期間的leader選舉,并且可以容忍機器故障。 etcd是go寫的一個分布式協調組件,尤其是在云原生的技術領域里,目前已經成為了云原生和分布式系統的存儲基石。 下圖是etcd的請求示意圖:

通常,一個用戶的請求發送過來,會經由 HTTP Server 轉發給 邏輯層進行具體的事務處理,如果涉及到節點的修改,則交給 Raft 模塊進行狀態的變更、日志的記錄,然后再同步給別的 etcd 節點以確認數據提交,最后進行數據的提交,再次同步。
5.2 應用場景
與zookeeper一樣,有類似的應用場景,包括:
- 服務發現
- 配置管理
- 分布式協調
- Master選舉
- 分布式鎖
- 負載均衡
比如openstack 使用etcd做配置管理和分布式鎖,ROOK使用etcd研發編排引擎。
5.3 簡單對比
|
zookeeper |
etcd |
語言 |
JAVA |
go |
協議 |
TCP |
grpc |
接口調用 |
必須要使用自己的client進行調用 |
可通過http傳輸,即可通過curl等命令實現調用 |
一致性算法 |
Zab; Zab 協議則由 Leader 選舉、發現、同步、廣播組成 |
Raft ;Raft 算法由 Leader 選舉、日志同步、安全性組成 |
watch功能 |
較局限,一次性觸發器 |
一次watch可以監聽所有的事件 |
數據模型 |
基于目錄的層次模式 |
參考了zk的數據模型,是個扁平的kv模型 |
存儲 |
kv存儲,使用的是 Concurrent HashMap,內存存儲,一般不建議存儲較多數據 |
kv存儲,使用bbolt存儲引擎,可以處理幾個GB的數據。 |
支持mvcc |
不支持 |
etcd支持mvcc,通過兩個b+tree進行版本控制 |
權限校驗 |
實現的 ACL |
etcd 實現了 RBAC 的權限檢驗 |
事務能力 |
提供了簡易的事務能力 |
只提供了版本號的檢查能力 |
在實際的業務場景中具體選擇哪個產品,要考慮自己的業務場景,考慮具體的特性,開發語言等等。目前zookeeper 是用 java 語言的,被 Apache 很多項目采用,etcd 是用 go 開發的,在云原生的領域使用比較多。不過從實現上看,etcd提供了更穩定的高負載穩定讀寫能力。
結尾
綜上所述,zookeeper是一個比較成熟的,經過市場驗證的分布式協調框架,可以協助我們快速地解決分布式系統中遇到的一些難題。另從上面的介紹中發現,zookeeper的核心是zab,etcd的核心是raft,那可以思考下,還有哪些一致性算法?在分布式存儲的架構里中又有哪些關聯呢呢?這篇文章不做詳細介紹了,后面會針對這塊做個詳細講解。