作者: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: