得物 ZooKeeper SLA 也可以 99.99%丨最佳實踐

作者:Bruce

背景

今天分享的案例來自於得物技術團隊應用 MSE-ZooKeeper 過程中的最佳實踐。原文得物 ZooKeeper SLA 也可以 99.99% | 得物技術

ZooKeeper(ZK)是一個誕生於 2007 年的分佈式應用程序協調服務。儘管出於一些特殊的歷史原因,許多業務場景仍然不得不依賴它。比如,Kafka、任務調度等。特別是在 Flink 混合部署 ETCD 解耦 時,業務方曾要求絕對的穩定性,並強烈建議不要使用自建的 ZooKeeper。出於對穩定性的考量,採用了阿里的 MSE-ZK。 自從 2022 年 9 月份開始使用至今,得物技術團隊沒有遇到任何穩定性問題,SLA 的可靠性確實達到了 99.99%。

在 2023 年,部分業務使用了自建的 ZooKeeper(ZK)集羣,然後使用過程中 ZK 出現了幾次波動,隨後得物 SRE 開始接管部分自建集羣,並進行了幾輪穩定性加固的嘗試。接管過程中得物發現 ZooKeeper 在運行一段時間後,內存佔用率會不斷增加,容易導致內存耗盡(OOM)的問題。得物技術團隊對這一現象非常好奇,因此也參與瞭解決這個問題的探索過程。

探索分析

確定方向

在排查問題時,非常幸運地發現了一個測試環境的故障現場,該集羣中的兩個節點恰好處於 OOM 的邊緣狀態。

有了故障現場,那麼一般情況下距離成功終點只剩下 50%。內存偏高,按以往的經驗來看,要麼是非堆,要麼是堆內有問題。從火焰圖和 jstat  都能證實:是堆內的問題。

如圖所示:說明 JVM 堆內存在某種資源佔用了大量的內存,並且 FGC 都無法釋放。

內存分析

爲了探究 JVM 堆中內存佔用分佈,得物技術團隊立即做了一個 JVM 堆 Dump。分析發現 JVM 內存被 childWatches 和 dataWatches 大量佔用。

dataWatches:跟蹤 znode 節點數據的變化。

childWatches:跟蹤 znode 節點結構 (tree) 的變化。

childWatches 和 dataWatches 同源於 WatcherManager。

經過資料排查,發現 WatcherManager 主要負責管理 Watcher。ZooKeeper(ZK)客戶端首先將 Watcher 註冊到 ZooKeeper 服務器上,然後由 ZooKeeper 服務器使用 WatcherManager 來管理所有的 Watcher。當某個 Znode 的數據發生變更時,WatchManager 將觸發相應的 Watcher,並通過與訂閱該 Znode 的 ZooKeeper 客戶端的 socket 進行通信。隨後,客戶端的 Watch 管理器將觸發相關的 Watcher 回調,以執行相應的處理邏輯,從而完成整個數據發佈/訂閱流程。

進一步分析 WatchManager,成員變量 Watch2Path、WatchTables 內存佔比高達 (18.88+9.47)/31.82 = 90%。

而 WatchTables、Watch2Path 存儲的是 ZNode 與 Watcher 正反映射關係,存儲結構圖所示:

WatchTables【正向查詢表】

HashMap<ZNode, HashSet<Watcher>>

場景:某個 ZNode 發生變化,訂閱該 ZNode 的 Watcher 會收到通知。

邏輯:用該 ZNode,通過 WatchTables 找到對應的所有 Watcher 列表,然後逐個發通知。

Watch2Paths【逆向查詢表】

HashMap<Watcher, HashSet>

場景:統計某個 Watcher 到底訂閱了哪些 ZNode。

邏輯:用該Watcher,通過 Watch2Paths 找到對應的所有 ZNode 列表。

Watcher 本質是 NIOServerCnxn,可以理解成一個連接會話。

如果 ZNode、和 Watcher 的數量都比較多,並且客戶端訂閱 ZNode 也比較多,甚至全量訂閱。這兩張 Hash 表記錄的關係就會呈指數增長,最終會是一個天量!

當全訂閱時,如圖演示:

當 ZNode數量:3,Watcher 數量:2 WatchTables 和 Watch2Paths 會各有 6 條關係。

當 ZNode數量:4,Watcher 數量:3 WatchTables 和 Watch2Paths 會各有 12 條關係。

通過監控發現,異常的 ZK-Node。ZNode 數量大概有 20W,Watcher 數量是5000。而 Watcher 與 ZNode 的關係條數達到了 1 億。

如果存儲每條關係的需要 1 個 HashMap&Node(32Byte),由於是兩個關係表,double 一下。那麼其它都不要計算,光是這個“殼”,就需要 210000^232/1024^3 = 5.9GB 的無效內存開銷。

意外發現

