大規模なキャッシュシステムにルーティング層が必要な理由

大規模なキャッシュクラスターでは、メモリや CPU より先に接続数やレイテンシが限界に達することがあります。規模の拡大に伴いルーティング層とエッジキャッシュが必要になる理由を解説します。

Allen Helton Allen Helton
クライアントからデータベースへのトラフィックを管理する Mo のイラスト

火曜日の午前 3 時。Valkey クラスターのメモリ使用率は 60% なのに、ノードの CPU は 100% に張り付いています。クエリの傾向は通常どおりで、メモリにも問題はなく、明らかなホットキーもありません。

そこで別の変化に気づきます。Kubernetes クラスターでデプロイが行われた直後でした。

直近の数分で、数万のアプリケーションインスタンスが再接続しています。CPU プロファイルのホットパスを占めているのは、リクエスト処理やコマンド実行ではなく、SSL_do_handshake() と accept()、つまり TLS 接続の確立です。

キャッシュを過負荷にしているのはトラフィックではなく、接続の入れ替わりです。

新しい TLS 1.3 ハンドシェイクは毎回、証明書の検証と鍵交換に数ミリ秒の CPU 時間を使います。ロールアウト中に数千もの新規接続が生じると、バックエンドノードは一時的に、リクエストを処理するよりもセッションをネゴシエートする時間のほうが長くなります。

これは NxM 問題が運用上の問題として現れたものです。クラスターが一定の規模を超えると、クライアントからクラスターへ直接接続するアーキテクチャに、この限界が見えてきます。

接続の集約

ルーティング層を設けると、総接続数の増え方は乗算的なものから、線形に近いものへ変わります。バックエンドのキャッシュノードは accept() 呼び出しや TLS ネゴシエーションに CPU 時間を費やさず、本来の役割であるリクエスト処理に集中できます。

Kubernetes の Pod が 1,000 個、キャッシュクラスターが 20 ノードという一般的な構成でも、直接接続には大きなオーバーヘッドがあります。コネクションプールは通常、負荷分散と障害の分離のため、各ホストへの接続を複数維持します。多くの場合、Pod ごと、キャッシュノードごとに 2~5 本です。控えめにホストごと 2 本としても、クラスター全体で 40,000 本のアクティブな接続になります。接続ごとに TCP バッファー用のカーネルメモリを約 4KB 消費し、さらに TLS 状態と接続追跡のためのアプリケーション側のオーバーヘッドが発生します。

ただし、本当の性能問題は定常状態だけではなく、接続の入れ替わりです。デプロイ、オートスケーリング、ノードのメンテナンスで Pod が再配置されるたびに、クラスターは大量の新規 TLS ハンドシェイクに対処します。完全な TLS ハンドシェイクは、ハードウェア、暗号化アクセラレーション、証明書チェーンの深さ、セッション再開の有無によって、接続ごとに数ミリ秒の CPU 時間を要します。頻繁に変化する Kubernetes 環境では、毎分数千回に達することもあります。再起動する各 Pod がクラスター全体で約 20 本の TLS 接続を維持しているなら、1,000 個の Pod の再起動で約 20,000 回の新規ハンドシェイクが発生します。1 回に数ミリ秒の CPU 時間がかかると、TLS 接続の確立だけで合計数十 CPU 秒を費やし、その負荷はバックエンドノードに偏って分散します。こうしてキャッシュノードは、リクエスト処理ではなくインフラのオーバーヘッドに CPU の 30~40% を使うようになります。

ルーティング層を設けると、トポロジーは大きく変わります。多数の接続を多重化する役割を担うため、ルーティング層自体をクライアント Pod 数より大きくすることも可能です。50 ノードのルーティング層が 1,000 個のクライアント Pod に対応する構成では、クライアントからルーターへの接続は 2,000~5,000 本、ルーターからキャッシュノードへのバックエンド接続はわずか 1,000~2,500 本になります(ルーティングノードごと、キャッシュノードごとに 2~5 本)。

