1. 背景
ZooKeeper(ZK)是一個誕生於 2007 年的分散式應用程式協調服務。儘管出於一些特殊的歷史原因,許多業務場景仍然不得不依賴它。比如,Kafka、任務排程等。特別是在 Flink 混合部署 ETCD 解耦 時,業務方曾要求絕對的穩定性,並強烈建議不要使用自建的 ZooKeeper。出於對穩定性的考量,採用了阿里的 MSE-ZK。自從 2022 年 9 月份開始使用至今,得物技術團隊沒有遇到任何穩定性問題,SLA 的可靠性確實達到了 99.99%。
在 2023 年,部分業務使用了自建的 ZooKeeper(ZK)叢集,然後使用過程中 ZK 出現了幾次波動,隨後得物 SRE 開始接管部分自建叢集,並進行了幾輪穩定性加固的嘗試。接管過程中得物發現 ZooKeeper 在執行一段時間後,記憶體佔用率會不斷增加,容易導致記憶體耗盡(OOM)的問題。得物技術團隊對這一現象非常好奇,因此也參與瞭解決這個問題的探索過程。
2. 探索分析
2.1 確定方向
在排查問題時,非常幸運地發現了一個測試環境的故障現場,該叢集中的兩個節點恰好處於 OOM 的邊緣狀態。
有了故障現場,那麼一般情況下距離成功終點只剩下 50%。記憶體偏高,按以往的經驗來看,要麼是非堆,要麼是堆內有問題。從火焰圖和 jstat 都能證實:是堆內的問題。
如圖所示:說明 JVM 堆記憶體在某種資源佔用了大量的記憶體,並且 FGC 都無法釋放。
2.2 記憶體分析
為了探究 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 發生變化,訂閱該 ZNode 的 Watcher 會收到通知。
邏輯:用該 ZNode,透過 WatchTables 找到對應的所有 Watcher 列表,然後逐個發通知。
Watch2Paths【逆向查詢表】
HashMap
場景:統計某個 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 一下。那麼其它都不要計算,光是這個“殼”,就需要 2*10000^2*32/1024^3 = 5.9GB 的無效記憶體開銷。
2.3 意外發現
透過上面的分析可以得知,需要避免客戶端出現對所有 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,這進一步證實了得物技術團隊關注的方向是正確的。
2.4 最佳化探索
鎖的最佳化
在預設版本中,使用的 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。點陣圖的記憶體消耗也只有8Byte*1W/64/1024=1.2KB。如果換成 HashSet ,則至少需要 32Byte*10000/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 換成了 ConcurrentHashMapBitHashSet>。也就是說不再儲存 Set,而是用點陣圖來儲存點陣圖索引值。
用 1W 的 ZNode,1W 的 Watcher,極端點走全訂閱(所有的 Watcher 訂閱所有的 ZNode),做儲存效率 PK:
可以看到 11.7MB PK 5.9GB,記憶體的儲存效率相差:516 倍。
邏輯最佳化
新增監視器:兩個版本都能夠在常數時間內完成操作,但是最佳化版透過使用 ConcurrentHashMap 提供了更好的併發效能。
刪除監視器:預設版可能需要遍歷整個監視器集合來找到並刪除監視器,導致時間複雜度為 O(n)。而最佳化版利用 BitSet 和 ConcurrentHashMap,在大多數情況下能夠快速定位和刪除監視器,O(1)。
觸發監視器:預設版的複雜度較高,因為它需要對每個路徑上的每個監視器進行操作。最佳化版透過更高效的資料結構和減少鎖的使用範圍,最佳化了觸發監視器的效能。
3. 效能壓測
3.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 倍。
3.2 效能壓測
接下來在一臺機器 (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 的數量在預設版是線性上漲,在最佳化版中表現非常好,這對記憶體佔用最佳化來說改善非常明顯。
3.3 灰度測試
基於前面的基準測試和容量測試,最佳化版在大量 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
4. 總結
透過之前的基準測試、效能壓測以及灰度測試,發現了 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。
原文連結
本文為阿里雲原創內容,未經允許不得轉載。