ZK 網路故障應對法

  • 2019 年 10 月 3 日
  • 筆記

網路故障可以說是分散式系統天生的宿敵。如果永遠不發生網路故障,我們實際上可以設計出高可用強一致的分散式系統。可惜的是不發生網路故障的分散式環境還不存在,ZK 使用過程中也需要小心的應付網路故障。

讓我們先忘掉故障發生的情況,首先來看到 ZK 對網路連接的處理。ZK 客戶端啟動時帶有所有可用的伺服器的資訊,它會隨機選擇和其中一台伺服器嘗試連接,在正常的成功連接的情況下,ZK 客戶端和服務端會建立起一個會話(session),在會話超時之前服務端會響應客戶端的請求,每次新的請求都會刷新會話超時的時間。當 ZK 客戶端和當前伺服器失聯時,它會試著從可用的伺服器列表中重新連接到一台伺服器上。

總體地看過 ZK 正常的網路連接處理之後,我們來看看網路故障在 ZK 世界中的抽象。網路故障在 ZK 的層面被轉換為兩種異常,一種是 ConnectionLossException ,一種是 SessionExpireException。前者發生在超時時間之前 ZK 客戶端與某台伺服器斷開之後,後者發生在服務端通知客戶端會話超時的時候。

ConnectionLossException

這個異常肯定是 ZK 中最讓人頭痛的異常之一了。ZK 客戶端通過 socket 和 ZK 服務端的某台伺服器連接,在客戶端由 ClientCnxn 管理,在服務端由 ServerCnxn 管理。ConnectionLossException 發生在 ZK 客戶端失去與 ZK 伺服器的連接的時候,它僅僅表明 ZK 客戶端發現自己失去了和當前伺服器的連接,除此之外什麼也不知道。這裡存在三個重要的問題。

從可恢復的故障中恢復

ConnectionLossException 是一個可恢復的異常,它僅僅代表著與當前伺服器的連接失效,客戶端完全有可能稍後連接上另一個伺服器並重新開始發送請求。在客戶端與 ZK 連接不穩定的情況下,我們需要特別小心的處理這類異常。否則,因為網路抖動而使上層應用崩潰是不可接受的。此外,重新創建 ZK 客戶端,開啟一個新的會話則只會加劇網路的不穩定性。這是因為客戶端不重連的情況下服務端只能通過會話超時來釋放與客戶端的連接,如果由於連接過多導致響應不穩定,開啟新的會話只會惡化這個情況。

一種常見的容忍 ConnectionLossException 的方式是重做動作,也就是形如下面程式碼的處理邏輯

