一文讓你搞懂 zookeeper

什麼是 zookeeper

zookeeper 是 Apache 開源的一個頂級項目,目的是爲分佈式應用提供協調服務,當然 zookeeper 本身也是分佈式的。

而從設計模式的角度來理解:zookeeper 是一個基於觀察者模式設計的分佈式服務管理框架,它負責存儲和管理大家都關心的數據,然後接收觀察者的註冊。一旦數據的狀態發生變化,zookeeper 就會通知那些已經註冊的觀察者,以便它們能夠及時做出反應。

所以 zookeeper 可以看作是一個文件系統 + 通知機制。文件系統指的是 zookeeper 可以存儲數據,儘管數據量比較少,但還是像文件一樣可以存儲的;而通知機制指的是當數據有變化,會立即通知觀察者。

那麼 zookeeper 都有哪些特點呢?

1)zookeeper 本身也是分佈式的,可以組成集羣。zookeeper 集羣由一個領導者節點(Leader)和多個追隨者節點(Follower)組成,Leader 負責接收寫請求,Follower 負責和 Leader 之間進行數據同步並接收讀請求。

2)集羣中只要有半數以上的節點存活,zookeeper 集羣就能正常服務,所以集羣內部的節點數量最好是奇數個。

3)zookeeper 是 CP 模型,在一致性和可用性之間選擇了一致性,因此集羣裏面的數據是全局一致的,每個 Server 都保存了一份相同的數據副本。客戶端無論連接到哪一個 Server,數據都是一致的。這也意味着 Leader 只有將新數據同步給所有的 Follower 之後,整個 zookeeper 集羣才能對外提供服務,否則客戶端就有可能讀到舊數據。因爲根據 CAP 理論,在保證 P 的前提下,C 和 A 是不可兼顧的,至於選擇哪一個則看是否對數據有強一致性的要求。而 zookeeper 存儲的數據一般都不大,所以選擇了一致性。

4)寫請求順序進行,來自同一個 client 的寫請求按其發送順序依次執行。

5)實時性,client 可以很快地讀到最新數據。雖然 Leader 和 Follower 之間的數據同步需要一定時間,但 zookeeper 保存的數據量很小,因此同步速度非常快。

zookeeper 的數據結構

zookeeper 數據結構和 UNIX 文件系統很類似,整體上可以看做是一棵樹,節點被稱爲 ZNode。每個 ZNode 默認能夠存儲 1MB 的數據,因爲 zookeeper 是 CP 模型,所以它不適合存儲大量的數據,只適合存儲一些簡單的配置信息。此外,每個節點都可以通過路徑進行唯一標識,我們通過 ZNode 的路徑即可獲取某個 ZNode 存儲的數據。

整體還是很好理解的,但是要明白,ZNode 能夠存儲的數據量比較少,不應該超過 1MB。

zookeeper 的應用場景

zookeeper 在生產上都能解決哪些問題呢?其實能解決的問題還蠻多的,比如統一命名服務、統一配置管理、統一集羣管理、服務器節點動態上下線、軟負載均衡等等。下面一個一個介紹。

統一命名服務:

在分佈式環境下,經常需要對應用 / 服務進行統一命名,便於識別。例如:IP 不容易記住,但是域名容易記住。

當訪問域名的時候,會自動轉發到某個服務器當中。

統一配置管理:

分佈式環境下,配置文件同步非常常見。一個集羣中,所有節點的配置信息是一致的,對配置文件修改之後,希望能夠快速同步到各個節點上。比如 kafka 集羣,當然 kafka 自帶 zookeeper,但是我們一般不用自帶的。

配置管理可交由 zookeeper 實現,可將配置信息寫入 zookeeper 的一個 ZNode,各個客戶端監聽這個 ZNode。一旦 ZNode 中的數據被修改,zookeeper 將通知各個客戶端,這樣一來每個客戶端讀到的配置信息都是一致的。

統一集羣管理:

分佈式環境中,實時掌握每個節點的狀態是必要的,這樣便可根據節點的實時狀態做出一些調整。

而 zookeeper 可以實現實時監控節點的變化,通過將節點信息寫入 zookeeper 的一個 ZNode,監聽這個 ZNode 便可獲取它的實時狀態變化。

