ZooKeeper 的設(shè)計(jì)精髓、工作原理和實(shí)際應(yīng)用
ZooKeeper 是什么?為什么要造這個(gè)輪子?
想象一下,在一個(gè)大型分布式系統(tǒng)里,成百上千臺(tái)服務(wù)器協(xié)同工作。這時(shí)候,會(huì)涌現(xiàn)出一大堆“雞毛蒜皮”的協(xié)調(diào)問(wèn)題:
- 配置管理 (Configuration Management) :某個(gè)配置項(xiàng)變了,如何讓所有機(jī)器都收到最新的配置?
- 服務(wù)發(fā)現(xiàn) (Service Discovery) :系統(tǒng)中誰(shuí)是主服務(wù)器(Leader)?哪些工作節(jié)點(diǎn)(Worker)還活著?新上線的服務(wù),它的地址和端口是什么?
- 分布式鎖 (Distributed Lock) :多個(gè)進(jìn)程要搶占同一個(gè)關(guān)鍵資源,怎么保證同一時(shí)間只有一個(gè)能拿到?
- 組成員管理 (Group Membership) :如何維護(hù)一個(gè)集群中所有在線成員的列表?
為每個(gè)問(wèn)題都專門開(kāi)發(fā)一套高可用的服務(wù),不僅費(fèi)時(shí)費(fèi)力,而且很容易出錯(cuò)。ZooKeeper 的目標(biāo),就是提供一個(gè)通用的、高性能的“協(xié)調(diào)內(nèi)核”。它本身不直接提供復(fù)雜的分布式鎖或領(lǐng)導(dǎo)者選舉等功能,而是提供一套足夠基礎(chǔ)、足夠強(qiáng)大的 API,讓開(kāi)發(fā)者可以在它的基礎(chǔ)上,像搭積木一樣輕松構(gòu)建出自己需要的、更復(fù)雜的協(xié)調(diào)“原語(yǔ)”(Primitives)。
它的設(shè)計(jì)哲學(xué)是“大道至簡(jiǎn)”:服務(wù)器端只做最核心、最簡(jiǎn)單的事,從而保證高性能和高可靠性,而將復(fù)雜性留給客戶端去實(shí)現(xiàn)。
核心設(shè)計(jì):像文件系統(tǒng)一樣簡(jiǎn)單
ZooKeeper 對(duì)外暴露的接口非常像一個(gè)精簡(jiǎn)版的文件系統(tǒng)。它的核心數(shù)據(jù)模型是一個(gè)樹(shù)狀的層級(jí)命名空間,由許多被稱為 數(shù)據(jù)節(jié)點(diǎn) (znodes) 的單元組成。
/
├── app1
│ ├── p_1 (ephemeral)
│ ├── p_2 (ephemeral)
│ └── p_3 (ephemeral)
└── app2
├── config
└── lock
├── write-0000000001
└── read-0000000002每個(gè) znode 都可以存儲(chǔ)少量數(shù)據(jù)(默認(rèn)不超過(guò) 1MB),通常是用于協(xié)調(diào)的元數(shù)據(jù),比如狀態(tài)信息、配置參數(shù)或者節(jié)點(diǎn)地址。
Znode 有幾種非常關(guān)鍵的類型:
- 常規(guī)節(jié)點(diǎn) (Regular) :需要客戶端顯式地創(chuàng)建和刪除。
- 臨時(shí)節(jié)點(diǎn) (Ephemeral) :這種節(jié)點(diǎn)的生命周期與創(chuàng)建它的客戶端 會(huì)話 (session) 綁定。當(dāng)客戶端與 ZooKeeper 的連接斷開(kāi),會(huì)話超時(shí)結(jié)束后,這個(gè)臨時(shí)節(jié)點(diǎn)就會(huì)被自動(dòng)刪除。這個(gè)特性是實(shí)現(xiàn)服務(wù)發(fā)現(xiàn)和故障檢測(cè)的利器。
- 順序節(jié)點(diǎn) (Sequential) :創(chuàng)建時(shí),ZooKeeper 會(huì)在節(jié)點(diǎn)路徑后面自動(dòng)追加一個(gè)單調(diào)遞增的數(shù)字序號(hào)。比如,在
/app2/lock/下創(chuàng)建一個(gè)名為write-的順序節(jié)點(diǎn),可能會(huì)得到write-0000000001、write-0000000002這樣的路徑。這個(gè)特性對(duì)于實(shí)現(xiàn)分布式鎖和隊(duì)列至關(guān)重要,可以有效避免“羊群效應(yīng)”。
核心機(jī)制:Watch 事件通知
如果客戶端想知道某個(gè) znode(比如存儲(chǔ)著主節(jié)點(diǎn)地址的 znode)有沒(méi)有變化,難道要不停地去輪詢(Polling)讀取嗎?這顯然效率低下,而且會(huì)給 ZooKeeper 服務(wù)帶來(lái)巨大壓力。
為此,ZooKeeper 引入了 監(jiān)視 (Watch) 機(jī)制??蛻舳嗽谧x取一個(gè) znode 時(shí),可以設(shè)置一個(gè) watch 標(biāo)志。當(dāng)這個(gè) znode 發(fā)生變化(被修改、被刪除,或者它的子節(jié)點(diǎn)列表發(fā)生變化)時(shí),ZooKeeper 就會(huì)向該客戶端發(fā)送一個(gè)一次性的通知??蛻舳耸盏酵ㄖ螅椭雷约罕镜鼐彺娴臄?shù)據(jù)已經(jīng)“過(guò)時(shí)”了,需要重新來(lái)拉取最新數(shù)據(jù)。
這個(gè)設(shè)計(jì)非常巧妙,它是一種事件驅(qū)動(dòng)的機(jī)制,類似于緩存失效通知,避免了無(wú)效的輪詢,大大提升了效率。
API 和保證:ZooKeeper 的契約
ZooKeeper 提供了一套簡(jiǎn)潔的 API,核心包括:
create(path, data, flags): 創(chuàng)建一個(gè) znode 。delete(path, version): 刪除一個(gè) znode,version參數(shù)用于實(shí)現(xiàn)樂(lè)觀鎖(CAS)。exists(path, watch): 檢查 znode 是否存在,并可以設(shè)置 watch 。getData(path, watch): 獲取 znode 的數(shù)據(jù)和元數(shù)據(jù),并可以設(shè)置 watch 。setData(path, data, version): 更新 znode 的數(shù)據(jù),同樣有version檢查。getChildren(path, watch): 獲取子節(jié)點(diǎn)列表,并可以設(shè)置 watch 。sync(path): 強(qiáng)制后續(xù)的讀操作能看到此sync調(diào)用之前的所有更新。
在這些 API 背后,ZooKeeper 提供了兩條黃金保證:
- 線性化寫入 (Linearizable Writes) :所有會(huì)改變 ZooKeeper 狀態(tài)的寫操作,其執(zhí)行順序是全局一致、可串行化的,并且尊重操作的實(shí)際發(fā)生順序。簡(jiǎn)單說(shuō),就是寫操作絕不會(huì)亂序。這是通過(guò)一個(gè)類似 Raft 的原子廣播協(xié)議 Zab 來(lái)實(shí)現(xiàn)的。
- FIFO 客戶端順序 (FIFO Client Order) :來(lái)自同一個(gè)客戶端的所有請(qǐng)求,會(huì)被嚴(yán)格按照它們發(fā)送的順序來(lái)執(zhí)行。這讓異步操作變得簡(jiǎn)單可靠。比如,客戶端可以先發(fā)一堆寫請(qǐng)求去修改配置,最后發(fā)一個(gè)創(chuàng)建 "ready" 節(jié)點(diǎn)的請(qǐng)求,ZooKeeper 保證 "ready" 節(jié)點(diǎn)一定是在所有配置修改完成后才出現(xiàn)的。
為什么讀操作不保證線性一致性?
這里有一個(gè)關(guān)鍵的設(shè)計(jì)取舍。如果讀操作也要求線性一致性(即必須讀到最新的數(shù)據(jù)),那么所有讀請(qǐng)求都得交給 Leader 處理,或者需要一個(gè)復(fù)雜的讀協(xié)議,這樣就無(wú)法通過(guò)增加服務(wù)器來(lái)擴(kuò)展讀性能。
ZooKeeper 的目標(biāo)應(yīng)用場(chǎng)景通常是“讀多寫少”。為了極大地提升讀的吞吐量,ZooKeeper 允許每個(gè)服務(wù)器副本(Follower)直接用自己的本地內(nèi)存數(shù)據(jù)庫(kù)來(lái)響應(yīng)讀請(qǐng)求。但這樣一來(lái),副本的數(shù)據(jù)可能暫時(shí)落后于 Leader ,導(dǎo)致客戶端可能會(huì)讀到 陳舊數(shù)據(jù) (stale data) 。
這聽(tīng)起來(lái)很危險(xiǎn),但 ZooKeeper 認(rèn)為對(duì)于協(xié)調(diào)服務(wù)來(lái)說(shuō),這種“最終一致”的讀是可以接受的。并且,它提供了 sync() 這個(gè)“后悔藥”,如果某個(gè)讀操作確實(shí)需要最新數(shù)據(jù),可以在讀之前調(diào)用一次 sync() 。sync() 會(huì)強(qiáng)制當(dāng)前客戶端連接的服務(wù)器與 Leader 同步,確保后續(xù)的讀能看到最新的狀態(tài)。
生產(chǎn)實(shí)踐:用 ZooKeeper 搭建協(xié)調(diào)原語(yǔ)
有了 znode、watch 和強(qiáng)大的順序保證,我們就可以構(gòu)建各種上層應(yīng)用了。
動(dòng)態(tài)配置管理
這是最簡(jiǎn)單的用法。將配置信息存放在一個(gè) znode /app/config 中。所有應(yīng)用進(jìn)程啟動(dòng)時(shí)讀取這個(gè) znode 的數(shù)據(jù),并設(shè)置一個(gè) watch 。當(dāng)配置需要變更時(shí),管理員只需修改這個(gè) znode 的內(nèi)容。所有設(shè)置了 watch 的進(jìn)程都會(huì)收到通知,然后重新讀取配置,實(shí)現(xiàn)動(dòng)態(tài)更新。
服務(wù)發(fā)現(xiàn)與組成員管理
利用臨時(shí)節(jié)點(diǎn)可以完美實(shí)現(xiàn)這個(gè)功能。假設(shè)有一個(gè)服務(wù)集群,每個(gè)服務(wù)實(shí)例啟動(dòng)時(shí),都在一個(gè)公共的 znode /service/members 下創(chuàng)建一個(gè)代表自己的臨時(shí)節(jié)點(diǎn),比如 /service/members/instance-1 。節(jié)點(diǎn)的數(shù)據(jù)可以存放該實(shí)例的 IP 和端口。
- 成員發(fā)現(xiàn) :其他客戶端只需
getChildren("/service/members", watch=true),就能獲取當(dāng)前所有在線服務(wù)的列表。 - 故障檢測(cè) :如果某個(gè)服務(wù)實(shí)例崩潰或網(wǎng)絡(luò)斷開(kāi),它與 ZooKeeper 的會(huì)話會(huì)超時(shí),其對(duì)應(yīng)的臨時(shí)節(jié)點(diǎn)會(huì)被自動(dòng)刪除。監(jiān)聽(tīng)
/service/members的其他客戶端會(huì)收到子節(jié)點(diǎn)變化的通知,從而知道有成員下線了。
分布式鎖(避免羊群效應(yīng))
一個(gè)簡(jiǎn)單的鎖可以通過(guò) create("/lock", EPHEMERAL) 來(lái)實(shí)現(xiàn),誰(shuí)創(chuàng)建成功誰(shuí)就獲得鎖。但這會(huì)導(dǎo)致 羊群效應(yīng) (herd effect) :一旦鎖釋放,所有等待的客戶端會(huì)同時(shí)被喚醒,然后蜂擁而上嘗試創(chuàng)建節(jié)點(diǎn),造成瞬間的網(wǎng)絡(luò)風(fēng)暴,而最終只有一個(gè)能成功。
更優(yōu)雅的做法是利用順序節(jié)點(diǎn):
獲取鎖 (Acquire)
- 在鎖目錄
/lock下,創(chuàng)建一個(gè) 臨時(shí)順序節(jié)點(diǎn) ,比如得到/lock/lock-0000000002。 - 獲取
/lock下的所有子節(jié)點(diǎn),并排序。 - 判斷自己創(chuàng)建的節(jié)點(diǎn)是不是序號(hào)最小的。如果是,則成功獲得鎖。
- 如果不是,就找到比自己序號(hào)小一位的節(jié)點(diǎn)(比如
lock-0000000001),并對(duì)它設(shè)置exists(watch=true)。 - 然后等待,直到收到 watch 通知。
- 收到通知后,回到第 2 步,重新檢查自己是不是最小的。
釋放鎖 (Release)
- 客戶端完成任務(wù)后,只需刪除自己創(chuàng)建的那個(gè)臨時(shí)節(jié)點(diǎn)即可。如果客戶端崩潰,節(jié)點(diǎn)也會(huì)自動(dòng)刪除。
這個(gè)方案中,鎖的釋放只會(huì)喚醒隊(duì)列中的下一個(gè)等待者,完美避免了羊群效應(yīng)。
領(lǐng)導(dǎo)者選舉
領(lǐng)導(dǎo)者選舉和分布式鎖非常相似,通常獲勝的進(jìn)程會(huì)把自己的信息寫入一個(gè)約定的 znode,其他進(jìn)程 watch 這個(gè) znode 來(lái)感知 Leader 的變化和存活狀態(tài)。
實(shí)際應(yīng)用與常見(jiàn)問(wèn)題
ZooKeeper 是許多著名開(kāi)源項(xiàng)目的基石,比如:
- Apache Kafka :用它來(lái)存儲(chǔ) Broker 和 Consumer 的元數(shù)據(jù),進(jìn)行領(lǐng)導(dǎo)者選舉等。
- Apache Hadoop/HDFS :用于 NameNode 的高可用方案,選舉 Active NameNode。
- Apache HBase :用于確保集群中只有一個(gè) Master,并存儲(chǔ) Region Server 的狀態(tài)。
常見(jiàn)問(wèn)題與解決方案:
- 問(wèn):客戶端斷線重連到另一個(gè)服務(wù)器,會(huì)不會(huì)讀到“倒退”的數(shù)據(jù)?
答:不會(huì)。客戶端會(huì)話中會(huì)記錄它所見(jiàn)過(guò)的最新事務(wù) ID,即 zxid 。當(dāng)它重連到一個(gè)新服務(wù)器時(shí),新服務(wù)器會(huì)檢查客戶端的 zxid。如果服務(wù)器自己的狀態(tài)比客戶端的還舊,它會(huì)拒絕建立會(huì)話,直到它從 Leader 那里同步到足夠新的狀態(tài)為止。
- 問(wèn):如何處理“羊群效應(yīng)”?
答:如上文所述,使用順序節(jié)點(diǎn)和只 watch 前一個(gè)節(jié)點(diǎn)的策略來(lái)實(shí)現(xiàn)有序、無(wú)驚群的鎖。
問(wèn):會(huì)話超時(shí)時(shí)間應(yīng)該設(shè)多長(zhǎng)?
答:這是一個(gè)權(quán)衡。太短,網(wǎng)絡(luò)抖動(dòng)可能導(dǎo)致節(jié)點(diǎn)被誤判為“死亡”,造成服務(wù)頻繁切換。太長(zhǎng),節(jié)點(diǎn)真的宕機(jī)后,系統(tǒng)需要更長(zhǎng)時(shí)間才能發(fā)現(xiàn)并恢復(fù)。客戶端庫(kù)通常會(huì)在超時(shí)時(shí)間的 1/3 時(shí)發(fā)送心跳,在 2/3 時(shí)間內(nèi)沒(méi)收到響應(yīng)時(shí)就嘗試連接新服務(wù)器,以增加魯棒性。
問(wèn):ZooKeeper 性能如何?
答:讀性能極高,并且可以通過(guò)增加服務(wù)器數(shù)量來(lái)水平擴(kuò)展。寫性能會(huì)隨著服務(wù)器增多而略有下降,因?yàn)?Leader 需要將寫入請(qǐng)求同步給大多數(shù) Follower 。但在現(xiàn)代硬件上,一個(gè)小型集群處理數(shù)萬(wàn)的寫入 QPS 也是可能的。
總而言之,ZooKeeper 通過(guò)提供一個(gè)看似簡(jiǎn)單、實(shí)則經(jīng)過(guò)深思熟慮的數(shù)據(jù)模型和 API,成功地將分布式協(xié)調(diào)中那些最棘手、最普遍的問(wèn)題抽象出來(lái),用一個(gè)可靠、高性能的“內(nèi)核”予以解決。它讓應(yīng)用開(kāi)發(fā)者可以更專注于業(yè)務(wù)邏輯,而不是陷入分布式共識(shí)的泥潭。