operation(...) {    zk.create(path, data, ids, mode, callback, data);  }    callback = (rc, path, ctx, name) -> {    switch (Code.get(rc)) {      case CONNECTIONLOSS:        operation(...);        break;        // ...    }  }

服務端上操作可能已經成功

上面提到從可恢復的故障中回復的時候,介紹了一種通過重做動作的方法。然而,重做動作是有風險的。這是因為先前的動作可能在客戶端上已經成功。

ConnectionLossException 只表明 ZK 客戶端發現自己與服務端的連接斷開,但是在斷開之前,對應的請求完全可能已經發送出去,已經到達服務端並被處理。只是由於客戶端與服務端的連接斷開而收不到回應而是觸發 ConnectionLossException 罷了。

對於讀操作,重試通常沒有什麼問題,因為我們總能得到重試成功的時候讀操作應有的返回值(或異常)。對於寫操作,情況稍微微妙一些。

對於 setData 操作,在重試成功的情況下,不考慮具體的業務邏輯,我們可以認為問題不大。因為兩次把節點設置為同一個值是冪等操作,對於前一次操作更新了 version 從而導致重試操作 version 不匹配的情況,我們也可以由吞掉異常或者觸發業務相關的異常邏輯。

對於 delete 操作,重試可能導致意外的 NoNodeException,我們可以吞掉這個異常或者觸發業務相關的異常邏輯。

對於 create 操作,情況稍微複雜一點。在非 sequential 的情況下,create 可能成功或者觸發一個 NodeExistException,這跟 delete 大約是對應的處理方式。但是在 sequential 的情況下,有可能先前的操作已經成功,而重試的操作也成功。由於我們丟失了先前操作的返回值,因此先前操作的 sequential 節點就成了孤兒,這有可能導致資源泄露或者更嚴重的一致性問題。例如,基於 ZK 的 leader 選舉演算法依賴於 sequential 節點的排序,一個序號最小的孤兒節點將導致整個演算法失敗,這是因為孤兒節點成為了 leader 其上的 Watcher 卻被 ConnectionLossException 觸發了,任何客戶端也沒有對它的所有權,因此它也不會被刪除以推動演算法繼續進行。

客戶端可能錯過狀態變化

ZK 的 Watcher 是單次觸發的,在前一次 Watcher 被觸發到重新設置 Watcher 並被觸發的間隔之間的事件可能會丟失。這本身是 ZK 上層應用需要考慮的一個重要的問題。 ConnectionLossException 會觸發 Watcher 接收到一個 WatchedEvent(EventType.None, KeeperState.Disconnected) 的事件。一旦收到這個事件,ZK 客戶端必須假定 ZK 上的狀態可能發生任意變化。對於依賴於某些狀態例如自己是應用程式中的 leader 的動作,需要掛起動作,在恢復鏈接後確認狀態之後再重新執行動作。

這裡有一個設計上的細節需要注意,不同於一般的 WatchedEvent 會在觸發 Watcher 後將其移除,EventType.None 的 WatchedEvent 在不設置系統屬性 zookeeper.disableAutoWatchReset=true 的情況下只會觸發 Watcher 而不將其移除。同時,在成功重新連接伺服器之後會將當前的所有 Watcher 通過 setWatches 請求重新註冊到服務端上。服務端通過對比 zxid 的數值來判斷是否觸發 Watcher。從而避免了由於網路抖動而強迫用戶程式碼在 Watcher 的處理邏輯中處理 ConnectionLossException 並重新執行操作設置 Watcher 的負擔。特別是,當前客戶端上註冊的所有的 Watcher 都將受到網路抖動的影響。但是要注意重新註冊的 Watcher 中監聽 NodeCreated 事件的 Watcher 可能會錯過該事件,這是因為在重新建立連接的過程中該節點由於其他客戶端的動作可能先被創建後被刪除,由於僅就有無節點判斷而沒有 zxid 來幫助判斷,這裡我們遇到了所謂的 ABA 問題。

SessionExpiredException

這個異常比 ConnectionLossException 好處理的地方在於它是嚴格不可恢復的故障,ZK 客戶端會話超時之後無法重新和服務端成功連接,因此我們通常只需要重新創建一個 ZK 客戶端實例並重新開始開始工作。但是會話超時會導致 ephemeral 節點被刪除,如果上層應用邏輯與此相關的話,就需要仔細的處理 SessionExpiredException

會話超時的檢測

ZK 客戶端與伺服器成功建立連接後,ClientCnxn.SendThread 會周期性的向伺服器發送 ping 資訊,伺服器在處理 ping 資訊的時候重置會話超時的時間。如果伺服器在超時時間內沒有收到客戶端發來的任何新的資訊,那麼它就會宣布這個會話超時,並顯式的關掉對應的鏈接。ZK 會話超時相關的邏輯在 SessionTracker 中,所有會話檢查和超時的判斷都是由 leader 作出的,也就是所謂的仲裁動作(quorum operation),因此客戶端超時是所有伺服器一致的共識。

在 ZK 客戶端的連接被服務端關閉後,客戶端嘗試重新連接伺服器,僅當它重新連接上某個伺服器時,該伺服器查詢服務端的會話列表,發現這個重連請求屬於超時會話,通過返回非正整數的超時剩餘時間通知客戶端會話已超時。隨後,客戶端得知自己已經超時並執行相應的退出邏輯。

這裡有一個非常 tricky 的事情,就是 ZK 客戶端的會話超時永遠是由服務端通知的。那麼,在一種很合理的超時情況,即服務端掛了或者客戶端與服務端徹底分區的情況下,實際上 ZK 客戶端是無法得知自己的會話已經超時的。ZK 目前沒有辦法處理這一情況,只能依賴上層應用自己去處理。例如,在確定之後主動地關閉 ZK 客戶端並重啟。在 Curator 中通過 ConnectionStateManager#processEvents 周期性的檢查在收到最後一個 disconnect 事件後過去的時間,從而從客戶端的角度在必然超時的時候注入會話超時事件。

ephemeral 節點的刪除

跟 ephemeral 節點的刪除相關的最大的問題是關於基於 ZK 的 leader 選舉的。ZK 提供了 leader 選舉的 recipe 參考[1],總的來說是基於一系列 ephemral sequential 節點的排序來做的。當上層應用基於 ZK 做 leader 選舉時,如果 ZK 客戶端與服務端超時,由於 ZK 相關的操作和相應往往和上層應用的主執行緒是分開的,這樣在上層應用得知自己不是 leader 之前就有可能作出很多越權的操作。

例如,在 FLINK 中,理論上只有成為 leader 的 JobManager 才有許可權寫入 checkpoint,但是由於 ZK 上產生丟失 leadership 的消息,到客戶端得知這一消息,再到通知上層應用,這幾個步驟之間都是非同步的,所以此前的 leader 並不能第一時間得知自己丟失 leadership 了。同時,其他的 JobManager 可能在同一時間被通知當選 leader。此時,集群中就會有兩個 JobManager 認為自己是 leader。如果對它們寫入 checkpoint 的動作不做其他限制,即只要 JobManager 認為自己有許可權,就是有許可權的話,就可能導致兩個 leader 並發的寫入 checkpoint 從而導致狀態不一致。這個由於響應時間帶來的問題 Curator 的技術注意事項中已有提及[2],由於發生概率較小,而且實現上依賴於」及時地「響應遠端資訊,因此雖然不少系統都有這個理論上的 BUG,但是很多時候只是作為注意事項幫助開發者和使用者在極端情況下理解發生了什麼。

FLINK-10333[3] 和 ZK 郵件列表上我發起的這個討論[4]詳細討論了這種情況下面臨的挑戰和解決方法。

[1] https://zookeeper.apache.org/doc/r3.5.5/recipes.html#sc_leaderElection

[2] https://cwiki.apache.org/confluence/display/CURATOR/TN10

[3] https://issues.apache.org/jira/browse/FLINK-10333

[4] https://lists.apache.org/x/thread.html/594b66ecb1d60b560a5c4c08ed1b2a67bc29143cb4e8d368da8c39b2@%3Cuser.zookeeper.apache.org%3E