🔄

[翻訳] レジリエントなセグメントレプリケーションのための適応的リフレッシュ

に公開

https://opensearch.org/blog/adaptive-refresh-for-resilient-segment-replication/

高可用性システムが高 QPS トラフィックを処理するには、レジリエントなレプリケーション戦略が不可欠である。これは普遍的な真理だ。

高度にレプリケートされたシステムは、現代の分散コンピューティングの基盤です。検索エンジンでは通常、同じインデックスを多数の「サーチャー」インスタンス(レプリカ)で提供します。これらのレプリカ間でクエリトラフィックを分散することで、単一インスタンスのスループットを超えてサービスをスケールできます。さらに、地理的に分離された物理インスタンスにレプリカを配置することで、高可用性を実現できます。1 つのインスタンスに障害が発生してもサービスは停止せず、データセンター全体の障害にも耐えられます。

レプリケートされたシステムにおける中心的な課題は、すべてのレプリカにインデックスの変更をどのように伝播するかです。実世界のドキュメントセットは時間とともに変化します。製品カタログの変更、e コマース検索における在庫や価格の変動、エンタープライズドキュメント検索におけるドキュメントの追加・更新、航空券検索における空席状況の変化など、ほとんどの商用検索エンジンはニアリアルタイムの更新を必要とします。

レプリケートされたシステムにおける変更の伝播

従来、システムはドキュメントレプリケーションを使用していました。ドキュメントはすべてのレプリカにルーティングされ、各レプリカが独立してドキュメントを検索インデックスにインデックス化します。「インデックス化」とは、ドキュメントからトークンを抽出し、ポスティングリスト、BKD ツリー、最近傍グラフなど、クエリ時に効率的な検索を可能にするデータ構造を作成する一連の計算処理を指します。ドキュメントレプリケーションでは、この処理がすべてのレプリカで繰り返されます。これは計算リソースの無駄であるだけでなく、レプリカがドキュメントのインデックス化という追加作業を行う必要があるため、検索に割り当てられるリソースが減少します。その結果、レプリカあたりの検索スループットが低下し、特定量の検索トラフィックをサポートするためにより多くのレプリカが必要になります。

しかし、Lucene には独自の「書き込み一度のセグメント化アーキテクチャ」があります。最近インデックス化されたドキュメントは、自己完結型で不変な Lucene セグメントに書き込まれます。一度作成されると、これらのセグメントファイルは二度と変更されません。従来のデータベースと比較すると珍しい設計ですが、この不変セグメントは多くの有用な機能を実現します。トランザクション性(ドキュメントは完全にインデックス化されるか、まったくインデックス化されないか)、決して変更されないインデックスのポイントインタイムビュー(ページネーションや複数のポイントインタイムスナップショットなどの機能を実現し、対応するセグメントをロードするだけで古いスナップショットに復元可能)などです。これらの特性により、セグメントレプリケーションと呼ばれる別のレプリケーション形式が可能になります。

セグメントレプリケーションでは、ドキュメントはプライマリインデックスマシン(インデクサー)で一度だけインデックス化されます。このプロセスで作成された不変セグメントは、ネットワーク経由ですべてのレプリカにコピーされます。ドキュメントの更新や新規追加があるたびに、Lucene(インデクサーマシン上)が変更をインデックス化し、新しいセグメントを作成します。これらはすべてのレプリカ(サーチャー)にコピーされます。新しいセグメント(レプリケーションペイロードまたはチェックポイントとも呼ばれる)を受信すると、レプリカはこれらの新しいセグメントをロードするためにサーチャーをアトミックに「リフレッシュ」します。その後のすべての検索クエリは、新しいインデックス変更が反映された状態で処理されます。

このアプローチでは、ドキュメントは一度だけインデックス化されます。レプリカの貴重な計算リソースを節約し、代わりにネットワークを活用してインデックス変更をフリート全体にコピー(レプリケート)します。レプリケーションチェックポイントを追跡することで、各レプリカがインデックスの同じポイントインタイムビューにあることを確認できます。これは、各レプリカが独自のペースでインデックス化する可能性があるドキュメントレプリケーションとは対照的です。また、問題のあるインデックス変更が本番環境に入った場合に、既知の「正常なチェックポイント」にロールバックする機能も得られます。

典型的なセグメントレプリケーションのセットアップ