接続状態のオーバーヘッドは 1 桁減りますが、それ以上に重要なのは CPU プロファイルの変化です。以前はサイクルの 30~40% を接続管理に使っていたバックエンドノードが、95% 以上をリクエスト処理に使えるようになります。TLS ハンドシェイクの頻度は、クライアント側のデプロイ作業から切り離されます。Pod は再起動すると、長期間稼働するルーティングノードに再接続するため、バックエンドクラスターは接続の集中にさらされません。ルーティングノードがハンドシェイクのコストを一度引き受け、数百万のリクエストで再利用できるバックエンドへの永続接続を維持します。

この変化はプロファイリングデータにも明確に現れます。直接接続する構成では、デプロイに伴う接続の入れ替わりの際に、SSL_do_handshake() と accept() がバックエンドの CPU プロファイルを占めがちです。ルーティング層があれば、これらのシンボルはバックエンドのホットパスからほぼ消え、ルーティング層の処理に現れるようになります。

この考え方自体は以前からありますが、実装の品質には大きな差があります。Twitter は、コネクションプーリングを主目的とする軽量プロキシ Twemproxy を開発し、Memcached クラスターを接続の集中から守りました。Facebook は、より高度なルーティングロジックと一部のキャッシュ機能を備えた McRouter を開発し、非常に大きな規模で接続の負荷を管理しました。どちらも接続のオーバーヘッドに伴う複雑さをキャッシュノードの外へ移し、運用を容易にするためのシステムです。

これらのシステムはルーティング層というパターンの価値と同時に、その複雑さも示しました。ルーティング層で十分な性能を得るには、システムに関する深い専門知識が必要です。理論だけでなく、役立つ層と足を引っ張る層を分ける実装上の詳細まで理解しなければなりません。ハッシュテーブルの検索におけるロック競合、シリアライズ処理での CPU キャッシュミス、GC 停止を引き起こすメモリアロケーションのパターンは、大規模な本番環境で現れます。避けるには慎重な設計が必要です。

接続の集約は、直接的な効果の第一段階にすぎません。その先でルーティング層が何をするかによって、アーキテクチャの選択が大きな差を生みます。

軽量なルーティングの限界

データベースの世界では、この教訓は先に知られていました。PgBouncer は PostgreSQL のコネクションプーリングで定評のあるツールです。軽量で実績があり、接続の多重化に特化しています。NxM の接続問題を集約し、データベースがソケット管理ではなくクエリに CPU サイクルを使えるようにする、という一つの仕事を非常にうまくこなします。

この集中した役割こそが、PgBouncer の成功の理由です。クエリオプティマイザーやアプリケーションキャッシュになろうとはせず、予測可能かつ効率的に接続の負荷を管理します。多くのワークロードでは、それこそが必要な機能です。

キャッシュシステムの従来型ルーティング層も、多くは同じ思想に従っています。リクエストを受け取り、転送先を決めて転送し、レスポンスを返します。予測可能で高速に動作するよう設計され、障害時の挙動を複雑にする状態や判断を意図的に避けます。この単純さには価値があります。ルーターを拡張しやすく、動作も把握しやすくなり、多くの構成では最も深刻な接続問題を解消するのに十分です。

しかし、このような接続の集約を導入しても、すべてのリクエストトラフィックはそのままバックエンドクラスターに流れます。キャッシュミス、リトライ、ホットキーによるバーストも変わらず転送されます。GET リクエストがルーターに届くと、ハッシュ計算で対象ノードを決め、転送し、レスポンスを待ってクライアントに返します。

ホットキーで何が起きるか考えてみましょう。一つのキーが毎秒 100 万件のリクエストを受ければ、その 100 万件はすべてバックエンドに届きます。ただし分散の仕方はクラスター構成に依存します。クライアントが結果整合性のある読み取りを行えるレプリケーション構成では、レプリカ数に応じて負荷が分かれます。レプリカが 5 個なら、各ノードはそのキーに対する約 200,000 件のリクエストを処理します。レプリケーションがなければ、100 万件すべてが一つのノードに集中します。

いずれの場合も、ルーティング層は接続を集約しただけで、リクエストの分布を根本的には変えていません。バックエンドノード、またはレプリカノードは依然として、毎秒数十万の GET を処理します。この規模では、十分に最適化されたキャッシュにも望ましくない挙動が現れ始めます。ハッシュテーブルのバケットでのロック競合、LRU メタデータ更新時の CPU キャッシュラインの移動、アクセスカウンターのミューテックス待ちが perf プロファイルに現れます。一つのホットキーだけで、ロック待ちが CPU 時間の 2 桁の割合を占めるようになり、同じ領域にハッシュされる周辺のキーの性能も低下します。

