Skip to content
Published on

キャッシュ無効化を徹底解説

Authors

はじめに — なぜこれが冗談になったのか

コンピュータサイエンスには有名な冗談があります。フィル・カールトン(Phil Karlton)が残したと伝えられる言葉です。

「コンピュータサイエンスで本当に難しいのは二つだけだ。キャッシュ無効化、そして命名。」

この冗談が長く語り継がれるのは、本当にキャッシュ無効化が難しいからです。キャッシュを作ること自体は簡単です。値をどこかに保存して次に再利用すればよいのです。本当の難しさは、その保存した値がいつ古くなったのか、だからいつ捨てるべきかを正確に知ることにあります。元のデータは変わったのにキャッシュが古い値を握っていれば、ユーザーは間違った情報を見ます。かといって毎回元を確認すれば、キャッシュを置いた意味がありません。

この記事はこの難しさを正面から扱います。キャッシュが何のためのものか、どんな書き込み戦略があるか、キャッシュスタンピードという落とし穴とその緩和法、無効化を実際にどう実装するか、そしてCPUからブラウザまで続くキャッシュの階層を押さえます。

キャッシュの役割と根本の問題

キャッシュの目的は単純です。高価な計算や遅い取得の結果を保存しておいて再利用することで時間を節約するのです。データベースクエリ、リモートAPI呼び出し、重いレンダリング。こうしたものの結果を近くに保管すれば、次はたちどころに答えられます。

キャッシュがうまく働くのは二つの性質のおかげです。

  • 時間的局所性(temporal locality):たった今使ったデータは、まもなくまた使われる可能性が高い。
  • 空間的局所性(spatial locality):あるデータを使えば、その近くのデータもまもなく使われる可能性が高い。

問題はキャッシュが元のコピーであることから生まれます。コピーは元が変わった瞬間に古くなります。この古さを扱うことが、キャッシュのあらゆる難しさの根源です。ここで二つの悪い状況を区別しなければなりません。

  • ステール(stale)データ:キャッシュが元より古い値を持っている。ユーザーが間違った値を見る。
  • キャッシュミス(miss):キャッシュに値がなく、元まで往復せねばならない。遅い。

キャッシュ戦略は結局この二つのあいだの綱渡りです。ステールを減らそうと頻繁に捨てればミスが増え、ミスを減らそうと長く保管すればステールが増えます。「正解」はなく、データの性質に合った均衡点があるだけです。

TTL — もっとも単純な無効化

もっとも広く使われる無効化の方法が**TTL(Time To Live)**です。各キャッシュ項目に「この値はN秒だけ有効」という寿命を付けるのです。その時間が過ぎると項目は失効し、次のリクエストは元から取り直します。

TTLの魅力は単純さと予測可能性です。無効化ロジックを別に書く必要がありません。時間が過ぎれば勝手に捨てられます。最悪の場合でもステールはTTLの時間を超えません。

しかしTTLは本質的にステールと負荷のあいだのトレードオフです。

  • 長いTTL:ヒット率が高く速く、元への負荷が少ない。しかし元が変わっても最大TTLのあいだ古い値を見せる。
  • 短いTTL:新しさがよい。しかし失効が頻繁で元の取得が増え遅くなる。

ですからTTLは「少し古くてもよい」データに向いています。ニュース一覧、人気の投稿、為替レートのように、数秒から数分の遅延が許されるものです。逆に「絶対に古くなってはいけない」データ(たとえば口座残高)にはTTLだけでは足りず、あとで見る明示的な無効化が必要です。

書き込み戦略 — write-through、write-behind、cache-aside

キャッシュと元をどう一緒に更新するかによって、いくつかの典型的なパターンがあります。これらを区別しておくと設計がずっと明確になります。