此外每一個客戶端的狀態也可以寫到節點上面,只要狀態發生變化,就會更新節點上客戶端的數據。只要數據發生更新,會立刻同步到其他的節點上,從而通知其他的客戶端。

服務器動態上下線:

客戶端能實時洞察到服務器上線的情況,還是最開始說的,如果某臺服務器宕機,比如 server3。那麼客戶端就會被 zookeeper 通知,之後就不會再請求 server3 了。

當然這只是宕機的情況,如果 server3 修好了重新上線,那麼 zookeeper 也要通知客戶端。客戶端會再次重新註冊監聽,之後仍然可以訪問 server3。

軟負載均衡:

在 zookeeper 中記錄每臺服務器的訪問數,讓訪問數最少的服務器去處理最新的客戶端請求。

當新的客戶端來訪問的時候,會自動分發到訪問次數比較少的服務器上。也就是類似 Nginx 負載均衡的效果,讓每一臺服務器的壓力都不會那麼大。

安裝 zookeeper 單機版

下面安裝 zookeeper,由於它是 Apache 的一個頂級項目,所以域名是 zookeeper.apache.org,所有 Apache 的頂級項目的官網都是以項目名. apache.org 來命名的。

點擊 Download 即可下載,這裏我們選擇的版本是 3.5.10,下載之後扔到服務器上。由於 zookeeper 是基於 Java 語言編寫的,所以還需要安裝 JDK,這裏我使用的是 JDK1.8,都已經已經安裝好了,並配置了環境變量。

我們安裝完畢之後不能直接用,還需要修改一下 zookeeper 的配置文件。在安裝目錄的 conf 目錄下,裏面有一個 zoo_sample.cfg,我們將其重命名爲 zoo.cfg,然後打開。

裏面有一個 dataDir 參數,表示數據的存儲目錄,數據在持久化之後會存儲在該目錄中,以防止數據丟失。但該目錄默認位於臨時目錄 /tmp 下面,這樣當節點重啓之後數據就沒了,所以需要換一個目錄(要提前創建好),至於目錄名無所謂,我這裏叫 zkData。

關於配置文件裏的其他參數,我們之後會解讀,下面先來啓動服務。

bin 目錄下有很多腳本,其中 .cmd 文件是在 Windows 上使用的,不用管。然後我們看到有一個 zkServer.sh,它就是負責啓動 zookeeper 服務的。

啓動成功,我們調用 jps 查看進程。

凡是基於 Java 語言編寫的框架,在啓動之後,都可以通過 jps 查看相應的進程。

要是看到輸出了 QuorumPeerMain 就代表 zookeeper 啓動成功了,如果想停止服務,可以通過 zkServer.sh stop,重啓則是 zkServer.sh restart。

啓動之後,我們也可以查看狀態。

此時的模式是 standalone 模式,表示單機,當然後面我們也會搭建集羣。

既然有了服務端,那麼是不是也要有客戶端呢,對的,類似於 Redis。下面啓動客戶端,直接 zkCli.sh 即可,不需要 start,出現如下表示啓動成功。

關於 zookeeper 客戶端的命令,一會兒詳細介紹,我們來解讀一下 zookeeper 的配置文件。

tickTime=2000
initLimit=10
syncLimit=5
dataDir=/opt/apache-zookeeper-3.5.10-bin/zkData
clientPort=2181
maxClientCnxns=60
autopurge.snapRetainCount=3
autopurge.purgeInterval=1

配置項還是比較少的,解釋一下它們的含義。

配置文件還是非常簡單的,以上我們就完成了 zookeeper 單機版的安裝。

搭建 zookeeper 集羣

我們之前說了,zookeeper 集羣是由一個領導者和多個追隨者組成,但這個領導者是怎麼選出來的呢?我們貌似沒有在配置文件中看到有關領導者和追隨者的參數啊。

在此之前先來看看 zookeeper 內部的一些機制:

那麼領導者到底是怎麼選出來的呢?很簡單,每臺服務器都有一個 id(這裏的 id 後面說),當啓動的服務器超過半數的時候,就會選擇 id 最大的 server 成爲領導者。比如有五臺服務器,半數就是 2.5,因此當啓動三臺的時候就可以選出領導者。至於剩餘的兩臺,啓動之後只能成爲追隨者,因爲領導者已經選出來了。關於這裏的細節,一會兒再詳細聊。

那麼怎麼指定服務器的 id 呢?還記得配置文件中的 dataDir 參數嗎,在該參數指定的目錄下創建一個 myid 文件(文件必須叫這個名字),然後在裏面寫上服務器的 id 即可。

[root@satori zkData]# echo 2 > myid

這裏給 id 設置爲 2,因爲一會要搭建由三個節點組成的集羣,而我希望當前節點成爲 Leader,所以它的 id 應該爲 2,其它的兩個節點的 id 顯然分別爲 1 和 3。這樣按着 id 從小到大的順序啓動時,該節點就會成爲 Leader。

下面來我們來搭建 zookeeper 集羣,總共三個節點:

satori 節點就是當前一直在用的節點,剩餘的兩個節點的 zookeeper 也已經安裝完畢。那麼問題來了,我們要如何將這三個節點組成一個集羣呢?顯然還需要修改配置文件,先在 satori 節點進行修改。

# koishi 節點
server.1=121.37.165.252:2888:3888
# satori 節點
server.2=0.0.0.0:2888:3888
# marisa 節點
server.3=123.60.7.226:2888:3888

將集羣中都有哪些節點寫在 zoo.cfg 中,解釋一下具體含義,首先兩個冒號把等號右邊分成了三部分,第一部分就不用說了,IP 地址或者主機名,用於定位節點;2888 是 Leader 和 Follower 交換信息的端口,因爲副本要進行同步;3888 是交換選舉信息的端口,因爲要選出 Leader。

然後我們注意到 satori 節點的 IP 設置成了 0.0.0.0,這是因爲當前的三個節點不在同一個網段,IP 用的都是公網 IP,而公網 IP 在綁定服務的時候會失敗。所以在綁定的時候,其它節點的 IP 要寫成公網 IP,自身節點的 IP 要寫成 0.0.0.0。因此其它兩個節點的 zoo.cfg 文件就應該這麼改:

########## koishi 節點配置 ##########
# koishi 節點
server.1=0.0.0.0:2888:3888
# satori 節點
server.2=82.157.146.194:2888:3888
# marisa 節點
server.3=123.60.7.226:2888:3888

########## marisa 節點配置 ##########
# koishi 節點
server.1=121.37.165.252:2888:3888
# satori 節點
server.2=82.157.146.194:2888:3888
# marisa 節點
server.3=0.0.0.0:2888:3888

但是在生產中,一個集羣內的節點應該都位於同一網段,然後將配置文件中的 IP 全部換成內網 IP 即可。這樣彼此之間可以通過內網訪問,而內網的訪問速度要遠遠快於公網,並且還不需要走公網的流量。但我當前的三臺雲服務器不在同一個網段,所以只能用公網 IP,並且綁定的時候,將節點自身的 IP 換成 0.0.0.0。

至於等號左邊的 server. 是固定的,後面的數字表示節點的 id,而節點 id 我們說了,通過在 myid 文件中進行指定。而節點 id 決定了,最終由誰擔任領導者。其中 satori 節點的 id 爲 2,剛剛已經改過了,然後將 koishi 和 marisa 兩個節點的 id 分別改爲 1 和 3,然後就大功告成了。

然後我們來啓動 zookeeper,由於 satori 節點的 zookeeper 已經啓動了,我們在修改完配置文件之後,需要重新啓動。

但是我們查看狀態的時候,發現出錯了,相信原因很好想。因爲配置文件中指定了三個節點,而剩餘兩個節點的 zookeeper 還沒啓動。下面我們來啓動一下,然後再次查看狀態。

當剩餘的兩個節點啓動之後,再次查看狀態,發現 Mode 變成了 Leader。顯然集羣已經啓動成功,至於剩餘的兩個節點,顯然就是 Follower。

此時集羣就啓動成功了,但是關於領導者和追隨者的選舉問題,我們還得再說一說。

領導者是怎麼選出來的

領導者選舉分爲兩種情況:

我們先來看第一種情況,假設集羣當中有 5 個節點,id 分別爲 1 到 5,來看看選舉過程是怎樣的?這裏 5 個節點按照 id 從小到大順序啓動。

所以 5 個節點,啓動 3 個之後就能選擇出 Leader。然後 server4 又啓動了,於是也發起一次選舉,並把票投給自己。但 server1,2,3 已經不是 LOOKING 狀態,所以它們不會更改自己的選票信息,最終結果 server3 仍有 3 票,server4 只有 1 票。少數服從多數,於是會再將自己的選票交給 server3,成爲 Follower,狀態改爲 FOLLOWING。

同理,最後 server5 啓動,結果就是 server3 有 4 票,自己只有 1 票。少數服從多數,於是將自己的選票交給 server3,成爲 Follower。

所以整個過程,關鍵點有兩個:

以上就是集羣第一次啓動的時候,選舉領導者。但如果在運行過程中,領導者掛了該怎麼辦呢?顯然要再選舉出一個新的領導者。所以當集羣中的追隨者發現自己連接不上領導者的時候,就會開始進入 Leader 選舉,但此時是存在兩種可能的。

先來解釋第二種情況,server5 認爲 server3 掛了之後,便會發起 Leader 選舉,呼籲其它追隨者進行投票。但是其它追隨者發現領導者並沒有掛,於是會拒絕 server5 的選舉申請,並告知它當前已存在的領導者信息。對於 server5 而言,只需要和已存在的領導者重新建立連接,並進行數據同步即可。

server3:老子還沒掛呢,莫要造反。

但如果是第一種情況,領導者真的掛了,該怎麼辦?比如這裏的領導者 server3,在運行的時候,節點突然宕機了。

要解釋這個問題,我們需要引入一些新的概念。

現在假設 server3 掛了,那麼要重新選舉 Leader,而選舉規則如下。

關於這麼做背後的原理,我們先暫且不表,等到後面介紹 Paxos 協議的時候再細說。而且這裏的 epoch 具體是幹什麼用的,估計也有人不太清楚,這些我們也留到後面再說。

客戶端命令行操作

我們已經搭建好了 zookeeper 集羣,接下來就是啓動客戶端,在裏面輸入增刪改查相關的命令,然後發送給服務端執行,就類似於 Redis 一樣。

# 輸入 zkCli.sh 即可啓動
# 會自動連接本地的 zookeeper 服務端
# 如果想連接其它節點的端,那麼需要加上 -server 參數
# 比如 zkCli.sh -server ip:2181
[root@satori ~]# zkCli.sh

回車之後,客戶端便可連接至 Leader 節點。

然後來看看命令都有哪些?

1)ls:顯示某個路徑下的所有節點

2)ls2:顯示某個路徑下的所有節點,以及當前節點的詳細信息。但是該參數已經廢棄,推薦使用 ls -s

不但顯示根節點下面的所有節點,還顯示了當前根節點的詳細信息,就是綠色框框內的部分。那麼它們都代表啥含義呢,來解釋一下。

3)create:創建節點

比如 / 下面只有 zookeeper 這一個節點,我們再創建一個新的。

因爲節點是用來存儲數據的,所以創建節點的時候也應該指定相應的值,正如 Redis 在 set 一個 key 的時候也要指定 value 一樣。當然不指定也可以,只不過不指定的話相當於值爲 null。

通過 create 創建的節點默認是持久節點,那麼什麼是持久節點呢?首先 zookeeper 的節點是有類型的,可以分爲持久節點和臨時節點:

此外節點還可以帶編號和不帶編號,如果帶編號的話,zookeeper 會自動在節點的末尾加上一串數字。比如上面的 /ow,它默認是不帶編號的,如果我們創建的是帶編號的,那麼節點創建之後就會變成 /ow001。

編號會依次遞增,因此帶編號的節點也叫做順序節點。

因此組合起來,zookeeper 的節點類型總共有 4 種。其中使用 zookeeper 作爲分佈式鎖,便是基於臨時順序節點實現的。多個客戶端同時往 zookeeper 上面創建臨時順序節點,誰的編號最小,那麼誰就先創建成功,我們就認爲它拿到了分佈式鎖。