従来の「セグメントレプリケート」システムでは、インデクサーは固定時間間隔で Lucene の commit を呼び出し、セグメントを作成します。各コミットはレプリケーションチェックポイントとして扱われ、レプリカインスタンスにコピーされます。インデクサーでチェックポイントを作成する頻度は、更新伝播の許容遅延に基づいて事前に設定されます。新しいチェックポイントを受信すると、レプリカはそれをローカル状態と比較し、不足しているファイルを取得し、Lucene の refresh を呼び出して、クエリが更新されたインデックスを参照できるようにします。

適切に設計されたシステムでは、インデクサーとレプリカはリモートストレージを介して分離されます。インデクサーは定期的なチェックポイントを Amazon Simple Storage Service (Amazon S3) などのリモートストアに公開し、レプリカはストアをポーリングして利用可能な最新のチェックポイントを取得します。リモートストアは耐久性を提供します。レプリカがクラッシュまたは再起動した場合でも、最新のチェックポイントを取得してロードできます。また、インデクサーとレプリカを分離する役割も果たします。インデクサーは、レプリカの遅延や問題に影響されることなく、固定ペースでチェックポイントを公開し続けることができます。

次に進む前に、Lucene がサーチャーのリフレッシュをどのように管理するかを理解しておきましょう。これはセグメントレプリケートシステムの基礎となります。

Lucene のリフレッシュメカニズムの理解

Lucene は、リーダーと参照管理に関するクリーンなリフレッシュの仕組みを提供しています。コア抽象化は ReferenceManager です。

Lucene は、単一のアクティブな IndexSearcher(およびオプションでファセット検索用の TaxonomyReader)を保持する SearcherManagerSearcherTaxonomyManager などの具体的な実装を提供します。これらのマネージャーは、リフレッシュ中にサーチャーをアトミックにスワップし、参照カウントを使用して、古いサーチャーでのアクティブなクエリがリソースがクローズされる前に安全に終了することを保証します。ユーザーとしては、通常 acquire() を呼び出してサーチャーへの参照を取得し、クエリを実行してから、release() を呼び出してサーチャー参照を解放します。

セグメントレプリケートシステムにおける典型的なリフレッシュの流れを見てみましょう。インデクサーマシンは時間の経過とともに新しいコミットを生成し、チェックポイントを介してレプリカにコピーされます。レプリカサーチャーは、refreshIfNeeded API を使用して新しいチェックポイントでリフレッシュされます。内部的には、この API がダウンロードされたチェックポイントからのコミットを保持するディレクトリで DirectoryReader.openIfChanged() を呼び出し、ディレクトリで利用可能な最新のコミットで IndexReader を返します。

次に、マネージャーはこの新しいリーダーで IndexSearcher を作成し、既存のサーチャー参照を新しいものとアトミックにスワップします。acquire() への後続のすべての呼び出しは、新しい IndexSearcher への参照を取得します。一方、古いサーチャーは、それへの参照を保持している実行中のクエリが完了するまで保持されます。参照カウントが 0 になると、古いサーチャーはクリーンアップされます。

リフレッシュ中にリクエストがどのように動作するかを具体的なタイムラインで見てみましょう:

[t0] STATE current=Searcher(S0 on commit=C0)
[t0] REQ-1 "GET /search?q=shoes"  acquire -> S0   refCount[S0]=1   serving commit C0
[t0] REQ-2 "GET /search?q=books"  acquire -> S0   refCount[S0]=2   serving commit C0

[t1] 新しいチェックポイントがコミット C1 で受信される

[t2] REFRESH 開始、コミット C1 をダウンロード
[t2] REFRESH コミット C1 でサーチャー S1 を構築

[t2] REQ-3  スワップ前に到着 acquire -> S0   refCount[S0]=3   serving commit C0
[t2] REFRESH current を S0 -> S1 にスワップ (アトミック)
[t2] STATE  current=Searcher(S1 on commit=C1); S0 はまだ生きている (refCount[S0]=2)

[t3] REQ-4  スワップ後に到着  acquire -> S1   refCount[S1]=1   serving commit C1
[t4] REQ-5  スワップ後に到着  acquire -> S1   refCount[S1]=2   serving commit C1