通過上面的分析可以得知,需要避免客戶端出現對所有 ZNode 進行全面訂閱的情況。然而,實際情況是,許多業務代碼確實存在這樣的邏輯,從 ZTree 的根節點開始遍歷所有 ZNode,並對它們進行全面訂閱。

或許能夠說服一部分業務方進行改進,但無法強制約束所有業務方的使用方式。因此,解決這個問題的思路在於監控和預防。然而,遺憾的是,ZK 本身並不支持這樣的功能,這就需要對 ZK 源碼進行修改。

通過對源碼的跟蹤和分析,發現問題的根源又指向了 WatchManager,並且仔細研究了這個類的邏輯細節。經過深入理解後,發現這段代碼的質量似乎像是由應屆畢業生編寫的,存在大量線程和鎖的不恰當使用問題。通過查看 Git 記錄,發現這個問題可以追溯到 2007 年。然而,令人振奮的是,在這一段時間內,出現了 WatchManagerOptimized(2018),通過搜索 ZK 社區的資料,發現了 [ZOOKEEPER-1177],即在 2011 年,ZK 社區就已經意識到了大量 Watch 導致的內存佔用問題,並最終在 2018 年提供瞭解決方案。正是這個WatchManagerOptimized 的功勞,看來 ZK 社區早就進行了優化。

有趣的是,ZK 默認情況下並未啓用這個類,即使在最新的 3.9.X 版本中,默認仍然使用 WatchManager。也許是因爲 ZK 年代久遠,漸漸地人們對其關注度降低了。通過詢問阿里的同事,確認了 MSE-ZK 也啓用了 WatchManagerOptimized,這進一步證實了得物技術團隊關注的方向是正確的。

優化探索

鎖的優化

在默認版本中,使用的 HashSet 是線程不安全的。在這個版本中,相關操作方法如 addWatch、removeWatcher 和 triggerWatch 都是通過在方法上添加了 synchronized 重型鎖來實現的。而在優化版中,採用了 ConcurrentHashMap 和 ReadWriteLock 的組合,以更精細化地使用鎖機制。這樣一來,在添加 Watch 和觸發 Watch 的過程中能夠實現更高效的操作。

存儲優化

這是關注的重點。從 WatchManager 的分析可以看出,使用 WatchTables 和 Watch2Paths 存儲效率並不高。如果 ZNode 的訂閱關係較多,將會額外消耗大量無效的內存。

感到驚喜的是,WatchManagerOptimized 在這裏使用了“黑科技” -> 位圖。

利用位圖將關係存儲進行了大量的壓縮,實現了降維優化。

Java BitSet 主要特點:

  • 空間高效:BitSet 使用位數組存儲數據,比標準的布爾數組需要更少的空間。
  • 處理快速:進行位操作(如 AND、OR、XOR、翻轉)通常比相應的布爾邏輯操作更快。
  • 動態擴展:BitSet 的大小可以根據需要動態增長,以容納更多的位。

BitSet 使用一個 long[] words 來存儲數據,long 類型佔 8 字節,64 位。數組中每個元素可以存儲 64 個數據,數組中數據的存儲順序從左到右,從低位到高位。

比如下圖中的 BitSet 的 words 容量爲 4,words[0] 從低位到高位分別表示數據 0 ~ 63 是否存在,words[1] 的低位到高位分別表示數據 64 ~ 127 是否存在,以此類推。其中 words[1] = 8,對應的二進制第 8 位爲 1,說明此時 BitSet 中存儲了一個數據 {67}。

WatchManagerOptimized 使用 BitMap 來存儲所有的 Watcher。這樣即便是存在1W的 Watcher。位圖的內存消耗也只有8Byte1W/64/1024=1.2KB。如果換成 HashSet ,則至少需要 32Byte10000/1024=305KB,存儲效率相差近 300 倍。

WatchManager.java:
private final Map<String, Set<Watcher>> watchTable = new HashMap<>();
private final Map<Watcher, Set<String>> watch2Paths = new HashMap<>();
WatchManagerOptimized.java:
private final ConcurrentHashMap<String, BitHashSet> pathWatches = new ConcurrentHashMap<String, BitHashSet>();
private final BitMap<Watcher> watcherBitIdMap = new BitMap<Watcher>();

ZNode到 Watcher 的映射存儲,由 Map<string, set> 換成了 ConcurrentHashMap<string, BitHashSet>。也就是說不再存儲 Set,而是用位圖來存儲位圖索引值。

用 1W 的 ZNode,1W 的 Watcher,極端點走全訂閱(所有的 Watcher 訂閱所有的 ZNode) ,做存儲效率 PK:

可以看到 11.7MB PK 5.9GB,內存的存儲效率相差:516 倍

邏輯優化

添加監視器:兩個版本都能夠在常數時間內完成操作,但是優化版通過使用 ConcurrentHashMap 提供了更好的併發性能。

