キャッシュ無効化とスタンピード対策
人気キャッシュの一斉失効はDBを一瞬で焼き切る。スタンピードが起こる原理と、single-flight・確率的早期失効・stale-while-revalidateの3つの緩和策を押さえれば、失効の瞬間を恐れず設計できるようになります。
- 1.多数のキャッシュが同一TTLで一斉失効すると、リクエストがキャッシュミスで殺到して同じ再計算をN重に走らせ、バックエンドを焼き切るキャッシュスタンピード(thundering herd)が起こる。
- 2.single-flight(再計算の重複排除)と確率的早期失効(XFetch)で、失効を1リクエストに集約し、TTL境界での同時失効そのものを散らす。
- 3.stale-while-revalidateは古い値を即座に返しつつ裏で1本だけ更新し、ミス時のレイテンシ尖りとバックエンド突入の両方を消す。
なぜ失効の瞬間にバックエンドが焼き切れるのか
キャッシュは「計算済みの結果を覚えておき、ミス時だけ重い処理を走らせる」仕組みです。平常時はヒット率が高く、バックエンド(DBや上流API)への負荷はごく一部に抑えられます。問題は キャッシュエントリが失効した瞬間 に起きます。
人気のキー(例:トップページの集計結果)に毎秒1万件のアクセスが来ているとします。このキャッシュがTTL満了で消えると、次の一瞬に到着するリクエストは 全件がミス します。各リクエストは「ミスしたから自分で再計算しよう」と判断し、同じ重い処理を一斉にバックエンドへ投げます。再計算に200ミリ秒かかるなら、その200ミリ秒の間に到着する2000件が、すべて独立に同じクエリを叩く。本来1回で済む計算が数千重に走り、DBが飽和して全体が崩れます。これが キャッシュスタンピード(cache stampede / thundering herd、サンダリングハード) です。
普段のヒット率が99.99パーセントだからこそ、バックエンドはその0.01パーセントしか捌けない容量で運用されています。失効の瞬間にトラフィックの100パーセントが突入すると、平常時の 数千倍 の負荷が一点に集中する。キャッシュが効いている人気キーほど、失効時の落差が大きく破壊的になります。
さらに悪いのは、多数のエントリが 同時刻に揃って失効する パターンです。デプロイ時の一括キャッシュ投入や、毎時0分のバッチ更新でTTLを揃えると、無数のキーが同じ秒に失効し、波が重なります。再計算で弱ったバックエンドが次の波をさらに遅らせ、遅延がタイムアウトと再試行を誘発する——この自己増幅は準安定障害(/devops/cascading-metastable-failures/)そのものです。
TTLジッタ:そもそも同時に失効させない
最も基本的な対策は、TTLに乱数を加えて 失効時刻をばらす ことです。固定TTL(例:全キー600秒)の代わりに ttl = 600 + random(0, 60) のように揺らせば、同一バッチで投入したキーも失効が60秒に散り、波が平坦になります。再試行のジッタ(/devops/retry-backoff-jitter/)と同じ「位相を揃えない」発想で、群れの同期を壊します。
ただしTTLジッタは 複数キー間の同時失効 を散らすだけで、単一の人気キー が失効した瞬間に殺到する問題は解けません。1個のホットキーには失効タイミングは1つしかなく、そこへ集中するミスは別の手段で抑える必要があります。
single-flight:再計算を1本に集約する
単一キーのスタンピードを根治するのが single-flight(再計算の重複排除) です。原理は単純で、「あるキーの再計算は同時に1本しか走らせない」を保証します。
on cache miss for key K:
if 既に K を再計算中のフライトがある:
その完了を待ち、同じ結果を共有する # 自分では計算しない
else:
ロック(フライト)を立てて自分が計算する
計算後にキャッシュへ書き、待っていた全員に結果を返す
ミスが2000件同時に来ても、最初の1件だけがバックエンドへ行き、残り1999件は その1件の完了を待って結果を相乗り します。バックエンドへの突入は常に1リクエストに固定され、増幅が消えます。1プロセス内ならミューテックスやGoの singleflight で十分ですが、複数サーバーにまたがる場合は 分散ロック(Redisの SET NX でキー単位のロックを取り、勝者だけが再計算)に拡張します。
分散ロックを使うsingle-flightは、ロック保持者が再計算中にクラッシュすると 誰もロックを解放できず 全員が待ち続ける危険があります。ロックには必ずTTL(自動失効)を付け、保持者が落ちても一定時間で次の勝者が引き継げるようにします。さらに、待機側にもタイムアウトを設け、ロック獲得待ちが無限にならないよう上限を切ります(/devops/timeout-deadline-propagation/)。
確率的早期失効(XFetch):失効する前に1本だけ更新する
single-flightは「失効した後の殺到」を抑えますが、失効の瞬間には依然として全リクエストが一度ミスを経験します(1本が計算する間、残りは待たされる)。これを 失効前 に解消するのが 確率的早期失効(probabilistic early expiration、通称 XFetch) です。
アイデアは「TTL満了を待たず、満了が近づいたら ごく一部のリクエストが確率的に 早めの再計算を引き受ける」こと。各リクエストはキャッシュヒット時に、残り寿命と前回の再計算コストから、確率的に「自分がいま更新すべきか」を判定します。
# XFetch(Vattani らの確率的早期再計算)
# delta = 前回の再計算にかかった時間, beta ≒ 1.0 の調整係数
# expiry = 絶対失効時刻, now = 現在時刻
if now - delta * beta * ln(random()) >= expiry:
早期に再計算してキャッシュを更新する # この1本だけが先回りする
else:
キャッシュ値をそのまま返す
random() は0から1の一様乱数なので ln(random()) は負(または0)になり、- delta * beta * ln(random()) 全体は0以上の「先取り幅」になります。この幅だけ now を未来へずらして expiry と比べるため、失効が近いほど条件が成立しやすくなります。再計算が重い(delta が大きい)キーほど早めに更新を始めるため、満了の瞬間に間に合わせて 値を差し替えられます。結果として、ほとんどのリクエストはヒットし続け、1本だけがバックグラウンドで先回り更新 する。失効境界での「全件ミス」という崖そのものが消えます。
TTL方式の本質的な弱点は、寿命が 0/1の崖(生きているか、死んでいるか)であることです。XFetchは満了の手前から確率的に更新を前倒しし、崖を坂に均します。single-flightが「殺到を1本に集約する」のに対し、XFetchは「そもそも殺到する瞬間を作らない」。両者は競合せず、組み合わせると先回り更新も1本に固定できます。
stale-while-revalidate:古い値を返しながら裏で更新する
XFetchと並んで実務で広く使われるのが stale-while-revalidate(SWR、ステイルワイルリバリデート) です。HTTPの Cache-Control 拡張としても標準化され、CDNやブラウザ、アプリ内キャッシュで採用されています。
考え方は「TTL満了後も、一定の猶予期間は 古い値(stale)を即座に返してよい。ただし返すと同時に、裏で 1本だけ 再検証(revalidate)を走らせて値を更新する」。
| 方式 | ミス時の挙動 | ミス時レイテンシ | 鮮度 | バックエンド突入 |
|---|---|---|---|---|
| 素朴なTTL | 全員が再計算を待つ | 高(再計算ぶん尖る) | 常に最新 | N重に殺到 |
| single-flight | 1本が計算、残りは待つ | 高(1本ぶんは待つ) | 常に最新 | 1本に集約 |
| XFetch | 満了前に先回り更新 | 低(ほぼ常にヒット) | ほぼ最新 | 1本(先回り) |
| stale-while-revalidate | 古い値を即返し裏で更新 | 低(待たない) | やや古い場合あり | 1本(非同期) |
SWRの強みは、再計算の完了を 誰も待たない ことです。XFetchやsingle-flightでは更新を引き受けた1本(または先回りの1本)が再計算ぶんのレイテンシを被りますが、SWRは古い値を即返しするため、ユーザー体感のレイテンシ尖り(テールレイテンシ、/devops/queueing-theory-tail-latency/)が出ません。代償は 一時的に古い値を返しうる ことで、強い鮮度が要る用途(残高、在庫の最終確定)には向きません。許容できる猶予を stale-while-revalidate=N で明示し、それを超えたら正規のミス扱い(再計算待ち)にフォールバックします。
設計の組み立て方
これらは排他ではなく 積層 して使います。実務での典型的な防御は次の順序です。
- TTLにジッタを混ぜる:複数キーの同時失効をばらし、波の重なりを断つ。最も安価で副作用がない第一防御。
- single-flightで重複排除:単一ホットキーのミス殺到を1本に集約。分散環境ではTTL付き分散ロックで実装する。
- XFetchまたはSWRで崖をなくす:失効境界での全件ミスそのものを消す。鮮度を厳しく要るならXFetch、レイテンシを最優先するならSWR。
- バックエンド側にも安全弁:再計算経路にサーキットブレーカや並行数の上限(バルクヘッド、/devops/circuit-breaker-bulkhead/)を置き、万一突入してもDBが沈まないようにする。
加えて見落としがちな2つの罠があります。1つは キャッシュ貫通(cache penetration)——存在しないキーへの問い合わせは毎回ミスしてバックエンドに届くため、「存在しない」という結果自体を短TTLでキャッシュするか、Bloomフィルタで弾きます。もう1つは キャッシュ全断(cache avalanche)——キャッシュ層そのものが落ちると全リクエストがバックエンドへ流れ込むため、キャッシュは「あれば速くなる最適化」であって「なければ即死する依存」にしてはいけません(/devops/microservices/ の依存設計の鉄則)。
「TTLを延ばせばスタンピードは防げるか」——防げません。TTLを延ばすと失効頻度は下がりますが、失効した その一瞬 の殺到は変わらず、鮮度が犠牲になるだけです。問われるのは三点——(1) single-flightで再計算を1本に集約、(2) XFetch/SWRで失効境界の崖をなくす、(3) ジッタで複数キーの同時失効を散らす。さらに キャッシュ貫通・全断 という別系統の崩れ方も併せて押さえること。
まとめ
- 人気キーがTTLで失効した瞬間、ミスが殺到して同じ再計算がN重に走り、バックエンドを焼き切るのが キャッシュスタンピード。ヒット率が高いキーほど落差が大きく破壊的になる。
- TTLジッタ で複数キーの同時失効を散らし、single-flight(分散ロック)で単一キーのミス殺到を1本に集約する。
- 確率的早期失効(XFetch) は満了前に1本だけ先回り更新して失効の崖をなくし、stale-while-revalidate は古い値を即返ししつつ裏で1本更新してレイテンシ尖りも消す。
- これらは積層して使い、バックエンド側のサーキットブレーカ・並行数上限と、キャッシュ貫通・全断対策まで含めて初めて「失効の瞬間」を恐れない設計になる。
DevOps/インフラ Article
キャッシュ無効化とスタンピード対策を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
キャッシュ
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 5
導入後に効く点
single-flight(再計算の重複排除)と確率的早期失効(XFetch)で、失効を1リクエストに集約し、TTL境界での同時失効そのものを散らす。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「キャッシュ / スタンピード」に近いか確認する。
- 強みである「多数のキャッシュが同一TTLで一斉失効すると、リクエストがキャッシュミスで殺到して同じ再計算をN重に走らせ、バックエンドを焼き切るキャッシュスタンピード(thundering herd)が起こる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。