[t5] REQ-1  release(S0)   refCount[S0]=2
[t6] REQ-2  release(S0)   refCount[S0]=1
[t6] REQ-3  release(S0)   refCount[S0]=0  -> S0 と R0 をクローズ
[t7] REQ-4  release(S1)   refCount[S1]=1
[t8] REQ-5  release(S1)   refCount[S1]=0  (S1 は current のまま)

[t8] STATE  current=Searcher(S1 on commit=C1)

大規模環境における実世界の課題

すべての分散システムと同様に、実際の課題はスケールで顕在化します。従来の単一コミットモデルには「崖」があります。レプリカは「現在」から「最新」へ一気にジャンプします。インデクサーが更新のバーストを受け取った場合(たとえば、トラフィックの多いシーズン中)、ある時点で作成されたチェックポイントは非常に大きくなる可能性があります。その後、レプリカはこれらの大きなチェックポイントを一度にロードするコストを支払う必要があります。

同様に、高可用性システムは多くの場合、地理的リージョン間でレプリケートされます。レプリケーションはクロスリージョンの帯域幅とラウンドトリップタイムに依存します。レプリカの現在のコミットとチェックポイントの間のデルタが大きい場合、転送に時間がかかり、リフレッシュは一度に大きな変更を適用することになります。大きなデルタをプルし、多くの新しいセグメントファイルを具体化し、サーチャーをリフレッシュすると、ページフォールトのバーストが発生し、レイテンシスパイクを引き起こす可能性があります。

レプリカが吸収するチェックポイント間のギャップが大きい場合、すべてのコストを一度に支払うことになり、ページフォールト、スラッシング、検索リクエストのタイムアウト、不安定なシステムにつながる可能性があります。この記事では、インデクサーでのバイトサイズコミットとレプリカでの適応的リフレッシュの組み合わせを使用して、この「崖」を「階段」に変える新しい技術を紹介します。

具体例で説明しましょう:

ライターがコミットのシーケンス 1, 2, 3, 4, 5, 6, 7 を生成するとします。レプリカは現在コミット 1 にあり、クエリを問題なく処理しています。一時的なネットワークの問題により、レプリケーションが遅延し、チェックポイント 2 がレプリカに伝播するのに時間がかかります。その間、インデクサーは更新の処理を続け、チェックポイントをリモートストアにプッシュし、現在はチェックポイント 7 に達しています。常に最新のコミットをレプリケートするため、次のチェックポイントにはコミット 7 が含まれます。

レプリカはこのチェックポイントを受信し、コミット 7 でリフレッシュしようとします。インデクサーでの高い更新トラフィックとマージアクティビティにより、コミット 7 のインデックスはコミット 1 と大きく異なる可能性があります。これは不合理ではありません。結局のところ、5 つのコミット分のインデックス化アクティビティをスキップしたのです。理論的には効率的に見えるかもしれません(1 回のダウンロード、1 回のリフレッシュでレプリカが最新になる)が、大量の新しいインデックスセグメントでリフレッシュすると、システムに大きなストレスがかかる可能性があります。

OS ページキャッシュは大量の新しいデータをロードする必要があり、既存のページを一時的に退避させるページフォールトがトリガーされます。ただし、前述のように、Lucene のサーチャーマネージャーは、既存の実行中のクエリが開始したのと同じサーチャーで完了することを保証します。そのため、インデックスの一部が新しいポストリフレッシュサーチャーにとって古くなっていても、古いサーチャーですでに進行中のクエリによって使用されます。これによりメモリの競合が発生し、ページフォールト、スラッシング、顕著なレイテンシスパイク、そして最終的には検索リクエストのタイムアウトにつながります。

単一チェックポイント方式では、各レプリカがすべての蓄積された変更を一度に吸収することを強制します。シンプルで正しいですが、レプリカが吸収するチェックポイント間のギャップが大きい場合、すべてのコストを一度に支払うことになります。この「崖」のような動作こそ、私たちが滑らかにしたかったものです。

バイトサイズコミットと適応的リフレッシュ

バイトサイズコミットでは、チェックポイントの構築と消費方法を再考します。最新のコミットのみをバンドルする代わりに、ライターは小さなローリング履歴のコミットを保持し、それらを単一のチェックポイントとしてまとめて公開します。検索側では、レプリカは 1 つではなくコミットのセットを受信します。これらのコミットを段階的にステップスルーし、管理可能なデルタをダウンロードし、小さく予測可能なサイクルでリフレッシュできます。