刪除監視器:默認版可能需要遍歷整個監視器集合來找到並刪除監視器,導致時間複雜度爲 O(n)。而優化版利用 BitSet 和 ConcurrentHashMap,在大多數情況下能夠快速定位和刪除監視器,O(1)。

觸發監視器:默認版的複雜度較高,因爲它需要對每個路徑上的每個監視器進行操作。優化版通過更高效的數據結構和減少鎖的使用範圍,優化了觸發監視器的性能。

性能壓測

JMH 微基準測試

ZooKeeper 3.6.4 源碼編譯, JMH micor 壓測 WatchBench。

pathCount:表示測試中使用的 ZNode 路徑數目。

watchManagerClass:表示測試中使用的 WatchManager 實現類。

watcherCount:表示測試中使用的觀察者(Watcher)數目。

Mode:表示測試的模式,這裏是 avgt,表示平均運行時間。

Cnt:表示測試運行的次數。

Score:表示測試的得分,即平均運行時間。

Error:表示得分的誤差範圍。

Units:表示得分的單位,這裏是毫秒/操作(ms/op)。

  • ZNode 與 Watcher 100 萬條訂閱關係,默認版本使用 50MB,優化版只需要 0.2MB,而且不會線性增加。
  • 添加 Watch,優化版(0.406 ms/op)比默認版(2.669 ms/op)提升 6.5 倍。
  • 大量觸發Watch ,優化版(17.833 ms/op)比默認版(84.455 ms/op)提升 5 倍。

性能壓測

接下來在一臺機器 (32C 60G) 搭建一套 3 節點 ZooKeeper 3.6.4 使用優化版與默認版進行容量壓測對比。

場景一:20W znode 短路徑

Znode 短路徑: /demo/znode1 

場景二:20W znode 長路徑

Znode 長路徑: /sentinel-cluster/dev/xx-admin-interfaces/lock/_c_bb0832d5-67a5-48ab-8fe0-040b9ddea-lock/12

  • Watch 內存佔用跟 ZNode 的 Path 長度有關。
  • Watch 的數量在默認版是線性上漲,在優化版中表現非常好,這對內存佔用優化來說改善非常明顯。

灰度測試

基於前面的基準測試和容量測試,優化版在大量 Watch 場景內存優化明顯,接下來開始對測試環境的 ZK 集羣進行灰度升級測試觀察。

第一套 ZooKeeper 集羣 & 收益

默認版

優化版

效果收益:

  • election_time (選舉耗時):降低 60%
  • fsync_time (事務同步耗時):降低 75%
  • 內存佔用:降低 91%

第二套 ZooKeeper 集羣 & 收益

效果收益:

  • 內存:變更前 JVM Attach 響應無法響應,採集數據失敗。
  • election_time(選舉耗時):降低 64%。
  • max_latency(讀延遲):降低 53%。
  • proposal_latency(選舉處理提案延遲):1400000 ms --> 43 ms。
  • propagation_latency(數據的傳播延遲):1400000 ms --> 43 ms。

第三套 ZooKeeper 集羣 & 收益

默認版

優化版

效果收益:

  • 內存:節省 89%
  • election_time(選舉耗時):降低 42%
  • max_latency(讀延遲):降低 95%
  • proposal_latency(選舉處理提案延遲):679999 ms --> 0.3 ms
  • propagation_latency(數據的傳播延遲):928000  ms--> 5 ms

總結

通過之前的基準測試、性能壓測以及灰度測試,發現了 ZooKeeper 的 WatchManagerOptimized。這項優化不僅節省了內存,還通過鎖的優化顯著提高了節點之間的選舉和數據同步等指標,從而增強了 ZooKeeper 的一致性。還與阿里 MSE 的同學進行了深度交流,各自在極端場景模擬壓測,並達成了一致的看法:WatchManagerOptimized 對 ZooKeeper 的穩定性提升顯著。總體而言,這項優化使得 ZooKeeper 的 SLA 提升了一個數量級。

ZooKeeper 有許多配置選項,但大部分情況下不需要調整。爲提升系統穩定性,建議進行以下配置優化:

  • 將 dataDir(數據目錄)和 dataLogDir(事務日誌目錄)分別掛載到不同的磁盤上,並使用高性能的塊存儲。
  • 對於 ZooKeeper 3.8 版本,建議使用 JDK 17 並啓用 ZGC 垃圾回收器;而對於 3.5 和 3.6 版本,可使用 JDK 8 並啓用 G1 垃圾回收器。針對這些版本,只需要簡單配置 -Xms 和 -Xmx 即可。
  • 將 SnapshotCount 參數默認值 100,000 調整爲 500,000,這樣可以在高頻率 ZNode 變動時顯著降低磁盤壓力。
  • 使用優化版的 Watch 管理器 WatchManagerOptimized。

Ref:

https://issues.apache.org/jira/browse/ZOOKEEPER-1177

https://github.com/apache/zookeeper/pull/590

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章