プロンプトとスコープ
高アクセスの製品設定キーに毎秒50,000件の読み取りが発生しています。元のデータベースは毎秒200クエリまで安全に処理でき、キャッシュの再構築にはp95で800ミリ秒かかります。キャッシュデータの鮮度は10分間維持され、ビジネス上は最大30秒の古さ(staleness)が許容されます。このサービスは、リモートキャッシュと同じソースデータベースを共有する100台のステートレスなアプリケーションインスタンス上で稼働しています。
読み取りとリフレッシュの完全なパスを設計してください。ソフト有効期限、ハード有効期限、最初のコールドロード、リフレッシャーのクラッシュ、キャッシュ停止、およびリフレッシュ中のソースデータ変更を網羅してください。また、この設計が単に並行負荷をデータベースへ横流ししていないことをどのように証明するか説明してください。スループット、レイテンシ、有効期限の値は面接用の前提条件であり、特定の製品に関する性能主張ではありません。
これはバックエンドの信頼性に関する質問です。2026年のSRE面接の公開質問集には、毎秒100,000リクエストの状況下で重要なキーが期限切れになることによって引き起こされるキャッシュスタンピードが明示的に提示されています。本シナリオはそれを測定可能なエンジニアリングの制約条件に落とし込んだものです。主な課題がインスタンス間の並行処理と障害セマンティクスにあるため、プロセス内LRUエビクションポリシーの実装とは異なります。
面接官が評価するポイント
第一に、候補者は有効期限切れのイベントを定量化する必要があります。800ミリ秒の再構築中にすべてのアクセスがキャッシュをバイパスした場合、およそ 50,000 × 0.8 = 40,000 件のリクエストがオリジンへ到達しようとします。これは毎秒200クエリという安全なレートをはるかに超えています。キャッシュエントリが消失した際、単にキャッシュを追加しただけでは同期された負荷を解決できません。
第二に、優れた回答は3つの保護レイヤーを明確に区別します。ローカルでのリクエスト結合(request coalescing)は1つのプロセス内のみを制約します。100台のインスタンスがそれぞれリフレッシャーを選出した場合、システムは依然として100件の並行再構築を発行する可能性があります。分散リースは通常、インスタンス間のリフレッシュを1件に減らしますが、リースの失効、プロセスの停止、ネットワーク分断によって重複リフレッシュが発生する可能性は残ります。したがって、オリジンの並行性バルクヘッド(bulkhead)やレート制限を独立した最終防衛線として維持しなければなりません。
第三に、有効期限のセマンティクスが明示的でなければなりません。新鮮なデータは即座に返却されます。ソフト有効期限の経過後は、バックグラウンドでリフレッシュを試行しつつ、30秒の許容期間内であれば古いデータを返すことができます。ハード有効期限の経過後は、無期限に古いデータを提供することはできません。リフレッシュ権限限を持たないリクエストは、すべてがオリジンに到達するのではなく、制限時間内だけ待機するか、縮退動作(degrade)するか、エラーを返さなければなりません。
最後に、古いリフレッシャーが新しい値を上書きするのを設計でどのように防ぐかを面接官に説明する必要があります。リースは有効期間中のみ排他性を提供するものであり、exactly-once(厳密に1回)の保証ではありません。キャッシュエントリにはソースのバージョンまたは世代が必要であり、書き込み時はデータを置き換える前にバージョンを比較しなければなりません。
確認すべき質問
- 古いデータを提供しても本当に安全か? このシナリオでは最大30秒が許容されています。残高、認可、在庫引き当てなどは、より厳格な動作が必要になる場合があります。
- オリジンの制限は何を測定しているか? 毎秒200クエリをデータベース全体の安全限界として扱い、キーごとの並行性およびタイムアウト制限についても確認します。
- コールドロード時にデフォルト値を返せるか? デフォルトでは古い値が存在しないため、1つのリフレッシャーのみがオリジンにアクセスし、フォロワーは制限時間内だけ待機します。静的なデフォルト値が存在する場合は、明示的な製品の縮退動作となります。
- エントリはどのように無効化されるか? 全コピーを同じ秒数で物理削除するのではなく、論理的な鮮度とハード有効期限のタイムスタンプを使用します。ソースの変更イベントによってエントリを早期にリフレッシュまたは無効化することも可能です。
- マルチリージョン構成か? まずは1リージョン内の100インスタンスから検討します。マルチリージョンの場合はオリジン予算の割り当てが必要であり、リージョン間ロックがすべての障害モードを解決すると想定すべきではありません。
- 呼び出し元はデータが古いことを知る必要があるか? 内部レスポンスとテレメトリには、少なくとも
stale_ageと縮退の理由を記録すべきです。エンドユーザーに見せるかどうかは製品要件に依存します。 - キャッシュが障害を起こした場合はどうなるか? 縮退動作とオリジンの予算を事前に定義します。キャッシュエラーが発生したからといって、すべてのリクエストがデータベースに問い合わせてよいわけではありません。
30秒での回答
「私なら値とともに fresh_until、stale_until、およびソースバージョンをキャッシュします。新鮮なヒットは直接返します。30秒の許容期間中は古い値を返しつつ、プロセスごとのsingleflightと所有権トークンおよびTTL付きの分散リースを用いてリフレッシャーを選出します。コールドミスまたはハードミスの場合、フォロワーは制限時間内のみ待機し、ジッターを加えて再読み取りします。リフレッシュ時の書き込みはソースバージョンを比較し、リースの解放時にはトークンを検証します。リースの失効によって重複が発生する可能性があるため、データベース側にもキーごとおよびグローバルのバルクヘッドが必要です。有効期限の負荷テスト、リフレッシャーのクラッシュ、リースの超過、キャッシュ停止訓練を通じて、オリジンのQPS、並行性、最大の古さを検証します。」
ステップごとの詳細解説
ビジネス上の値だけを保存するのではなく、キャッシュエントリを定義することから始めます。
CacheEntry {
value
source_version
generated_at
fresh_until
stale_until
}fresh_until は10分間の鮮度期間の終了を示し、stale_until はそれを最大30秒延長します。リモートキーの物理TTLは、stale_until に少々のクリーンアップマージンを加えた時間をカバーしている必要があります。そうでないと、ソフト有効期限中に提供しても安全な値をキャッシュが削除してしまいます。この許容期間はビジネス上の予算であり、リフレッシュが繰り返し失敗した際に暗黙的に延長してはなりません。
読み取りパスは次のような疑似コードで表現できます。
entry = cache.get(key)
now = clock.now()
if entry exists and now < entry.fresh_until:
return entry.value
if entry exists and now < entry.stale_until:
try_refresh_async(key)
return entry.value
return rebuild_or_wait(key, request_deadline)ソフト有効期限のパスはリクエストのレイテンシを保護します。ソフト有効期限切れを最初に検出したリクエストがバックグラウンドでリフレッシュを試み、他のリクエストは古い値を使い続けます。プロセスごとのsingleflightは、キー単位でリフレッシュ呼び出しを結合します。Goの公開実装では、キーごとに実行中の処理を1つに絞り、その結果を重複する呼び出し元と共有するものとして定義されています。これはプロセス境界を越えないため、100インスタンス全体では単体では不十分です。
インスタンス間のリフレッシュには、時間制限付きのリースを使用します。競合するインスタンスは、ランダムで再利用不可能な token を生成して実行します。
SET refresh:{key} {token} NX PX {lease_ms}リースを取得した後は、別のリフレッシャーが完了したばかりである可能性を考慮してキャッシュを再読み取りし、依然としてリフレッシュが必要な場合のみオリジンにクエリを発行します。リースの解放は、現在の値が所有者の token と一致している場合にのみ行います。単純な DEL は安全ではありません。古いリフレッシャーがリース失効まで一時停止し、後続が新しいリースを取得した後に、古いプロセスが再開して後続のリースを削除してしまう可能性があるためです。Redisの分散ロックのガイドラインでも、安全な解放のためにユニークな値と所有権のチェックが同様に要求されています。
lease_ms は、800ミリ秒のp95に機械的に合わせるのではなく、実測されたリフレッシュのp99にネットワークおよびスケジューリングのマージンを加えた値に設定します。リースが短すぎると重複が増加し、長すぎるとクラッシュ後の引き継ぎが遅れます。リースの失効は新しい候補者が試行することを許可するだけであり、古い処理が停止したことを証明するものではありません。そのため、オリジンの読み取りにはキーごとのsingleflightサービスまたはデータベースのバルクヘッドが依然として必要であり、キャッシュの書き込みは重複実行を許容しなければなりません。
データの読み取り時には、行バージョンやイベントシーケンスなどの単調増加するソースバージョンを取得します。キャッシュの置換は new.source_version >= cached.source_version の場合にのみ行います。ソースに信頼できるバージョンがない場合は、共有の調整用ストレージでアトミックなインクリメントを行ってリフレッシュ世代を割り当て、キャッシュ側でアトミックに比較します。キャッシュは信頼できる唯一の情報源(source of truth)ではないため、遅延したリフレッシュによって変更イベントで既にインストールされた新しいバージョンを上書きしてはなりません。
ハード有効期限切れまたは初回ロード時には、利用可能な古い値が存在しません。リースの所有者はオリジンのバルクヘッドに入った後でのみ再構築を行います。フォロワーは一斉にポーリングするのではなく、リクエストの制限時間内で短くジッターを加えた間隔でキャッシュを再読み取りします。その待機時間が経過した場合は、明示的な縮退レスポンスまたはエラーを返します。製品が許容する場合は静的なデフォルト値を使用できますが、見かけ上の可用性のために50,000件のリクエストすべてをオリジンに送信してはなりません。
オリジンの最終防衛線には、キーごとの並行性上限、グローバルなリフレッシュ並行性上限、およびクエリレート予算を含めるべきです。通常の状態では、1つのキーに対して1つの再構築のみが実行されます。リース障害時でも、バルクヘッドによりオリジンの総作業量が安全な境界内に維持されます。キャパシティが枯渇した場合、リフレッシュは即座に失敗するか制限付きキューに入ります。ソフト有効期限切れのリクエストは30秒以内の値を使い続け、ハードミスは定義された縮退ポリシーに従います。
キャッシュが利用できなくなった際、アプリケーションは読み取り負荷全体をデータベースにシフトしてはなりません。短時間の読み取り専用ニアキャッシュ(near-cache)が許容可能な古い値を提供する場合がありますが、必要なすべてのオリジン読み取りは依然としてグローバルバルクヘッドを通過する必要があります。ニアキャッシュに値がないリクエストは縮退するかエラーとなる必要があります。復旧時は全インスタンスが一斉に再充填するのではなく、ホットキーを制御されたレートでウォームアップします。ランダムなTTLジッターは多数の異なるキーが同時に失効する場合には有効ですが、1つのホットキーが並行して再構築される問題は解決しません。
関連する障害モードは区別して管理する必要があります。キャッシュスタンピード(ホットキーの崩壊)は、既存のホットキーが利用できなくなった後の並行再構築です。キャッシュアバランシェは、多数のキーの同時失効やキャッシュ全体の停止です。キャッシュペネトレーションは、存在しないデータに対する繰り返しの検索です。TTLジッターは主にアバランシェに有効であり、短いネガティブキャッシュやBloomフィルタはペネトレーションに有効です。いずれもこのシナリオにおけるリクエスト結合の代替にはなりません。
アクセス集中が予測されるデータは、fresh_until の前にリフレッシュできます。Cloudflareが公開している確率的早期再検証アプローチでは、有効期限が近づくにつれてリフレッシュ確率を高め、高リクエストレート下でのロック競合を減らします。「リクエストの1%をリフレッシュする」といった固定的なルールは、トラフィックに応じて挙動が変わるため安全ではありません。また、制限付きの許容期間とバックグラウンドリフレッシュは、HTTPの stale-while-revalidate のセマンティクスとも一致します(非同期で再検証が行われている間、明示的な期間内でのみ古いコンテンツが許可される)。
マルチリージョンの場合は、オリジンQPSと並行性予算が割り当てられたリージョンキャッシュおよびリージョンリフレッシャーを採用することをお勧めします。単一のグローバルリースを使用すると、読み取りパスにリージョン間レイテンシと分断時の挙動が持ち込まれます。全リージョンが1つのオリジンを共有する場合、コントロールプレーンがリフレッシュ予算を割り当てるか、オリジンが一元化された再構築サービスを公開します。どちらの場合も、リージョン予算の合計は毎秒200クエリ以内に収める必要があります。
新鮮なヒット率、古いデータ提供率、ハードミス率、データの古さ、リフレッシュの試行と失敗、singleflightで共有された呼び出し元、リースの競合と失効、リフレッシュレイテンシ、オリジンのQPS・並行性・拒否数、キャッシュのレイテンシとエラーを監視します。キャッシュヒット率だけに依存するのではなく、オリジン予算の枯渇、stale_until に近づいている値、継続的なリフレッシュ障害に対してアラートを設定します。
不変条件を直接テストします。毎秒50,000リクエストの負荷下でキーを失効させ、通常ケースではキーあたり1回のオリジン再構築となることを検証します。書き込み前にリフレッシャーをクラッシュさせ、古いデータが利用可能なままであること、およびリース失効後に後続が処理を引き継ぐことを確認します。古いリフレッシャーをリース期間以上に一時停止させ、新しいバージョンを置換できないことを確認します。キャッシュを無効化し、オリジンが毎秒200クエリおよび並行性バルクヘッド内に収まっていることを検証します。多数のキーを同時に失効させ、TTLジッターとグローバル予算をテストします。
優れた回答例
「最悪のケースの負荷から検討します。毎秒50,000リクエストに800ミリ秒の再構築時間を掛けると、有効期限切れの間に約40,000件のリクエストが到着しますが、データベースは毎秒200クエリまでしか安全に対応できません。フォロワーが直接オリジンにフォールスルーすることは許されません。
値とともにソースバージョン、fresh_until、および stale_until をキャッシュします。10分間は直接返し、その後はリフレッシュを試行しながら最大30秒間その値を提供します。各プロセスはまずsingleflightでローカル処理を結合し、競合プロセスは SET lock token NX PX lease を使用してインスタンス間のリフレッシャーを選出します。所有者はデータベースに問い合わせる前にキャッシュを再確認します。リースの解放時はトークンを比較し、キャッシュ書き込み時はソースバージョンを比較するため、古いリフレッシャーが新しいリースを削除したり、新しいデータを上書きしたりすることはありません。
コールドロード時または30秒経過後は、利用可能な古い値がありません。1つのリクエストが再構築を行う間、フォロワーはタイムリミットまでジッターを加えて再読み取りし、その後、明示的なデフォルトの縮退動作を行うかエラーを返します。リースの失効によってリフレッシャーが重複する可能性があるため、データベースにもキーごとおよびグローバルのバルクヘッドを設けます。ロックはキャパシティ保護の代わりにはなりません。
有効期限の境界値で厳密に負荷テストを行い、リフレッシャーのクラッシュ、リースより長い一時停止、キャッシュ障害、並行するソース更新を注入します。合格基準には、オリジンQPSが200以下であること、キーごとの通常再構築が1回であること、データの古さが30秒以下であること、遅延した書き込みによるバージョンの先祖返りがないことが含まれます。これにより、レイテンシ、鮮度、オリジンの安全性に測定可能な境界がもたらされます。」
よくある間違い
- ランダムなTTLジッターのみを追加する → これは異なるキー間の失効を分散させますが、1つのホットキーの並行再構築は防げません → キーごとに処理を結合し、オリジンのバルクヘッドを維持してください。
- プロセス内のsingleflightのみを使用する → 100インスタンスから依然として100個のリフレッシャーが生成される可能性があります → ローカル結合とインスタンス間リースを組み合わせてください。
- リフレッシュ権限を取得する前にデータベースを読み取る → 並行性のスパイクが既にオリジンに到達してしまいます → 最初に選出し、キャッシュを再確認してから、オリジンの予算に入ってください。
- TTLなしで
SETNXロックを取得する → クラッシュしたリフレッシャーが無期限に更新をブロックする可能性があります → 引き継ぎ動作を備えた時間制限付きリースを使用してください。 - 単純な
DELで解放する → 古いリフレッシャーが後続のリースを削除してしまう可能性があります → ユニークなトークンが一致している場合にのみアトミックに解放してください。 - リースを厳密に1回の実行として扱う → TTLを超えて一時停止したプロセスが後続と重複する可能性があります → オリジンのバルクヘッドとバージョン付き書き込みを使用して重複を許容してください。
- 障害発生後に古いデータを永久に提供し続ける → データの古さの上限が失われます →
stale_untilの前にのみ提供し、その後は明示的に縮退するかエラーにしてください。 - キャッシュ停止時に全トラフィックをオリジンへ流す → 毎秒50,000の読み取りは、200が限界のオリジンを圧倒します → 許可される場合はニアキャッシュの値を使用し、すべてのオリジン読み取りを共有予算経由でルーティングしてください。
- スタンピード、アバランシェ、ペネトレーションを混同する → 対策が障害と一致しなくなります → リクエスト結合、TTLジッター、ネガティブキャッシュをそれぞれの問題に応じて使い分けてください。
- ヒット率のみを監視する → 高いヒット率は、失敗したリフレッシュや短時間のオリジンスパイクを隠蔽する可能性があります → データの古さ、リフレッシュ並行性、リース、オリジン予算も監視してください。
フォローアップ
フォローアップ 1: ビジネス上、古いデータを一切提供できない場合はどうするか?
古いレスポンスを排除し、フォロワーには制限時間内のみ待機させます。十分な再構築キャパシティを確保し、オリジンの安全性を損なうことなく明示的なエラーを返します。
フォローアップ 2: リースのTTLはどのくらいの長さにすべきか?
実測されたリフレッシュのp99、ネットワークタイムアウト、スケジューリングの一時停止を基準とし、マージンを加えた上で、処理がリースを超過する頻度を観察します。800ミリ秒のp95単体では不十分です。
フォローアップ 3: リフレッシュ中にソースデータが変更された場合はどうするか?
ソースバージョンを読み取って保持し、条件付きでキャッシュを更新します。変更イベントによってインストールされた新しいバージョンを、遅延したリフレッシュで上書きしてはなりません。
フォローアップ 4: キャッシュクラスタ全体が利用できない場合はどうするか?
許容可能なニアキャッシュの値を提供し、必要なすべてのオリジン読み取りをグローバルバルクヘッドの背後に配置し、コピーが存在しない場合は縮退させ、復旧時は制御されたレートでキーをウォームアップします。
フォローアップ 5: ネガティブキャッシュはどのように適合するか?
ペネトレーションを防ぐため、確認された「not found」結果を短いTTLでキャッシュします。これは既存のホットキーのリフレッシュ結合とは独立しています。
フォローアップ 6: 確率的早期リフレッシュはいつ役立つか?
再計算可能で、ある程度の古さが許容され、リクエストレートが高いデータに有用です。確率は残りの鮮度と観測されたトラフィックに依存させるべきであり、リフレッシュ障害やオリジン予算の強制は依然として必要です。
フォローアップ 7: リージョン間で1つのロックを共有すべきか?
通常は共有しません。リージョンごとにリフレッシュし、オリジン予算を割り当てます。中央での調整が正当化されるのは、グローバルで単一のリフレッシュが必須であり、リージョン間レイテンシや分断が許容できる場合に限られます。
フォローアップ 8: 設計が機能することをどのように証明するか?
クラッシュ、一時停止、キャッシュ障害、バージョン競合を注入しながら、ソフトおよびハード有効期限にまたがって毎秒50,000リクエストを投入します。キーあたり1回の通常リフレッシュ、予算内のオリジン負荷、最大30秒の古さ、バージョンのロールバックが発生しないことを確認します。