ルーティング層はクラスターを接続の過負荷から守りますが、リクエストの負荷は減らしません。負荷の届き方を変えるだけで、届く量は同じです。

レイテンシのコストも問題を大きくします。すべてのリクエストでネットワークホップが一つ増えます。適切な構成でも、トポロジー、シリアライズ、同一場所への配置状況により、サブミリ秒から数ミリ秒のレイテンシが追加されます。バックエンドへの直接アクセスと比べ、リクエストごとのレイテンシが無視できないほど増える構成もあります。GC 停止や CPU 競合で既に p99 が 5~10ms に達している処理では、ルーティングの 1 ホップによって SLA の境界を超える可能性があります。

性能工学の観点では、軽量なルーティング層は、スループットとレイテンシのトレードオフです。バックエンドの接続オーバーヘッドを取り除く代わりに、すべてのリクエストに 1 ホップを加えます。p99 より p50 のレイテンシが重要な場合や、バックエンドの CPU が接続管理によって実際に制約されていた場合には、有益な選択です。既に SLA の限界に近い、レイテンシに敏感なワークロードではそうとは限りません。

今度はルーティング層自体がホットパスの一部になります。もともと問題だったトラフィックのパターンが、より少ない入り口の背後に集中するだけです。障害の要因は取り除かれず、場所が移ったにすぎません。

キャッシュ階層としてのルーティング

このパターンには見覚えがあるかもしれません。CDN も同じ進化をたどりました。初期のエッジ層はリクエストをオリジンサーバーに転送するだけで、負荷の形を十分に変えられませんでした。転機は、エッジのインフラがルーティングだけでなく、トラフィックを吸収するようになったことです。エッジでキャッシュすることで、オリジンを読み取りレイテンシやリクエスト数ではなく、書き込みスループットと永続性に向けて最適化できました。

Momento のルーティング層は、この教訓をキャッシュインフラに直接応用しています。受動的なプロキシとしてだけ動くのではなく、データ経路に能動的に参加します。アクセスの多いキーをルーティング層自体で直接キャッシュするため、読み取りトラフィックの大部分はバックエンドのキャッシュクラスターに届きません。

各ルーティングノードは、ホットデータ用のインメモリキャッシュを持ちます。そこを通る最も頻繁に使われるキーをキャッシュし、キャッシュノードに流れるはずだった負荷を引き受けます。性能への影響は測定可能です。頻繁な読み取りをルーティング層で吸収すると、バックエンドのリクエスト数を大幅に減らせます。

キャッシュ項目には、設定可能な短い TTL を設けます。この短い有効期間により、明示的な無効化がなくても古いデータがルーティング層に長く残りません。TTL が切れると項目は失効し、次のリクエストがバックエンドから新しいデータを取得します。

レイテンシの特性も根本的に変わります。ルーティングキャッシュのヒットは、最適化されたシリアライズ処理を使った純粋なインメモリアクセスで、約 100 マイクロ秒(0.1ms)で完了します。バックエンドまで往復するプロキシ経由のリクエストの 1.5~2ms や、サブミリ秒の直接アクセスと比較してみてください。大多数のリクエストでは、ルーティング層を追加することで、観測されるレイテンシが実際に 1 桁小さくなります。

キャッシュのレイテンシ性能を比較したグラフ

この改善は中央値だけでなく分布全体に現れます。ルーティングキャッシュが吸収するのは、ロック待ちや CPU キャッシュラインのスラッシングを引き起こしていたホットキーへのリクエストそのものなので、p99 は p50 より大きく改善することがよくあります。それらをルーティングキャッシュから 100μs で処理すると、問題のあるアクセスパターンが除かれ、バックエンドの p99 レイテンシが下がります。

キャッシュの整合性

キーが更新されたらどうなるのでしょうか。ルーティングノードがデータをキャッシュしている場合、整合性をどう維持するのでしょうか。

書き込みは別々のルーティングノードに届く可能性があります。クライアントがルーター A 経由で書き込み、その後ルーター B 経由で読み取ると、B にはまだ古いコピーが残っているかもしれません。バックエンドではハッシュによって処理先を決めます。ルーティングキャッシュは、主に短い TTL と無効化の仕組みによって古さを制限します。書き込みが異なるノードを経由できる以上、一時的な不整合は起こり得ますが、これは通常のキャッシュのセマンティクスに沿ったものです。

大半のキャッシュワークロードには、この整合性の水準で十分です。キャッシュはもともと強整合性のあるシステムとして設計されていません。ルーティング層がなくても、レプリケーションの遅延やクライアント自身のローカルキャッシュによって、古いデータが見える場合があります。ルーティング層の整合性モデルは、書き込み側には直ちに反映され、すべての読み取り側には最終的に反映されるという、クライアントが既にキャッシュに期待するセマンティクスに一致します。

より強い保証が必要なワークロードには、クライアント初期化時に設定する ReadConcern パラメーターを用意しています。Consistent にすると、そのクライアントのすべてのリクエストはルーティングキャッシュを完全に迂回し、常にバックエンドのストレージ層から読み取ります。バックエンドから直接読むことで、読み取りの鮮度をより強く保証します。大半のワークロードは、性能のためにルーティングキャッシュを利用する既定の Balanced モードを使いますが、厳格な整合性要件があるアプリケーションは整合性のある読み取りを設定できます。

ホットキーへの対応

ルーティング層が予測可能性に与える効果が最もよく見えるのは、ホットキーです。サービスの起動ごとに読む設定値や、リクエストごとに確認する機能フラグを考えてみてください。エッジキャッシュがなければ、少数のバックエンドレプリカに負荷が集中し、ロック競合や CPU の急増となって現れます。

ルーティングキャッシュを置くと、アクセスの集中を吸収できます。バックエンドへのリクエストが 1 件で済む間に、エッジのメモリから数千件を処理できます。効果はツールにもすぐ現れます。以前の perf top で上位にあった pthread_mutex_lock やハッシュテーブルの競合が、表示から完全に消えることも珍しくありません。バックエンドの CPU 使用率は通常の水準に戻り、それ以上に重要なことに、レイテンシのばらつきが小さくなります。キーの読み取り時間が、それを求めるクライアント数にほぼ左右されなくなります。

ルーティング層は、同時に起きるキャッシュミスも集約します。キャッシュにないキーへのリクエストが多数同時に届いても、転送するのは 1 件だけです。他のリクエストは待機し、その結果を共有します。これにより、期限切れやデプロイをきっかけに重複処理が一時的にバックエンドへ殺到し、p99 が SLA を超える「thundering herd」現象を防げます。

エッジでのポリシー適用

接続管理と頻繁な読み取りの吸収に加え、ルーティング層はアクセス制御を一元的に適用する場所にもなります。

クライアントがキャッシュクラスターへ直接接続する構成では、認可ロジックは各クライアントかキャッシュノード自体に置かれます。どちらも拡張性に欠けます。クライアント側での適用は監査や一貫性の維持が難しく、ノード側での適用はシステムで最も頻繁に実行される部分に直接オーバーヘッドを加えます。

ルーティング層は、ポリシーを適用する自然な場所です。リクエストがバックエンドのインフラへ届く前に、細かなアクセス制御を一度評価できます。

Momento では各リクエストに認証トークンが付きます。ルーティング層は署名、有効期限、失効状態を検証して、関連する権限を取り出します。権限はキャッシュ単位、操作単位です。たとえば、prod-cache-1 では読み書きを許可し、prod-cache-2 では読み取りだけを許可できます。

認可チェックは、認証後、ルーティング前に行います。拒否されたリクエストはバックエンドのリソースを使いません。これは設定を誤ったクライアントや悪意あるトラフィックからの保護に重要です。アクセス権のないキーを読もうとするクライアントは、バックエンドのキャッシュノードに到達せず、ルーティング層で拒否されます。

トレードオフ

ルーティング層を導入すると、運用、監視、スケーリングが必要な階層が一つ増えます。状態を持つ層なら、追い出し、障害復旧、オブザーバビリティにも慎重に対応しなければなりません。性能特性も変わるため、中央値だけでなくレイテンシ分布全体を理解する必要があります。

運用の複雑さ