鋭い読者は、Lucene の refreshIfNeeded API がデフォルトで最新のコミットでのみリフレッシュすることに気付くでしょう。私たちは、SearcherManager(および関連クラス)がリフレッシュの候補コミットをインテリジェントに選択できるようにする 変更を Lucene に貢献しました。コンシューマー実装は、現在のサーチャーコミットと利用可能なコミット間のバイト差を計算し、利用可能なサーチャーメモリに収まる最新のコミットを選択するなど、適切なコミットを選択するロジックを追加できます。これにより、レプリカはバイトサイズコミットで適応的にリフレッシュできます。すべてのコミットが十分に小さい場合、最新のものに直接ジャンプできます。逆に、提供されたすべてのコミットを段階的にステップスルーすることもできます。これらの変更は Lucene 10.3 の一部として利用可能です。

バイトサイズコミットのためのインデクサーのセットアップ

インデクサー側では、Lucene の IndexWriter は以前と同様に動作し続け、インデクサーマシンでコミットを作成します。ただし、各コミットを前のコミットからの小さな差分として保つために、コミット頻度を増やすことを検討する必要があるかもしれません。たとえば、1 分ごとにコミットを作成する代わりに、15 秒ごとに作成することを検討できます。これにより、レプリカが選択できる 1 分間のウィンドウあたり 4 つの増分ホップが得られます。これは多くの可能なアプローチの 1 つです。別のより良い方法は、セグメントのターンオーバーが設定されたしきい値サイズを超えたときに正確にコミットをトリガーすることです。

IndexWriters は、不要になった古いコミットをクリーンアップするために IndexDeletionPolicy で設定されます。デフォルトは KeepOnlyLastCommitDeletionPolicy で、最新のコミットのみを保持します。チェックポイントに複数のコミットを保持したいので、別のインデックス削除ポリシーを使用する必要があります。Lucene は、最後の N 個のコミットを保持する KeepLastNCommitsDeletionPolicy を提供しています。指定された時間ウィンドウ内のすべてのコミットを保持するなど、特定のビジネス要件に基づいて独自の削除ポリシーを作成することもできます。

最後に、レプリケーションチェックポイントを更新して、まとめてバンドルしたいすべてのコミットを含める必要があります。通常、インデックスで現在利用可能なすべてのコミットです。設定された削除ポリシーが、保持ウィンドウの外にあるコミットのクリーンアップをすでに処理しているためです。

この基盤により、インデクサーをマルチコミットチェックポイントを公開するように設定し、レプリカがインテリジェントで適応的なリフレッシュ決定を行えるようになります。

適応的リフレッシュのためのレプリカでの変更

レプリカは各チェックポイントでコミットのセットを受信するようになりました。目標は、最終的に最新のコミットでリフレッシュすることです。ただし、最新のコミットに直接ジャンプする代わりに、レプリカは安全にリフレッシュできる一連のコミットを通じてパスを計画できるようになりました。

Lucene 10.3 で、ユーザーがリフレッシュするコミットを選択する戦略を指定するために実装できる RefreshCommitSupplier インターフェースが追加されました。シンプルなアプローチは、しきい値ベースの戦略です。ランタイム環境で利用可能なメモリに基づいて静的しきい値を定義し、現在のサーチャーコミットと利用可能なコミット間の「バイトデルタ」を計算し、しきい値を下回るデルタを持つ最新のコミットを選択します。各 Lucene コミットは、(long) コミット生成 ID によって一意に識別され、新しいコミットにはより高い生成 ID が割り当てられます。

ただし、適応的リフレッシュは Lucene のリフレッシュデルタを滑らかにしますが、マシンでチェックポイントをダウンロードする行為自体もメモリの競合を引き起こす可能性があることに注意が必要です。結局のところ、同じマシンで実行され、同じメモリを使用しています。

以下のステップでは、リフレッシュするバイトのみを外科的にダウンロードしてリフレッシュし、レプリカにストレスを与えない増分で行う詳細な戦略について説明します。

1) 軽量なコミットメタデータのみを最初に取得する レプリカは最初に軽量なコミットメタデータのみをダウンロードします。チェックポイントに存在するすべてのコミットの segments_N および .si(セグメント情報)ファイルです。これらのファイルは、各コミットによって参照されるインデックス構造とセグメントを記述しますが、完全なセグメントデータと比較すると非常に小さいです。