當客戶端操作完共享數據需要釋放鎖的時候,只需要斷開連接即可,這樣該客戶端創建的臨時節點就會自動刪除。一旦節點刪除,那麼它的下一個順序節點就成了編號最小的節點,從而拿到分佈式鎖,因此這個機制就避免了因客戶端掛掉而導致的死鎖問題。

順序節點非常有用,特別是在分佈式系統中,編號可以用於爲所有事件進行全局排序,這樣客戶端通過順序號就能推斷事件的順序。

使用 create 創建的節點默認是持久非順序節點,那麼其它類型的節點怎麼創建呢?

4)create -e:創建臨時節點

臨時節點創建完畢,如果此時客戶端斷開連接,臨時節點就會被刪除。

我們重啓客戶端,再次查看,發現臨時節點已經被刪除了。

5)create -s:創建順序(帶編號)節點****

創建的時候,自動在結尾加上編號。我這裏之前創建過幾個,現在編號是從 11 開始,總之順序節點的編號是遞增的,只會增大,不會減小。

所以 -e 表示臨時節點,-s 表示順序節點,那如果創建臨時順序節點呢?很簡單,兩個參數一塊指定即可。

客戶端退出之後,這個臨時節點就會消失。

然後再次創建,發現編號從 14 開始,因爲順序節點的編號只會依次增加。

6)get:獲取節點內容

如果加上 -s 參數,還可以獲取節點的詳細信息。

/china 節點存儲的值是 beijing,/china/henan 節點存儲的值是 zhengzhou。所以 zookeeper 的數據結構就類似一個樹,樹上的每一個節點都可以存儲具體的值,並且節點之間具有父子關係。

7)set:修改節點內容

create 表示創建一個新的節點,每個節點會存儲一個值,get 表示獲取節點存儲的值,set 表示修改節點存儲的值。

需要注意的是,節點不能重複,所以我們不能這麼做:

因爲 /china/henan 這個節點已經存在了,我們不能重複 create,所以要修改節點的值的話,應該使用 set。

8)get -w:監聽某個節點的值的變化

假設現在有兩個客戶端同時連接至 zookeeper 集羣,客戶端 A 執行 get -w /china 就表示監聽 /china 這個節點。然後在客戶端 B 上面對 /china 這個節點進行 set,那麼 A 機器上就會收到提示,提示我們監聽的節點被修改了。

注意:監聽是一次性的,如果再次 set 的話,那麼 A 機器就不會再提示了,除非再次 watch。另外除了節點的值被修改之外會提示,當節點被刪除時也會提示。

那麼這背後的原理是怎麼實現的呢?首先監聽的時候,客戶端會創建兩個子線程,一個負責網絡通信(connector),另一個負責監聽(listener)。通過 connector 將註冊的監聽事件發送給服務端,服務端將註冊的監聽事件添加進註冊監聽器列表中。

當服務端監聽到有數據變化,就會將這個消息發送給 listener 線程,然後 listener 線程將消息輸出出來。

9)ls -w:監聽某個節點的子節點變化

當新建一個子節點、或者刪除一個子節點的時候,就會收到提示,但是修改不會,所以這裏監聽的變化指的是子節點數量的變化。

注意:這裏只監聽子節點的變化,子節點的子節點則不在範圍之內。至於實現原理,和 get -w 相同,並且執行 ls -w 之後也只會監聽一次。

10)delete:刪除節點

注意:delete 只能刪除葉子節點,而非葉子節點、比如這裏的 /china 就無法刪除。

在 3.5.0 之前刪除非葉子節點使用的命令是 rmr,當然現在也可以使用,只不過廢棄了。

11)stat:查看節點狀態

這些字段的含義我們已經介紹過了,還可以通過 ls -s 或者 get -s 獲取。

Python 連接 zookeeper

下面來看看如何用 Python 充當客戶端,連接 zookeeper,之所以要介紹 Python 是因爲筆者是 Python 系的。

Python 連接 zookeeper 的話,需要安裝第三方模塊,模塊名叫 kazoo,直接 pip 安裝即可。其實連接 zookeeper 還有一個模塊,也叫 zookeeper,但是個人更推薦 kazoo,因爲它是純 Python 實現的,使用起來更方便。

