zookeeper使用心得

最近在公司的分布式項目pegasus中用到了zookeeper的c客戶端,在此記錄下使用zookeeper c client時的一些心得。

zookeeper c api的介紹和部分坑

這里先推薦兩處別人寫的blog,分別介紹了c api接口的使用方法, 和一些要注意的坑.后面的內容主要依據自己在使用zk時的心得體會,對這兩篇blog中的內容做補充.

zookeeper client的會話狀態

使用zookeeper的c client端訪問服務的初始化邏輯如下圖所示:

//初始化會話
zhandle_t* handle = zookeeper_init(hosts, watcher_callback ... ); 
...
// 等待zookeeper client會話的狀態轉換為connected
... 
// 其他的zookeeper read/write操作
zookeeper_close(handle);

總的來說,zk C client的api是非常簡潔的:首先創建一個會話用以和服務器列表進行通信;然后便開始調用zk的讀寫api。但就細節上而言,有一點是非常值得注意的:客戶端會話是有“連接狀態”這一概念的,只有狀態是“connected”的會話,才能和服務器進行正常的通信。

會話的狀態是zk客戶端中一個比較容易混淆的概念。下圖給出了client會話一個簡要的狀態轉移圖(基于3.4.6的c client代碼,沒有考慮zk ACL層):


zookeeper-state.png

上圖中幾個狀態的說明如下:

  • not_connected 最開始,未和服務器進行通訊的時候
  • connecting 客戶端正在開始和zookeeper集群服務中的某一臺服務器進行連接
  • associating 客戶端已經和某個服務器建立tcp連接,在等待服務器根據客戶端上傳的會話信息進行"匹配"
  • connected 客戶端已和服務器聯系上,此時可以和服務器發送讀寫請求
  • expired 會話已經超時不可用,客戶端此時應關閉本會話,自殺或者重新發起連接

客戶端在創建handle后,等待會話狀態變為connected的方法一般有以下兩種:

  1. 在調用zookeeper_init生成handle后,開始不斷用zoo_state的api輪詢handle的狀態,并根據返回的狀態值做對應的動作。
  2. 在調用zookeeper_init時,用戶需要注冊一個回調函數。在會話狀態的每一次改變時,回調函數就會被調用一次。所以客戶端的使用者可以根據
    回調函數所傳入的不同狀態執行不同的動作。

在zk會話的整個生命周期中,其狀態可能在connected和connecting之間做多次切換。使用客戶端庫的業務代碼需要注意這兩種情況,并避免在connecting
的時候對zookeeper進行讀寫。

會話狀態轉換的進一步說明

造成client session狀態轉換的原因主要有兩種:

  • zk服務器集中一臺或多臺機器的crash
  • client和server的網絡分區

在發生以上情況時,client會和server失去連接。在這種情況下,clientlib會關掉和當前服務器的socket句柄,開始對集合中的服務器進行輪詢。在重新連上某一臺服務器
后,clientlib會把本地保存的會話簽名(也就是c client庫中的clientid_t結構體)發送給服務器;根據該簽名,服務器可以判斷該會話是否已經超時。客戶端再根據服務器返回的結果,
在回調函數中返回重新連接上,或者是session expired。

由此可知zk一個非常重要的特性:客戶端會話是否超時是由服務器決定的。考慮到zookeeper是由多個機器組成的一個集群服務,這樣的語義其實
是非常合理的:只有把會話生命周期的決定權交給服務器,才能比較好的實現服務在不同機器間的“平滑切換”。

其實對于zk狀態轉換和會話超時做何種處理,是完全取決于使用zk的業務場景的。如果僅僅把zk當做一個保存元數據的服務,那么只要在connected的時候做讀寫即可。
而對于zk最常見的分布式任務的“仲裁者”的功能,在處理客戶端會話的狀態上就要小心很多。

比如很常見的分布式搶鎖的場景(比如:主從分布式系統中的Master):

有一個任務T需要進行持續處理。因為任務比較重要,我們給任務派了A,B,C三個候選機器;這樣,

(a)一旦有一個機器發生宕機,就會有另一個候選者接棒。

同時,因為任務涉及全局狀態的改變。為了保證狀態的一致,我們必須保證

(b)在任意時刻只能有至多一個的候選機器在處理任務T。