Cache-aside(遅延ロード、lazy loading)。 もっともよくあるパターンです。アプリケーションがキャッシュを直接管理します。

  読み取り:
    1. キャッシュを見る
    2. あれば(hit)その値を返す
    3. なければ(miss)元から読んでキャッシュに入れて返す

  書き込み:
    1. 元を更新
    2. キャッシュの該当項目を無効化(削除)

核心はキャッシュが「必要なときだけ」満たされることです。読まれないデータはキャッシュに入りません。書き込み時はふつうキャッシュを更新せず削除だけし、次の読み取りが新しい値で満たし直すに任せます。この方式は単純で堅牢ですが、ミスのたびに元へ往復する遅延があります。

Write-through(ライトスルー)。 書き込みがキャッシュと元を同時に更新します。アプリケーションはキャッシュに書き、キャッシュがその場で元にも書きます。

  書き込み:
    アプリケーション --> キャッシュに書く --> (即座に) 元にも書く
    両方成功して完了

長所はキャッシュが常に新しいことです。読み取りは常にキャッシュから直接処理され速いです。短所は書き込みが遅くなることです。書き込みのたびに元への書き込みを待たねばならないからです。またしばらく読まれないデータまでキャッシュに満たして空間を無駄にしかねません。

Write-behind(write-back、ライトバック)。 書き込みがまずキャッシュだけに記録され、元への反映はあとで非同期に行われます。

  書き込み:
    アプリケーション --> キャッシュに書く (即座に完了応答)
                          |
                          v (あとで、バッチで)
                       元に反映

長所は書き込みが非常に速く、複数の書き込みをまとめて一度に元へ反映し負荷を減らせることです。短所は危険です。キャッシュには書いたが元にはまだ反映されていないその間にキャッシュが死ぬと、データが失われます。ですからwrite-behindは損失を許容できるか、別の耐久性の仕組みを備えた場合にのみ使います。

三つの戦略を一目で比べるとこうです。

戦略書き込み速度読み取りの新しさ損失リスク典型的な状況
cache-aside普通ミス後は新しい低い汎用、読み取り中心
write-through遅い常に新しい低い新しさが重要
write-behind非常に速い常に新しい(キャッシュ基準)高い書き込み殺到、損失許容

キャッシュスタンピード — ドッグパイル問題

さてキャッシュでもっとも悪名高い落とし穴を扱います。キャッシュスタンピード(cache stampede)、別名ドッグパイル(dogpile)問題、またはキャッシュラッシュと呼ばれる現象です。

状況はこうです。人気のあるキャッシュ項目が一つ失効する瞬間を想像してみましょう。その項目は毎秒数千回読まれていました。失効したまさにその刹那、数千個のリクエストが同時にキャッシュミスを起こします。そしてそれらがすべて同時に元(たとえばデータベース)へ押し寄せ、同じ値を計算し直そうとします。一つで十分だった元の取得が数千に爆発し、元はその負荷に押しつぶされます。最悪の場合、元が死に、するとさらに多くのミスが出て、連鎖的にシステム全体が崩れます。

  正常: リクエストがキャッシュで処理される
    req req req --> [キャッシュ hit] --> 速い応答

  スタンピード: 人気キーの失効の瞬間
    req req req req ... (数千個)
         すべて miss
           |
           v (同時に殺到)
        [元のDB] <-- 数千の同一計算リクエストに圧殺される

核心は「人気のある項目ほど危険」という逆説です。頻繁に読まれる値ほど失効の瞬間の同時ミスが大きいからです。この問題を緩和する方法がいくつかあります。

1. ロック / ミューテックス。 ミスが起きたとき、最初のリクエストだけが元を取得するようロックをかけます。残りのリクエストはその一つが値を満たすまで少し待つか、しばらく古い値を受け取ります。こうすれば元の取得が一つに減ります。短所は待ちが生じ、実装がやや複雑になることです。

2. TTLにジッター(jitter)を入れる。 多くの項目がまったく同じ瞬間に失効するとスタンピードが大きくなります。そこでTTLにわずかな無作為値を加え、失効の時点を散らします。たとえばTTLをちょうど300秒にする代わりに270〜330秒のあいだに無作為化すると、失効が時間軸に広がり同時ミスが減ります。