2) コミット差分を計算する コミットメタデータを使用して、レプリカはコミットがもたらす新しいセグメントファイルのリストを作成します。これらのファイルのサイズにより、各コミットがインデックスにもたらす新しいバイト数がわかります。これが、リフレッシュコミット選択戦略で使用する「コミット差分」です。各セグメントファイルのサイズを保持するシンプルなメタデータファイルをチェックポイントに維持することで、実際にセグメントファイルをダウンロードすることなく、このコミット差分を計算できます。

3) リフレッシュに最適なコミットを選択する しきい値ベースの選択戦略の例を使用すると、レプリカは利用可能な各コミットのコミット差分を評価し、しきい値を下回る最新のコミットを選択します。リフレッシュ用のコミットが特定されると、レプリカはそのコミットによって参照されるすべてのファイルをダウンロードし、それでリフレッシュします。

静的に定義されたしきい値を下回るコミットがない場合のエッジケースを処理する必要があることは言及しておく価値があります。これは、ベストエフォートの 15 秒ウィンドウでも大規模な更新ストームが発生した場合や、定義されたしきい値よりも大きいセグメントファイルにつながる積極的なマージが原因で発生する可能性があります。このような場合、単に次のコミットを選択することが最も安全な前進方法であることがわかりました。

4) 最新のコミットでリフレッシュするまでループする 最後に、最新のコミットでリフレッシュするまでこのループを続けます。サーチャーコミットがチェックポイントの最新のコミットと同じになるまで(インデックスがチェックポイントに存在するすべての更新で最新になるまで)、ステップ 2 にループバックします。

実際の動作

この理論が実際の適応的リフレッシュシナリオでどのように見えるか、具体化してみましょう。次のセットアップから始めます:

  • ライターコミット: 1, 2, 3, 4, 5, 6, 7(最新)
  • レプリカ: 現在コミット 1 にある
  • チェックポイント: コミット [1–7] を含み、過去 30 分間のアクティビティを表す
  • しきい値: 5 GB

パス 1(現在のサーチャーコミット = 1、最新のコミット = 7)

現在(1)に対するデルタをチェックし、最新から最古のコミットまで順に確認します:

  • Δ(7, 1) = 11 GB > 5 → スキップ
  • Δ(6, 1) = 9 GB > 5 → スキップ
  • Δ(5, 1) = 6 GB > 5 → スキップ
  • Δ(4, 1) = 5 GB ≤ 5 → コミット 4 を選択

5 GB のしきい値を下回るデルタを持つ最新のコミットはコミット 4 です。そのコミットのファイルをダウンロードし、それでサーチャーをリフレッシュします。

パス 2(現在のサーチャーコミット = 4、最新のコミット = 7)

まだ最新のコミットにいないので、ループを再度実行します。ただし、今回はサーチャーがコミット 4 にあるため、後続のコミットとのバイトデルタは小さくなります:

  • Δ(7, 4) = 6 GB > 5 → スキップ
  • Δ(6, 4) = 5 GB ≤ 5 → コミット 6 を選択

「コミット 6」が 5 GB の安全なリフレッシュしきい値内にあることがわかります。関連ファイルをダウンロードし、コミット 6 でリフレッシュし、サーチャーをチェックポイントの最新のコミットに近づけます。レプリカは、古いポイントインタイムサーチャーから使用されなくなったセグメントファイルを削除し、ディスクスペースを解放することもできます。

パス 3(現在のサーチャーコミット = 6、最新のコミット = 7)

同じループを実行すると、「コミット 7」が安全なリフレッシュしきい値内に収まることがわかります。コミットを取得してリフレッシュし、サーチャーにインデックスの最新データを提供します:

  • Δ(7, 6) = 3 GB ≤ 5 → コミット 7 を選択

このように、1 つの大きなジャンプの代わりに、レプリカは境界のあるバイトで移動し、各リフレッシュはサイズしきい値によって制限されます。単一の 11 GB 転送の代わりに、レプリカは 5 GB、5 GB、3 GB の 3 つの小さなステップを通じて進行します。この場合、新しいバイトの合計は大きくなりますが(13 GB)、個々のリフレッシュはそれぞれ小さく安価であり、システムはネットワーク、メモリ、または CPU 負荷の急激なスパイクを回避できます。