對于這種場景,zk有幾乎“模板化”的解決方案,所以就不再搬運了:-)。這里只是想強調一下客戶端會話的狀態在分布式搶鎖中的作用。
為此,我們先大體復現下A,B,C利用zk搶鎖完成任務T的一個流程:

  1. A、B、C都和zk集群Q創建會話。
  2. 通過zk的仲裁,A搶得處理任務T的資格。
  3. zk在斷定A會話超時后,會用某種形式讓B和C得到通知;并在B和C之間重新做仲裁。

為了滿足前面的(b),我們在流程3中需要處理一點細節:在zk斷定A超時之前,A自己必須停止對任務T的處理。這種要求使得A絕對不能在收到zk服務端的session_expired
消息后才停止自己的工作。而是應該在之前的某個時間點時就把自己停下來。

而這個時間點,其實就應該是狀態由connected變為connecting的時刻。

會話狀態怎樣會變為connecting

就實現細節而言,有兩種事件會導致zk會話變為connecting

  1. client和某個server間的tcp socket不可用(poll失敗,或者send/recv失敗)
  2. client端租約超時

其中client端的租約用的是最常見的租約技術:

  1. client定期給zk server定期發送一個ping的消息,記周期為T1。
  2. 如果client有一段時間沒收到zk server端的數據,則視為client的租約超期;記超期的時長T2。

在具體的實現中,ping消息的發送并非單純的以T1為周期的數據包,而是和zk數據請求混在一起的心跳機制。具體來說,如果距上次發送數據包
的時間已經過了T1,那么就會發送一個ping。在3.4.6的客戶端中,T1的值為recv_timeout/3。

相應的,client租約也是隨著不停的收到數據包而不停的延長。在3.4.6的c client代碼中,T2的值為recv_timeout*2/3。

依據這些實現細節,我們可以看出對于前面討論的“分布式搶鎖”,作為zk client的A,在會話變為connecting后將自己的任務T停下來是合理的。
一方面而言,無論是socket不可用還是租約超時,client都有理由懷疑自身在zk server那邊會有可能發生會話超時。另一方面而言,這種服務不可用
只是一種暫時性的不確定的“懷疑”。隨著client對zk server列表中其他機器的連接成功,這種暫時性的不確定性就很快被打破,回到可用的狀態或者
變成確定的“過期”狀態。

有一點需要注意的是,如果client端無法連接到zk server列表中的任何一臺機器,那么client端就會陷入到無限的等待中去。就正確性而言,
這并無影響。但事實上而言,這樣的狀態還是有一些不太好的:

  1. 因為客戶端無法打破這種不確定性,所以它無法依靠zk的某個確定事件來執行自殺的邏輯。
  2. 在無限的循環等待時,client事實上在做不停的輪詢,這帶來的最直接的后果就是——費電。

所以在具體的使用上,client最好在收到connecting的事件后自己觸發一個定時器,用以在合適的時候做自殺的工作。

另外,有一個很簡單的實驗可以證明客戶端的session expire是取決于zk server的:

  1. 啟動一個單機版的zk服務,并寫一個簡單的客戶端連接上它。
  2. 手動停掉zk服務,這時候可以觀察到客戶端變為connecting
  3. 過很長的時間(至少超過和server協商的recv_timeout),再啟動起來服務;可以看到客戶端變為connected,而非expire。

原因也很簡單,在關掉zk服務后,server的整個時間流其實是“停止”的。再重新啟動起來后,server端其實只是過了很短的一個時間段,自然不會
把client判定為超時。

這其實是一個比較好的特性。可以試想,如果因為某種原因,zk集群發生了整體性的宕機(斷電?),那么在集群重啟之后,所有依賴zk的服務在
理論上是可以原樣啟動起來的。

zk api的返回值問題

不得不承認,程序員大部分的時間精力,其實都集中在了程序只有在很少情況下才會觸發的一些異常邏輯上.所以在使用某個庫的時候,我們自然而然就得多花精力
去關注api那些表征異常的返回值是什么含義.

zk api返回值,在異常情況下基本上可以分為三大類:

  1. 運行時環境錯誤,比如內存分配失敗等等
  2. 輸入參數不合法
  3. tcp連接異常而導致的錯誤