from kazoo.client import KazooClient

hosts = [
    "82.157.146.194:2181",  # satori 節點
    "121.37.165.252:2181",  # koishi 節點
    "123.60.7.226:2181",    # marisa 節點
]
# 輸入 ip:port,創建 zookeeper 客戶端
# 多個節點之間,使用逗號分隔
client = KazooClient(",".join(hosts))
# 和 zookeeper 集羣建立連接
client.start()

"""
start 函數里面接收一個 timeout,默認是 15 秒
如果服務沒有啓動(目標計算機積極拒絕)
那麼會不斷地嘗試連接,直到超時
"""

並且連接一旦建立,無論是連接丟失、還是會話過期,KazooClient 都會不斷地嘗試連接。另外當客戶端不需要再使用的時候,還可以調用 stop 方法,顯式地中斷連接。

下面來看看相關的 API。

# ls
print(client.get_children("/"))
"""
['xyz', 'zookeeper', 'china']
"""

# 查詢某個節點是否存在
# 不存在返回 None,存在則返回節點的信息
# 相當於 stat
print(client.exists("/abc"))
"""
None
"""
print(client.exists("/china"))
"""
ZnodeStat(czxid=17179869187, mzxid=17179869193, 
          ctime=1664981696194, mtime=1664981963644, 
          version=4, cversion=1, aversion=0, 
          ephemeralOwner=0, dataLength=6, numChildren=1,
          pzxid=17179869188)

"""

# 創建節點(可以遞歸創建)
client.ensure_path("/中國/四川/成都")
print(client.get_children("/"))
print(client.get_children("/中國"))
print(client.get_children("/中國/四川"))
"""
['xyz', '中國', 'zookeeper', 'china']
['四川']
['成都']
"""

# ensure_path 只能創建節點,不能添加數據
# 如果想添加數據,需要使用 set 修改
# 但是有一點需要注意,set 的值,必須是 bytes 類型
client.set("/中國", b"CHINA")
client.set("/中國/四川", b"SICHUAN")
client.set("/中國/四川/成都", b"CHENGDU")
# 使用 set 修改,get 獲取
# 這個 zkCli.sh 的 API 是一致的
print(client.get("/中國"))
print(client.get("/中國/四川"))
print(client.get("/中國/四川/成都"))
"""
(b'CHINA', ZnodeStat(czxid=17179869208, ...))
(b'SICHUAN', ZnodeStat(czxid=17179869209, ...))
(b'CHENGDU', ZnodeStat(czxid=17179869210, ...))
"""
# 會將值和節點狀態一起返回

# 創建節點除了使用 ensure_path,還可以使用 create
# ensure_path 在創建節點的時候,不要求上一級必須存在,會遞歸創建
# 但是 create 創建的時候,要求上一級必須存在
# 並且 ensure_path 創建節點時不可以指定數據,只能後續set修改
# 但是 create 在創建節點時可以指定數據
client.create("/中國/上海", b"shanghai",
              # 是否是順序節點,默認爲 False
              sequence=False,
              # 是否是臨時節點,默認爲 False
              ephemeral=False)
# 查看 /中國 的子節點,會多出一個 "上海"
print(client.get_children("/中國"))
"""
['四川', '上海']
"""
print(client.get("/中國/上海"))
"""
(b'shanghai', ZnodeStat(czxid=17179869233, ...))
"""

# 刪除節點
print(client.get_children("/中國"))
"""
['四川', '上海']
"""
client.delete("/中國/上海")
print(client.get_children("/中國"))
"""
['四川']
"""
# delete 方法默認只能刪除葉子節點
# 如果想遞歸刪除非葉子節點,需要多指定一個參數
client.delete("/中國"recursive=True)
print(client.get_children("/"))
"""
['xyz', 'zookeeper', 'china']
"""

然後還有監聽,監聽的話,可以監聽節點存儲的數據的變化,也可以監聽節點下面的子節點數量的變化。

def watch(event):
    print("觀察者發現變化了")
    print(event)

# 執行 watch=True,即可開啓監聽
# 相當於 get -w
client.get("/xyz"watch=watch)
# 相當於 ls -w
client.get_children("/xyz"watch=watch)