OS ページキャッシュのチャーンが低いため、ページフォールトが少なくなり、検索リクエストのレイテンシがより安定します。プロセス全体は冪等で、再試行可能で、一時的な障害に対してレジリエントです。すべての中間状態は有効な Lucene コミットであるため、最後のコミットポイントからリフレッシュを再開できます。さらに、サーチャーがすでにいるコミットポイントで再度リフレッシュすることは no-op であり、システムに影響を与えません。

鋭い読者は、なぜ単にチェックポイントをより頻繁に作成し、単一コミットチェックポイントを使用し続けないのか疑問に思うかもしれません。これはリフレッシュ効率を実現するために行われます。単一コミットチェックポイント(小さなコミットでも)では、レプリカが最新の変更に追いつくときにすべてのチェックポイントを反復処理する必要があります。同じチェックポイントに複数のコミットがあると、共通のチェックポイントメタデータを使用してそれらをまとめて評価し、リフレッシュするコミットをインテリジェントに決定できます。

結論

セグメントレプリケートシステムにおける大きなチェックポイントジャンプは、根本的な緊張を生み出します。レプリカを迅速に追いつかせたい一方で、一度にあまりにも多くの変更を吸収するとシステムが不安定になる可能性があります。常に最新のコミットをレプリケートする従来のアプローチは「崖」を作ります。レプリカが遅れると、ページフォールト、レイテンシスパイク、そして最終的にはタイムアウトした検索リクエストという 1 つの痛みを伴うステップで全コストを支払う必要があります。

バイトサイズコミットと適応的リフレッシュは、この「崖」を「階段」に変えます。コミットのローリング履歴を維持し、レプリカがそれらを段階的にステップスルーできるようにすることで、予測可能なパフォーマンス特性を維持しながら、レプリカが独自の持続可能なペースで追いつくことができます。各リフレッシュは安全なリソース境界内に留まり、ページキャッシュのチャーンは低く保たれ、更新バーストやネットワークの問題が発生しても検索レイテンシは安定したままです。

このアプローチの優雅さはそのシンプルさにあります。複雑な調整プロトコルも、高価な分散コンセンサスもありません。Lucene がすでに提供しているもの(不変セグメントとアトミックリフレッシュセマンティクス)をインテリジェントに使用するだけです。レプリカは、デルタサイズしきい値などのシンプルなヒューリスティックを使用して、次にリフレッシュするコミットについてローカルな決定を行います。システムは完全に冪等で再試行可能です。リフレッシュ中に何かが失敗した場合、最後に成功したコミットから再開するだけです。各バイトサイズコミットは実際にはインデックスの増分バックアップです。素晴らしい副作用として、停止やデータ破損イベントの場合にインデックスを回復できる細かいポイントインタイムチェックポイントが得られます。

さまざまなネットワーク条件を持つ複数の地理的リージョンにまたがる本番環境では、これは大きな意味を持ちます。帯域幅の制約を経験している遠隔地のデータセンターのレプリカは、危険なほど遅れることなく着実に進歩できます。再起動から回復するレプリカは、1 つの大規模なリフレッシュを試みるのではなく、段階的に追いつくことができます。

同時に、このセットアップによりリモートストレージコストが増加することにも注意が必要です。より頻繁なコミットのスライディングウィンドウを保存するようになったため、頻度の低いチェックポイントではスキップされていたであろう一時的なセグメントもキャプチャします。このストレージの増加は、維持することを選択したチェックポイントのウィンドウと頻度によって直接制御されます。より長いウィンドウはより多くのストレージを消費します。古い、時代遅れのチェックポイントを定期的に削除するリモートストレージクリーンアップポリシーを設定することが重要です。

このアーキテクチャのサポートは Lucene 10.3 で利用可能になり、高スループットで地理的に分散された検索システムに、より安定したレプリケーションへの実証済みのパスを提供します。リフレッシュ中にレプリカがレイテンシスパイクを経験している場合、またはクロスリージョンレプリケーションの課題に対処している場合、適応的リフレッシュはまさにあなたが探していたレジリエントなレプリケーション戦略かもしれません。


OpenSearch Project

Discussion