import random

def ttl_with_jitter(base=300, spread=30):
    # 失効の時点を散らして同時失効を防ぐ
    return base + random.randint(-spread, spread)

3. Stale-while-revalidate(再検証中に古い値を提供)。 これは特に優雅な方法です。項目が失効してもその古い値をすぐには捨てず、「とりあえず古い値を返しながら、裏で静かに新しい値を取ってきて更新」します。ユーザーは待たずに(少し古いかもしれない)値をすぐ受け取り、更新はバックグラウンドで一度だけ起きます。HTTPキャッシュ制御のstale-while-revalidateディレクティブがまさにこの概念を標準化したものです。

  項目が失効
      |
      v
  リクエスト到着 --> 古い値を即座に返す (ユーザーは待たない)
      |
      +--> バックグラウンドで新しい値を取得しキャッシュ更新 (一度だけ)

4. 事前再計算(early recomputation)。 失効の直前に、確率的にあらかじめ値を計算し直しておく手法もあります。失効が迫るほど再計算の確率を上げ、実際の失効の瞬間にはすでに新しい値が準備されているようにします。これを確率的早期失効(probabilistic early expiration)と呼びます。

実務ではこれらを組み合わせます。たとえばジッターで失効を散らし、stale-while-revalidateで待ちをなくし、ロックで更新を一つにまとめる、というふうにです。

無効化 — 明示的に古い値を捨てる

TTLは「時間が過ぎたら捨てる」という受動的な無効化です。しかしあるデータは元が変わった瞬間にキャッシュを捨てねばなりません。これが**明示的な無効化(invalidation)**であり、ここが本当に難しい部分です。二つの代表的な戦略があります。

イベントベースの無効化(event-based)。 元のデータが変わるとイベントを発生させ、関連するキャッシュ項目を即座に削除(または更新)します。たとえば「商品42の価格が変更された」というイベントが出たら、商品42を含むすべてのキャッシュ項目を無効化します。

この方式の強みは新しさです。元が変われば、ほぼ即座にキャッシュが片付けられます。難しさは依存関係の追跡です。「商品42」は商品詳細キャッシュにも、カテゴリ一覧キャッシュにも、検索結果キャッシュにもあるかもしれません。どのキャッシュ項目がこのデータに依存するかを正確に知って初めて、漏れなく無効化できます。この依存グラフを誤って管理すると、一部のキャッシュが静かにステールのまま残ってバグになります。

バージョンキー / キーバージョニング(versioned keys)。 とても実用的で堅牢な手法です。キャッシュキー自体にバージョンを埋め込み、無効化を「削除」ではなく「新しいキーへの移動」で処理します。

  キャッシュキーにバージョンを含める:
    user:42:v7:profile

  ユーザー42のデータが変わったら --> バージョンをv8に上げる
    いまやコードは user:42:v8:profile を照会
    -> v7キーは誰も探さないので自然に捨てられる(TTLで片付け)

この方式の美しさは削除を直接しなくてよいことです。バージョン番号を上げるだけで、古いバージョンのキーは誰にも照会されなくなり、やがてTTLで片付けられます。競合状態(無効化と再取得が交錯する問題)にも強いです。古いキーと新しいキーが物理的に別だからです。よくある応用は、あるエンティティやデータセット全体に「バージョンカウンター」を置き、それをキャッシュキーに付けることです。

二つの戦略は排他的ではありません。細かい即時無効化が必要なところにはイベントベースを、広い範囲を一度に置き換えるところにはバージョンキーを、というふうに一緒に使います。

無効化が本当に難しい理由

ここでなぜこのすべてがそれほど難しいのか、一度整理しておきましょう。キャッシュ無効化が難しいのは単にコードが複雑だからではなく、いくつかの根本的な緊張のためです。