各ルーティングノードで、キャッシュヒット率、メモリ使用量、CPU 使用率、リクエストのレイテンシを監視する必要があります。さらに重要なのは、それらのメトリクスの分布を理解することです。ヒット率が低ければ、キャッシュサイズが適切でないか、ワークロードがキャッシュに向いていません。メモリ使用量が多いなら、キャッシュすべきでない大きな値を格納している可能性があります。CPU の急増は、ホットキーの集中や高コストの認可チェックを示すかもしれません。

特に重要なのは、ノード間のヒット率のばらつきです。負荷が適切に分散されたルーティング層では、全ノードのヒット率の差は 5~10% 以内に収まるはずです。あるノードは 80%、別のノードは 30% という大きな差は、不均等な負荷分散やコンシステントハッシュの問題を示し、性能上のホットスポットを生みます。

メモリのオーバーヘッド

ルーティングキャッシュは、バックエンドのストレージに使えたはずのメモリを消費します。ルーティングノードがバックエンド容量の 10~20% を使うなら、クラスター全体のメモリ使用量にそれを含める必要があります。メモリコストが主要な関心事である環境では、バックエンド負荷の削減やレイテンシ改善によって、このオーバーヘッドに見合う価値を示す必要があります。

適切なサイズはワークロードの特性に依存します。キーの再利用が多い読み取り中心のワークロードは、ルーティングキャッシュの恩恵が大きく、サイズを大きくする価値があります。書き込み中心なら恩恵は少なく、より小さくするか、特定のキャッシュではルーティング層でのキャッシュを完全に無効にする選択もあります。

整合性のトレードオフ

無効化に基づく整合性モデルでは、異なるルーティングノードが同じキーの異なるバージョンを返す期間があります。通常、キャッシュは性能と整合性を引き換えにするため、大半の用途では許容されます。しかし強整合性が必要なアプリケーションは、この挙動を理解し、該当するキーをルーティング層でキャッシュしないか、追加の調整を行う必要があります。

レイテンシの考慮事項

ルーティング層のキャッシュはヒット時のレイテンシを削減しますが、ミス時や書き込みにはレイテンシを加えます。ミス時には、クライアント → ルーター → バックエンド → ルーター → クライアントという 2 ホップが必要になります。ホップごとに、シリアライズとデシリアライズのオーバーヘッド、およびネットワーク転送時間が加わります。

この追加ホップは、キャッシュミスと書き込みの両方に影響します。バックエンドへの直接接続なら 1.5ms で終わった書き込みが、ルーティング層を経由すると 2.5~3ms になります。ヒット率が高い読み取り中心のワークロードでは、大半の操作がミリ秒ではなく 100 マイクロ秒で完了するので、全体のレイテンシ特性は大きく改善します。

効果が逆転する点は、ワークロードの特性、特にルーティングキャッシュのヒット率と、ルーティング経由のリクエストと直接アクセスのレイテンシ差に依存します。ルーティングキャッシュのヒットはバックエンドへのアクセスよりはるかに速い一方、ミスではルーティング層の追加ホップのコストを払います。

ヒット率が上がるほど、ルーティング層のメモリから直接処理される割合が増え、平均レイテンシは改善します。時間的局所性が強い、または頻繁に使われるキーがあるワークロードでは大きな削減が見られますが、アクセスが一様な場合は効果が小さいかもしれません。実際の影響は、理論上の平均から推測せず、本番のトラフィックで測定するのが最善です。

性能を重視するチームは、自分たちのワークロードで実測すべきです。理論的な計算より、本番のトラフィックパターンで観測されるレイテンシのほうが重要です。強い時間的局所性や高いキー再利用率により、ルーティングキャッシュのヒット率が 90% を超え、劇的な改善が得られるワークロードもあります。一様なキーアクセスで再利用が少ない場合は 20~30% のヒット率にとどまるものの、100μs のヒット性能によって、なお全体のレイテンシが改善することもあります。

ルーティング層が担うべき役割

システムが一定の規模に達すると、性能上、何らかのルーティング層が必要になります。問うべきなのは、中間に何かを置くかどうかではなく、その層にどの責務を持たせるか、そしてその選択をどれだけよく実装するかです。

接続を集約するだけにするのか、集中するトラフィックも吸収するのか。リクエストの転送だけか、ポリシーを適用してバックエンドを不適切な利用から守るのか。さらに重要なのは、データをキャッシュする場合、キャッシュ自体がボトルネックにならないようどう保証するかです。