# 此時程序不會阻塞,如果在客戶端退出之前
# 節點變化了,則觸發 watch 函數的執行

# 或者還可以使用裝飾器的方式
@client.DataWatch("/xyz")  # get -w
def watch1(event): pass

@client.ChildrenWatch("/xyz")  # ls -w
def watch2(event): pass

以上就是 Python 連接 zookeeper 的一些操作,還是很簡單的。因爲使用 zookeeper 本身也不會做太多複雜的操作,就是把它當成是一個配置中心,用於簡單地數據存儲以及數據監聽。

數據是怎麼寫入的

無論是 zookeeper 自帶的客戶端 zkCli.sh,還是使用 Python(或者其它語言)實現的客戶端,本質上都是連接至集羣,然後往裏面讀寫數據。那麼問題來了,集羣在收到來自客戶端的寫請求時,是怎麼寫入數據的呢?

另外客戶端在訪問集羣的時候,本質上是訪問集羣內的某一個節點,而根據訪問的節點是領導者還是追隨者,寫入數據的過程也會有所不同。

先來看看當訪問的節點是領導者的情況:

這裏面有一個關鍵的地方,就是 Leader 不會等到所有的 Follower 都寫完,只要有一半的 Follower 寫完,就會告知客戶端。還是半數機制,一半的 Follower 加上 Leader 正好剛過半數。而這麼做的原因也很簡單,就是爲了快速響應。

再來看另一種情況,如果客戶端訪問的節點是追隨者,情況會怎麼樣呢?其實很簡單,由於追隨者沒有寫權限,那麼會先將寫請求轉發給領導者,然後接下來的步驟和上面類似,只是最後一步不同。

當 Leader 發現有半數的 Follower 寫完,就認爲寫數據成功,於是返回 ack。但這個 ack 不會返回給客戶端,因爲客戶端訪問的不是領導者,最終領導者會將 ack 返回給客戶端訪問的追隨者,再由這個追隨者將 ack 返回給客戶端,告知寫請求已執行完畢。

基於 zookeeper 實現分佈式鎖

關於分佈式鎖,我之前介紹過如何基於 Redis 實現分佈式鎖,裏面對分佈式鎖做了比較詳細的解析。如果你還不清楚分佈式鎖的相關概念,可以先看這篇文章,下面來聊一聊如何基於 zookeeper 實現分佈式鎖。

先來說一下原理,當客戶端需要操作共享資源時,需要先往 zookeeper 集羣中創建一個臨時順序節點。然後查看對應的編號,如果沒有比它小的,說明最先創建,我們就認爲客戶端拿到了分佈式鎖。

如果客戶端發現節點的編號不是最小的,說明已經有人先創建了,也就是鎖已經被別的客戶端拿走了。那麼該客戶端會對前一個節點進行監聽,等待釋放。

所以從概念上還是很好理解的,然後我們來編程實現一下。

from typing import List
import queue
from kazoo.client import KazooClient

class DistributedLock:

    def __init__(self, hosts: List[str]):
        """
        :param hosts: 'ip1:port1,...'
        """
        self.client = KazooClient(",".join(hosts))
        self.client.start()
        # 要在 /lock 節點下面創建臨時順序節點
        # 所以先保證 /lock 節點存在
        if not self.client.exists("/lock"):
            self.client.create("/lock")

        # 要創建的臨時順序節點
        self.cur_node = None
        # 要監聽的節點(也就是上一個節點)
        self.prev_node = None
        # 本地隊列
        self.q = queue.Queue()

    def acquire(self):
        """
        獲取鎖
        :return:
        """
        self.cur_node = self.client.create(
            "/lock/seq-",
            # 臨時順序節點
            ephemeral=True,
            sequence=True
        )
        # create 方法會返回創建的節點名稱
        # 需要判斷編號是不是最小的
        # 因此要拿到所有的節點
        nodes = self.client.get_children("/lock")
        # nodes: ["seq-000..0", "seq-000...1"]
        nodes.sort()
        if len(nodes) == 1:
            return True
        elif "/lock/" + nodes[0] == self.cur_node:
            # 如果 nodes 裏面的最小值和 node 相等
            # 說明該客戶端創建的節點的編號最小
            # 於是我們就認爲它拿到了分佈式鎖
            return True
        # 否則說明不是最小,因此要找到它的上一個節點
        # 也就是要監聽的節點
        index = nodes.index(self.cur_node.split("/")[-1])
        self.prev_node = "/lock/" + nodes[index - 1]
        # 對上一個節點進行監聽
        self.client.get(self.prev_node, watch=self.watch)
        # 這一步不是阻塞的,但程序必須要拿到鎖之後纔可以執行
        # 所以我們要顯式地讓程序阻塞在這裏
        self.q.get()
        return True

    def release(self):
        """
        釋放鎖
        :return:
        """
        self.client.delete(self.cur_node)

    def watch(self, event):
        """
        監聽函數,參數 event 是一個 namedtuple
        kazoo.protocol.states.WatchedEvent
        裏面有三個字段:type、state、path

        監聽節點的值被改變時,type 爲 "CHANGED"
        監聽節點被刪除時,type 爲 "DELETED"

        path 就是監聽的節點本身

        state 表示客戶端和服務端之間的連接狀態
        建立連接時,狀態爲 LOST
        連接建立成功,狀態爲 CONNECTED
        如果在整個會話的生命週期裏,伴隨着網絡閃斷、服務端異常
        或者其他什麼原因導致客戶端和服務端連接斷開,狀態爲 SUSPENDED
        與此同時,KazooClient 會不斷嘗試與服務端建立連接,直至超時
        如果連接建立成功了,那麼狀態會再次切換到 CONNECTED
        """
        if event.type == "DELETED" and \
            self.prev_node == event.path:
            # 往隊列裏面扔一個元素
            # 讓下一個節點解除阻塞
            self.q.put(None)

# 測試函數
def test(lock, name):
    lock.acquire()
    print(f"{name}獲得鎖,其它人等着吧")
    print(f"{name}處理業務······")
    print(f"{name}處理完畢,釋放鎖")
    lock.release()

if __name__ == '__main__':
    import threading
    hosts = [
        "82.157.146.194:2181",  
        "121.37.165.252:2181",  
        "123.60.7.226:2181",    
    ]
    # 創建三把鎖
    lock1 = DistributedLock(hosts)
    lock2 = DistributedLock(hosts)
    lock3 = DistributedLock(hosts)
    threading.Thread(
        target=test, args=(lock1, "客戶端1")
    ).start()
    threading.Thread(
        target=test, args=(lock2, "客戶端2")
    ).start()
    threading.Thread(
        target=test, args=(lock3, "客戶端3")
    ).start()
"""
客戶端1獲得鎖,其它人等着吧
客戶端1處理業務······
客戶端1處理完畢,釋放鎖
客戶端3獲得鎖,其它人等着吧
客戶端3處理業務······
客戶端3處理完畢,釋放鎖
客戶端2獲得鎖,其它人等着吧
客戶端2處理業務······
客戶端2處理完畢,釋放鎖
"""

實現起來不是很難,並且使用 zookeeper 的好處就是,我們不需要擔心死鎖的問題。因爲客戶端宕掉之後,臨時節點會自動刪除,但缺點是性能沒有 Redis 高。

另外值得一提的是,kazoo 已經幫我們實現好了分佈式鎖,開箱即用,我們就不需要再手動實現了。

# 創建客戶端
client = KazooClient(",".join(hosts))
client.start()
# 此時需要自己手動給一個唯一標識
lock = client.Lock("/lock""unique-identifier")
# 獲取鎖
lock.acquire()
# 處理業務邏輯
...
# 釋放鎖
lock.release()
# 或者也可以使用上下文管理器
with lock:
    ...

顯然就優雅多了,藉助於 kazoo 實現好的分佈式鎖,可以減輕我們的心智負擔。此外 kazoo 還提供了讀鎖和寫鎖:

我們一般使用 client.Lock 就行,可以自己測試一下。

關於 zookeeper 的基礎內容就介紹到這裏,但伴隨着 zookeeper 還有一系列的協議,比如 Paxos 協議、ZAB 協議、CAP 定理等等,這些可謂是分佈式系統的重中之重。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/uUjP0KDd2bEFazyZrfV3MA