1. 背景
ZooKeeper (ZK) は、2007 年に誕生した分散アプリケーション連携サービスです。ただし、歴史的に特別な理由があり、多くのビジネス シナリオでは依然としてそれに依存する必要があります。たとえば、Kafka、タスクのスケジューリングなど。特に Flink の混在展開と ETCD の分離の場合、ビジネス側は絶対的な安定性を必要とし、自作の ZooKeeper を使用しないことを強く推奨しました。安定性を考慮して、Alibaba の MSE-ZK が使用されます。 2022 年 9 月に使用を開始して以来、安定性の問題は一度も発生しておらず、SLA の信頼性は確かに 99.99% に達しています。
2023 年に、一部の企業が自社構築の ZooKeeper (ZK) クラスターを使用しましたが、ZK では使用中にいくつかの変動が発生し、その後 Dewu SRE がいくつかの自社構築クラスターを引き継ぎ、安定性の強化を数回実施しました。テイクオーバー中に、ZooKeeper が一定期間実行されるとメモリ使用量が増加し続けるため、メモリ不足 (OOM) の問題が発生しやすいことがわかりました。私たちはこの現象に非常に興味を持っていたため、この問題を解決するための調査プロセスに参加しました。
2. 探索と分析
方向を決める
問題のトラブルシューティングを行ったとき、幸運なことに、クラスター内の 2 つのノードがエッジ状態の OOM になっていたことがわかりました。
障害シーンでは、通常、成功した終了点までに 50% しか残っていません。
過去の経験によると、メモリがハイ側にあります。ヒープではないか、ヒープ内に問題があります。フレームグラフやjstatからヒープ内の問題であることが確認できます。
図に示すように、これは、JVM ヒープ内の特定のリソースが大量のメモリを占有しており、FGC がそれを解放できないことを意味します。
メモリ分析
JVM ヒープ内のメモリ使用量の分布を調査するために、すぐに JVM ヒープ ダンプを作成しました。分析の結果、JVM メモリは childWatch と dataWatch によって大きく占有されていることがわかりました。
dataWatches: znode ノード データの変更を追跡します。
childWatches: znode ノード構造 (ツリー) の変更を追跡します。
childWatches と dataWatches は WatcherManager と同じ起源を持ちます。
データ調査の結果、WatcherManager が主に Watcher の管理を担当していることがわかりました。 ZooKeeper (ZK) クライアントは、まず Watcher を ZooKeeper サーバーに登録し、次に ZooKeeper サーバーは WatcherManager を使用してすべての Watcher を管理します。 Znode のデータが変更されると、WatchManager は対応する Watcher をトリガーし、Znode にサブスクライブされている ZooKeeper クライアントのソケットと通信します。その後、クライアントのウォッチ マネージャーが関連するウォッチャー コールバックをトリガーして、対応する処理ロジックを実行し、データのパブリッシュ/サブスクライブ プロセス全体を完了します。
WatchManager をさらに分析すると、メンバー変数 Watch2Path および WatchTables のメモリ率が (18.88+9.47)/31.82 = 90% と高いことがわかります。
WatchTables と Watch2Path は、ストレージ構造図に示すように、ZNode と Watcher の間の正確なマッピング関係を保存します。
WatchTables [前方参照テーブル]
HashMap<ZNode、HashSet<Watcher>>
シナリオ: ZNode が変更されると、ZNode にサブスクライブしている Watcher が通知を受け取ります。
ロジック: この ZNode を使用して、WatchTable を通じて対応するすべての Watcher リストを検索し、通知を 1 つずつ送信します。
Watch2Paths [逆引きテーブル]
HashMap<ウォッチャー、ハッシュセット>
シナリオ: 特定の Watcher がどの ZNode をサブスクライブしたかを数える
ロジック: この Watcher を使用して、Watch2Paths を通じて対応するすべての ZNode リストを検索します。
Watcher は本質的に NIOServerCnxn であり、接続セッションとして理解できます。
多数の ZNode と Watcher があり、クライアントが多数の ZNode をサブスクライブしている場合は、完全にサブスクライブすることもできます。これら 2 つのハッシュ テーブルに記録された関係は指数関数的に増加し、最終的には膨大な量に達します。
完全にサブスクライブされている場合、図に示すように:
ZNode数:3、Watcher数:2の場合、 WatchTableとWatch2Pathはそれぞれ6つのリレーションシップを持つことになります。
ZNode の数: 4、Watcher の数: 3 の場合、 WatchTable と Watch2Path にはそれぞれ 12 の関係があります。
モニタリングにより、異常な ZK-Node を発見しました。 ZNode の数は約 20W、Watcher の数は 5,000 です。 Watcher と ZNode 間の関係の数は 1 億に達しました。
各リレーションシップを保存するために 1 つの HashMap&Node (32Byte) が必要な場合、リレーションシップ テーブルが 2 つあるため、それを 2 倍にします。この場合、他には何も計算しません。この「シェル」だけで 2*10000^2*32/1024^3 = 5.9GB の無効なメモリ オーバーヘッドが必要になります。
分析のこの時点では、誰もが理解できるはずです。なぜ私たちの ZK 記憶は常に「綱渡り」をし、しばしば OOM になるのでしょうか。
思いがけない発見
問題の原因が特定されたので、次にそれを修正する方法を考えます。
上記の分析から、クライアントがすべての 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はあまりにも古いので、人々は徐々にZKに注目しなくなりました。 Alibaba の同僚に尋ねたところ、MSE-ZK で WatchManagerOptimized も有効になっていることが確認され、これにより私たちの焦点が正しい方向にあることがさらに確認されました。したがって、このクラスの可能性をさらに深く掘り下げる必要があると考えました。
探索を最適化する
ロックの最適化
デフォルトのバージョンでは、使用される HashSet はスレッドセーフではありません。このバージョンでは、addWatch、removeWatcher、triggerWatch などの関連する操作メソッドはすべて、同期された重いロックをメソッドに追加することによって実装されます。最適化されたバージョンでは、ConcurrentHashMap と ReadWriteLock を組み合わせて、より洗練された方法でロック メカニズムを使用します。このようにして、Watch の追加および Watch のトリガーのプロセス中に、より効率的な操作を実現できます。
ストレージの最適化
これが私たちの焦点です。 WatchManager の分析から、WatchTable と Watch2Path を使用した場合のストレージ効率は高くないことがわかります。 ZNode に多くのサブスクリプション関係がある場合、追加で大量の無効なメモリが消費されます。
驚いたことに、WatchManagerOptimized はここで「ブラック テクノロジー」 -> ビットマップを使用します。
リレーショナル ストレージは、次元削減の最適化を実現するためにビットマップを使用して大幅に圧縮されます。
Java BitSet の主な機能:
-
スペース効率: BitSet はビット配列を使用してデータを保存するため、標準のブール配列よりも必要なスペースが少なくなります。
-
高速処理: ビット単位の演算 (AND、OR、XOR、反転など) の実行は、多くの場合、対応するブール論理演算よりも高速です。
-
動的拡張: BitSet のサイズは、より多くのビットに対応するために、必要に応じて動的に拡張できます。
BitSet は、long[] ワードを使用してデータを格納します。long 型は 8 バイトを占め、64 ビットです。配列内の各要素は 64 個のデータを保存できます。配列内のデータの保存順序は、左から右、低位から高位の順です。
たとえば、下図の BitSet のワード容量は 4 です。下位から上位までの Words[0] はデータ 0 ~ 63 が存在するかどうかを示し、下位から上位までの Words[1] はデータ 64 ~ 127 が存在するかどうかを示します。の上。このうち、words[1] = 8 であり、対応する 2 進数のビット 8 は 1 であり、この時点で BitSet にデータ {67} が格納されていることを示します。
WatchManagerOptimized は BitMap を使用してすべてのウォッチャーを保存します。このように、1Wウォッチャーがいても。ビットマップのメモリ消費量はわずか 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<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) 内のモニターを迅速に見つけて削除します。
-
モニターのトリガー: デフォルトのバージョンは、すべてのパス上のすべてのモニターに対する操作が必要なため、より複雑です。最適化されたバージョンでは、より効率的なデータ構造とロックの使用量の削減を通じて、トリガー モニターのパフォーマンスが最適化されます。
3. パフォーマンスストレステスト
JMH マイクロベンチマーク
Zookeeper 3.6.4 ソース コード コンパイル、JMH マイク ストレス テスト WatchBench。
pathCount: テストで使用される ZNode パスの数を示します。
watchManagerClass: テストで使用される WatchManager 実装クラスを表します。
watcherCount: テストで使用されるオブザーバー (ウォッチャー) の数を示します。
Mode: テスト モードを示します。ここでの avgt は、平均実行時間を示します。
Cnt: テストの実行数を示します。
スコア: テストのスコア、つまり平均実行時間を示します。
Error: スコアの誤差範囲を示します。
単位: スコアを表す単位。ここではミリ秒/オペレーション (ms/op) です。
-
ZNode と Watcher の間には 100 万のサブスクリプションがあり、デフォルト バージョンでは 50 MB が使用されますが、最適化バージョンでは 0.2 MB しか必要とせず、直線的に増加することはありません。
-
Watch を追加すると、最適化バージョン (0.406 ms/op) はデフォルト バージョン (2.669 ms/op) より 6.5 倍高速になります。
-
多数のウォッチがトリガーされ、最適化されたバージョン (17.833 ミリ秒/オペレーション) はデフォルト バージョン (84.455 ミリ秒/オペレーション) より 5 倍高速です。
パフォーマンスストレステスト
次に、最適化バージョンとデフォルト バージョンを使用して、マシン (32C 60G) 上に 3 ノードの Zookeeper 3.6.4 のセットを構築し、容量ストレス テストの比較を実行しました。
シナリオ 1: 20W znode ショート パス
Znode のショートパス: /demo/znode1
シナリオ 2: 20W znode の長いパス
Znode の長いパス: /sentinel-cluster/dev/xx-admin-interfaces/lock/_c_bb0832d5-67a5-48ab-8fe0-040b9ddea-lock/12
-
ウォッチのメモリ使用量は、ZNode のパスの長さに関係します。
-
ウォッチの数はデフォルト バージョンでは直線的に増加し、最適化バージョンでは非常に良好なパフォーマンスを示します。これは、メモリ使用量の最適化にとって非常に明らかな改善です。
グレースケールテスト
前回のベンチマーク テストと容量テストに基づいて、最適化されたバージョンでは、多数の Watch シナリオで明らかにメモリが最適化されています。次に、テスト環境で ZK クラスターのグレースケール アップグレード テストの観察を開始しました。
最初の飼育員クラスターとメリット
デフォルトのバージョン
最適化されたバージョン
影響収入:
-
election_time (選挙時間): 60% 削減
-
fsync_time (トランザクション同期時間): 75% 削減
-
メモリ使用量: 91% 削減
2 番目の飼育員クラスターとメリット
影響収入:
-
メモリ: 変更前は、JVM Attach 応答が応答せず、データ収集に失敗していました。
-
election_time (選挙時間): 64% 削減されました。
-
max_latency (読み取りレイテンシ): 53% 削減。
-
professional_latency (選挙処理提案遅延): 1400000 ミリ秒 --> 43 ミリ秒。
-
propagation_latency (データ伝播遅延): 1400000 ミリ秒 --> 43 ミリ秒。
動物園飼育員クラスターと特典の第 3 セット
デフォルトのバージョン
最適化されたバージョン
影響収入:
-
メモリ: 89% 節約
-
election_time (選挙時間): 42% 削減
-
max_latency (読み取りレイテンシ): 95% 削減
-
professional_latency (選挙処理提案遅延): 679999 ミリ秒 --> 0.3 ミリ秒
-
propagation_latency (データ伝播遅延): 928000 ミリ秒 --> 5 ミリ秒
4. まとめ
これまでのベンチマーク テスト、パフォーマンス ストレス テスト、グレースケール テストを通じて、Zookeeper の WatchManagerOptimized を発見しました。この最適化により、メモリが節約されるだけでなく、ロックの最適化を通じてノード間の選択やデータ同期などの指標が大幅に改善され、Zookeeper の一貫性が向上します。また、Alibaba 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 Manager WatchManagerOptimized の最適化されたバージョンを使用します。
参照:
https://issues.apache.org/jira/browse/ZOOKEEPER-1177
https://github.com/apache/zookeeper/pull/590
※文/ブルース
この記事は Dewu Technology によるものです。さらに興味深い記事については、 Dewu Technology 公式 Web サイトをご覧ください。
Dewu Technology の許可なく転載することは固く禁じられています。さもなければ、法律に従って法的責任が追及されます。
JetBrains 2024 (2024.1) の最初のメジャー バージョン アップデートは オープンソースです。Microsoft も費用を支払う予定です。なぜオープンソースが依然として批判されているのでしょうか? [復旧] Tencent Cloud バックエンドがクラッシュ: コンソールにログイン後、大量のサービス エラーとデータなし ドイツも 「独立して制御可能」にする必要がある 州政府は 30,000 台の PC を Windows から Linux deepin-IDE に移行し、最終的に達成ブートストラッピング! Visual Studio Code 1.88 がリリースされました. 良い人です、Tencent は Switch を本当に「思考する学習マシン」に変えました. RustDesk リモート デスクトップが起動し、Web クライアントを再構築します. SQLite に基づく WeChat のオープン ソース ターミナル データベースである WCDB がメジャー アップグレードされました.