軽量なルーティング、つまりコネクションプーリングを伴うリクエスト転送は、主なボトルネックが接続のオーバーヘッドであり、バックエンドがリクエストの全負荷を処理できる場合に適しています。運用は簡単で、障害の起こり得る範囲は小さく、追加レイテンシも最小限です。ただしホットキーの問題は解決せず、バックエンドのリクエスト数も減りません。ピーク時のリクエストレートに合わせたバックエンド容量が引き続き必要です。

状態を持つルーティング、つまりルーティング層でのキャッシュは、リクエストの分布がボトルネックとなる場合に適しています。ホットキーのロック競合、バーストによる CPU の急増、読み取りの割合が高く同じデータが繰り返し使われる状況です。より慎重な容量計画と監視が必要ですが、バックエンドに届く負荷を根本的に変えます。適切に実装すれば、バックエンドのリクエスト数を 70~90% 減らしながら、レイテンシも改善できます。

決定的な違いは実装品質です。ルーティング層にキャッシュを置くだけでは足りません。ロックフリーのデータ構造や CPU 効率の高いシリアライズによる速度、TTL に基づく無効化やリクエストの集約、コンシステントハッシュによる適切な制御、そしてヒット率、レイテンシ分布、追い出し率の可観測性が必要です。ルーティング層に組み込む設計判断が、システムへの負担を実質的に減らすか、ボトルネックを移すだけかを決めます。大規模な環境では、この違いが、成長してもサブミリ秒のレイテンシを維持するシステムと、容量を増やしても p99 が悪化し続けるシステムを分けます。

Momento がプラットフォームに組み込んだ理由

これらすべてを正しく構築して調整できる専門知識や時間を持つチームは多くありません。接続の集約、頻繁な読み取りの吸収、ポリシーの適用、状態を持つルーティング層の運用には、深い性能工学の知識が必要です。CPU プロファイルとロック競合、デプロイ中の接続の入れ替わりに伴う TLS ハンドシェイクのコスト、偏ったアクセス下での追い出し挙動、コールドスタートの影響がレイテンシ分布に広がる仕組みを理解しなければなりません。平均値ではなくテールレイテンシとばらつきがルーティングの性能を決めるため、こうしたパターンを明確に把握するオブザーバビリティも構築する必要があります。

Momento のルーティング層は、この負担を取り除くためにあります。各チームが接続の多重化、ホットキーの検出、キャッシュの整合性戦略を独自に設計する代わりに、数千もの本番ワークロードで観測したパターンを基に、プラットフォームの一部として実装しています。

ルーティング層はハブアンドスポークモデルで接続を集約し、アプリケーションのデプロイ作業をバックエンドの接続負荷から切り離します。読み取りにも能動的に参加し、バックエンドノードに届く前にホットなトラフィックを吸収します。さらに、アクセス制御を一元的に適用し、アプリケーションコードやストレージノードにロジックを追加せずに、設定ミスや権限のないクライアントからバックエンドを保護します。

運用上のシグナルも組み込まれています。ヒット率、レイテンシ分布、接続の入れ替わり、追い出し挙動を直接公開するので、チームは独自の計装を作らずに、負荷がかかったときのシステムの挙動を理解できます。目標はトレードオフをなくすことではありません。トレードオフは残りますが、各組織が同じ障害パターンを個別に再発見する代わりに、一元管理して継続的に調整します。

こうしてルーティング層は、規模が大きくなってから緊急に追加するものではなく、システムの性能を保証する構成の一部になります。接続の集約、ホットキーの保護、ポリシーの適用は、後付けの機能ではなくプラットフォーム自体のアーキテクチャ特性です。バックエンドがオーバーヘッドの管理ではなくリクエスト処理に CPU サイクルを使い、トラフィックの形が変わっても予測可能なレイテンシを維持できるよう設計されています。

本記事の多くの考え方は、Unlocked でも取り上げられました。性能に敏感なシステムを運用するエンジニアが、本番環境の接続負荷、ホットキーの挙動、ルーティング戦略について知見を交換した場です。こうしたトレードオフに心当たりがあるなら、Unlocked はまさにそのような対話を引き出すための場です。

あわせて読みたい