第一に、分散の問題です。キャッシュはたいてい複数のサーバー、複数の階層に散らばっています。一か所で無効化しても、別のキャッシュにはまだ古い値が残っているかもしれません。すべてのキャッシュを同時に、原子的に無効化することは、分散システムの難しい問題(合意、順序)をそのまま引き継ぎます。

第二に、**競合状態(race condition)**です。「キャッシュを無効化する瞬間」と「別のリクエストが古い値を読み直してキャッシュに入れる瞬間」が交錯すると、たった今消した古い値がキャッシュに蘇ります。無効化の順序と再取得の順序が微妙に絡んでこうしたバグを作ります。

第三に、依存関係の複雑さです。さきほど見たように、一つの元データが複数のキャッシュ項目に散らばっていると、何を無効化すべきか完全に把握するのが難しいです。見落とした依存関係の一つがステールバグになります。

この三つが重なるので、「命名」と並んで冗談の題材になるほど難しいのです。完璧な無効化はしばしば不可能で、だから実務は「どれだけ長くステールに耐えられるか」を決めてTTLと明示的な無効化を混ぜる実用的な妥協へ進みます。

キャッシュの階層 — CPUからブラウザまで

キャッシュは一か所だけにあるのではありません。コンピューティングのスタック全体がキャッシュの層でできています。一つのデータリクエストがブラウザから出発してサーバーの奥へ入っていくあいだ、いくつものキャッシュ階層を通ります。この全体像を見ることが重要です。

  もっとも近く速い (小さく短命)
  ┌─────────────────────────────┐
  │ CPUキャッシュ (L1/L2/L3)     │  ナノ秒、ハードウェアが管理
  ├─────────────────────────────┤
  │ OSページキャッシュ / メモリ   │  ディスク読み取りをメモリに保管
  ├─────────────────────────────┤
  │ アプリのインメモリキャッシュ  │  プロセス内のローカルキャッシュ
  ├─────────────────────────────┤
  │ 分散キャッシュ (Redis)        │  複数サーバーで共有 (Memcachedも)
  ├─────────────────────────────┤
  │ データベースキャッシュ        │  クエリ/バッファプールキャッシュ
  ├─────────────────────────────┤
  │ CDNエッジキャッシュ           │  ユーザー近くにコンテンツ
  ├─────────────────────────────┤
  │ ブラウザキャッシュ            │  ユーザー端末に保存
  └─────────────────────────────┘
  もっとも遠いが、ユーザーにはもっとも近い

各階層の性格を挙げるとこうです。

  • CPUキャッシュ(L1/L2/L3):ハードウェアが自動で管理するもっとも速いキャッシュです。無効化もハードウェアのキャッシュコヒーレンシプロトコルが処理します。私たちが直接触れませんが、データ局所性を意識したコードがなぜ速いのかの根拠です。
  • OSページキャッシュ:OSがディスクから読んだブロックをメモリに保管します。だから同じファイルを二度目に読むときずっと速いです。
  • アプリのインメモリキャッシュ:プロセス内部のローカルキャッシュ(たとえばローカルハッシュマップ、LRUキャッシュ)です。もっとも速いですが、サーバーごとに別々なので一貫性の維持が難しいです。
  • 分散キャッシュ(Redis、Memcached):複数のサーバーが共有するキャッシュ層です。さきほど論じた戦略が主にここで起きます。
  • データベースキャッシュ:DB自体のバッファプールとクエリキャッシュです。
  • CDNエッジキャッシュ:静的リソース(そして次第に動的コンテンツまで)をユーザー近くのエッジに保管します。ここでの無効化(パージ、purge)は世界中のエッジに伝播せねばならず、それ自体が難しい問題です。
  • ブラウザキャッシュ:ユーザー端末に保存され、Cache-ControlETagのようなHTTPヘッダーで制御します。

この階層構造がキャッシュ無効化をさらに難しくします。元を一つ変えても、その値のコピーがこれらすべての層に散らばっているかもしれません。「なぜまだ古いデータが見えるのか」という問いの答えはたいてい「どこかの層のキャッシュがまだ更新されていない」です。だから診断は常に「どの層でステールが起きているのか」を絞り込んでいかねばなりません。

HTTPキャッシング — ウェブの標準的な無効化

ウェブ層のキャッシュはHTTPヘッダーで精緻に制御されます。これはブラウザキャッシュとCDNキャッシュを御する標準の言語です。

  • Cache-Control:キャッシュ動作の核心のディレクティブです。max-age(N秒キャッシュ)、no-cache(キャッシュするが使うとき再検証)、no-store(そもそもキャッシュ禁止)、private/public(誰がキャッシュできるか)などを指定します。
  • ETagと条件付きリクエスト:応答にコンテンツの指紋のようなETagを付けます。次のリクエストでブラウザがこの値をIf-None-Matchで送ると、サーバーはコンテンツが変わっていないとき本文なしで304 Not Modifiedだけを返し、転送量を大きく節約します。
  • stale-while-revalidate:さきほど見たあの概念です。失効した応答を即座に渡しながら裏で再検証することを許します。

HTTPキャッシングの強力さはこれらディレクティブの組み合わせにあります。たとえばめったに変わらない静的アセット(JS、CSS)には非常に長いmax-ageとコンテンツハッシュを含んだファイル名を組み合わせます。すると、ファイルの内容が変わるとファイル名(つまりURL)が変わるので、事実上さきほど見たバージョンキー戦略をウェブに適用することになります。古いURLは誰も要求しなくなり、自然に無効化されます。

実務の指針

ここまでの内容を実務の視点で圧縮します。

まずこのデータがどれだけステールでよいかを決めてください。これがあらゆる決定の出発点です。数分古くてよいならTTLで十分です。絶対に古くなってはいけないなら明示的な無効化が必要です。この問いに答えずにキャッシュを設計すると、必ずあとでステールバグや過負荷として返ってきます。

次に人気のある項目のスタンピードに備えてください。 頻繁に読まれるキーほど失効の瞬間が危険です。TTLにジッターを入れ、stale-while-revalidateで待ちをなくし、必要ならロックで更新を一つにまとめてください。

無効化が必要ならバージョンキーをまず検討してください。 直接削除より堅牢で競合状態に強いです。細かい即時無効化がどうしても必要なところにだけイベントベースを加えてください。

そしてキャッシュが複数の層にあることを忘れないでください。 ブラウザ、CDN、アプリケーション、分散キャッシュ、DB。無効化を設計するときはどの層まで伝播すべきかを明確にし、診断するときはどの層でステールが出ているかを絞り込んでください。

最後に観測可能性を備えてください。 キャッシュヒット率、ミス率、ステール率を測らなければ、キャッシュがうまく回っているのか、それとも静かに問題を積み上げているのか分かりません。

おわりに

キャッシュ無効化が命名と並んで冗談の題材になったのには理由があります。キャッシュを作るのは簡単ですが、そのコピーがいつ古くなったのかを正確に知ることは、分散、競合状態、依存関係という三つの根本的な難しさを同時に触れるからです。完璧な無効化はしばしば手に届きません。

だから現実の解法は魔法ではなく実用的な妥協です。「このデータがどれだけステールでよいか」を決め、それに合わせてTTLと明示的な無効化を混ぜます。人気項目にはジッターとstale-while-revalidateでスタンピードを防ぎ、広い無効化にはバージョンキーを、細かい無効化にはイベントを使います。そしてこのすべてがCPUからブラウザまで続くキャッシュの層の上で起きていることを常に意識します。

キャッシュは性能のもっとも強力なてこであると同時に、もっとも微妙なバグの源です。その両面を一緒に理解したとき、キャッシュは頭痛の種ではなく信頼できる道具になります。

参考資料