這里重點強調下3.具體來說,會話異常可能會導致zk api返回三種錯誤碼:

  1. invalid_state: 當前session的狀態不是connected,而調用zk的讀寫api
  2. connection_loss: 在進行異步的讀寫操作時,socket不可用
  3. operation_timeout: 在進行異步的讀寫操作時,client session發生租約超時

2和3所代表的錯誤含義,在前面分析connecting_state的時候已經給出了說明.這里更多的想說明以下返回這些錯誤的具體場景.為此,我們先看一下zk c client
的程序結構:

zookeeper_structure.png

如上圖所示,zk client內部共有兩個線程:

  • io_thread, 主要負責socket的讀寫
  • completion_thread, 主要負責異步api返回狀態的分發

另一方面,zk client內部一共維護了四個隊列:

  • to_send: 裸的memory_buffer, client線程調用api的時候, 請求被序列化成memory buffer放到to_send尾部
  • sent_request: 和to_send中的memory_buffer一一對應,存放一個異步api的回調函數地址,上下文等
  • to_process: 裸的memory_buffer, 收到的服務端發來的消息時,io_thread將其按序入隊
  • completions_to_process: 根據服務端發來的數據(數據請求響應,watcher), io_thread把本地保存的回調函數上下文等一系列東西和之對應后放入該隊列;然后
    等待competion_thread對該隊列中的內容進行dispatch

在知道zk client的結構后,我們可以得出一些結論:

  • zk api的回調全部在completion thread的上下文中執行.所以如果有全局變量在多個回調函數中執行,不用擔心競爭條件的發生.
  • 會話變為connecting的處理邏輯是這樣的:
    • io thread把會話異常的消息放入到completions_to_process隊列中
    • io_thread清空to_send和to_process
    • io_thread遍歷sent_request隊列,設置返回值為connection_loss(operation_timout),放入completions_to_process隊列.所以,一旦會話變為connecting,已經加入發送隊列中的消息旋即都會隨后返回,而不做任何重發的cache.
  • 如果遇到了zk會話暫時不可用,合理的方法是等待狀態變為了connected了之后再開始重試;無腦的重試只會導致cpu空轉.

封裝zk的細節是不是一個好的主意

在最開始對zookeeper的c api進行封裝的時候, 想法是屏蔽掉zk會話狀態和超時重傳的細節, 直接提供一個簡單的接口供上層使用.后來隨著對zk了解的深入,才漸漸覺得這樣的
封裝其實不是非常合適.從會話狀態的角度看, 其重要性已在前面解釋過; 而對于超時重傳, 是不是要把該功能封裝起來其實也是值得商榷的.

這一點其實是取決于使用zk的邏輯是如何的. 如果zk client的使用者就是數據的產生者, 那么這樣做其實并無不可. 而如果使用者也是一個二道販子, 那么這么做就不是很合適了.

在pegasus項目中, 存到zk上的數據就是由二道販子轉手過的. 正常的流程是這樣的:

  1. Server A(Partition Server)把數據發送給Server B(meta server), 如果數據滿足一定的要求, 再由B把數據存到zk上;
  2. 存放成功后, B再給A發響應. 其中Server A和Server B的通信采用的是無連接的rpc通信, A在超時后會進行重傳.

這時候,如果在Server B和zk之間實現一層cache(cache的狀態對A是不可見的), 那么很顯然的, 一旦B和zk之間出現了connecting state, B就會很有可能給zk發重復請求.
更進一步而言, 長的緩存隊列, 對整個系統的實時性可能是會造成影響的( 請允許我拿這個極端的例子信口開河胡說八道:-) ).

此外,對需要重傳的消息進行cache也得注意一些小問題,比如:

  • 如果對已經發送成功的消息進行重傳,那么需要注意錯誤處理(重復create, 第二次會返回失敗)
  • 對于基于狀態的帶有保序特性的連接,添加中間cache往往意味著會破壞保序性(第一條失敗,第二條成功,第一條重傳成功)

transaction不是萬能的

較新版本的zk引入了transaction的接口, 可以批量的向zk服務器發送請求, 并保證操作的原子性. 對于這個特性, 有一點需要補充: 接口不能支持較大數據量的傳送, 如果一次傳輸過多
會導致會話超時.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容