Tech Learning Daily

2026-08-01 (Sat) — 第 16 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Arch 🌿 基礎 ⏱ 約 7 分

キャッシュの有効期限が切れた瞬間、裏で何が起きているのか?

昨日も Cache-Control ヘッダーか Redis の EXPIRE でキャッシュの有効期限を設定したはずだ。だがその秒数はどんな基準で決めたのか、そしてその秒数を過ぎた瞬間にキャッシュの裏側で何が起きているのか、説明できるだろうか。

🎯 3 行まとめ
  • TTL は「新鮮さ」と「オリジンへの負荷」のトレードオフを秒数にしたもので、長くすれば速くなる代わりに変更の反映が遅れる
  • キャッシュキーの設計を誤ると、別のリクエストに対する応答が誤って配信される事故につながる。Vary ヘッダーはリクエストのどの要素をキーに含めるかを指定する
  • TTL が切れた瞬間に大量アクセスが同時にキャッシュ miss すると、オリジンや DB に負荷が殺到する「キャッシュスタンピード」が起きる。stale-while-revalidate は古い値を返し続けながら裏で 1 件だけ再検証することでこれを防ぐ

キャッシュが埋めているのは「速さ」と「正しさ」のすきま

キャッシュとは、一度計算・取得した結果を、元データより速く読み出せる場所に置いておく仕組みだ。DB のクエリ結果を Redis に置く、API のレスポンスを CDN のエッジに置く、どちらも同じ発想である。だが元データは後から変わりうるので、キャッシュに置いた瞬間から「今キャッシュにある値は、まだ元データと一致しているか」という保証がじわじわ崩れていく。

TTL(Time To Live、キャッシュを新鮮とみなす残り秒数)は、このずれをどこまで許容するかを数値にしたものだ。TTL を設定するとは、実は「何秒キャッシュするか」ではなく「元データとどれだけずれても構わないか」を決めていることになる。

TTL は「何秒か」ではなく「誰にとっての鮮度か」

HTTP の Cache-Control ヘッダーには max-ages-maxage という 2 つの鮮度指定がある。max-age はブラウザのような個人専用キャッシュ(private cache)を含む全キャッシュに効くのに対し、s-maxage は CDN やプロキシのような複数人で共有するキャッシュ(shared cache)にだけ効き、指定されていれば shared cache に対する max-age より優先される。

Cache-Control: max-age=60, s-maxage=600

この設定では、各利用者のブラウザは 60 秒で新鮮さを失い次のアクセスで再検証しにいくのに対し、CDN のエッジキャッシュは 600 秒間そのまま配信し続ける。使い分けない場合、両方の層が同じ秒数で鮮度を失うため、大量アクセスを裁く CDN 層だけ長く持たせたい、といった調整ができない。

TTL は長くするほど言葉のとおりオリジンへのアクセスが減って速くなるが、その分「変更してから利用者に届くまでのタイムラグ」も伸びる。価格改定や在庫切れの反映が TTL 分だけ遅れる、という形で表面化する。

「同じリクエスト」をどう定義するか — キャッシュキー設計

キャッシュは実際には URL だけでなくキャッシュキー(保存したレスポンスを引き当てるための識別子)単位で保存されている。何も指定しなければキーは URL とメソッドだけで決まるが、実際のレスポンスは Accept-Language による言語出し分けや Accept-Encoding による圧縮形式の違いなど、リクエストヘッダーの値によっても変わることがある。

Vary: Accept-Encoding, Accept-Language

この指定があると、圧縮対応クライアント向けと非対応クライアント向け、日本語版と英語版のレスポンスがそれぞれ別のキャッシュエントリとして保存される。ここを指定し忘れると、最初にキャッシュを作ったリクエストのレスポンス(例えば英語版)が、本来異なる応答を受け取るべき別のリクエスト(日本語ブラウザ)にもそのまま配信されてしまう。ユーザーごとに内容が変わるパーソナライズされたレスポンスをキャッシュする設計では、この誤配信は個人情報の混線という重大な事故になりうる。

有効期限が切れた瞬間、何が起きているか — キャッシュスタンピード

TTL は 1 件のキャッシュエントリに対して 1 つの時計として動いているように見えるが、実際にはそのキーへのアクセスが多いほど「切れた瞬間」に居合わせるリクエストの数も増える。人気の高いキーが失効した直後は、それを待っていたかのように大量のリクエストが同時にキャッシュ miss し、いっせいにオリジンや DB へ再計算を要求する。

TTL失効の瞬間、同時アクセスが来ると
client×1000 ─┬─▶ 同時に cache miss
            ├─▶ origin へ 1000 同時アクセス
            └─▶ DB 負荷が瞬間的に跳ね上がる

この現象はキャッシュスタンピード(サンダリングハード、dogpile 現象とも呼ばれる)と呼ばれる。1 秒前まで軽快に捌けていたキャッシュ層が、TTL 失効という 1 つの出来事をきっかけに、瞬間的に無防備な直撃リクエストの束へと変わる。TTL を短くして鮮度を上げようとするほど、この瞬間の発生頻度も上がるという副作用がある。

🍱 たとえるなら

定食屋の厨房を思い浮かべてほしい。スープの寸胴を何時間も出し続けると仕込みたてより質が落ちるが、一杯出すたびにゼロから作り直していては行列ができる。多くの店は「仕込みから何分以内は看板の味として出す」という線を引き、その線を越えたら次の一杯の仕込みを始める。ただし仕込みが終わるまでの数分間は、客を待たせずに今ある寸胴のスープを出し続ける。客の列は途切れず、厨房も客の数だけ同時に鍋を火にかける混乱を避けられる。線を越えてから仕込みが終わるまでの短い間だけは、案内される客に「厳密には作りたてではない一杯」が出ている。

stale-while-revalidate は「古いまま返す」ことで両方を解決する

RFC 5861 で定義された stale-while-revalidate は、max-age による新鮮期間が終わったあとも、指定した秒数の間はキャッシュを stale(古いが使える状態)のまま返し続けてよいという指示だ。

Cache-Control: max-age=600, stale-while-revalidate=30

この設定では、生成から 600 秒までは新鮮なレスポンスとして即座に返す。600〜630 秒の間にアクセスが来ると、キャッシュはその古い値をそのまま即座に返しつつ、裏側で 1 回だけオリジンへの再検証をバックグラウンドで走らせる。先ほどの寸胴の例で言えば、線を越えた直後に来た客の一人分の注文だけが「次の仕込み」の合図になり、残りの客は行列に並ぶことなく今ある寸胴から出してもらえる、という状態だ。再検証が終わればキャッシュは新しい値に更新され、次のアクセスからはまた新鮮な状態に戻る。

新鮮さだけを追うなら失効直後に全リクエストを待たせてオリジンに投げるのが「正しい」ように思える。しかし実際には、それこそがキャッシュスタンピードを引き起こす原因であり、stale-while-revalidate はあえて多少古い値を許容することで、レイテンシの露出とオリジンへの同時アクセスを同時に抑えている。

💼 実務でどう出会うか

Cloudflare や Fastly などの CDN は edge cache の設定で stale-while-revalidate を直接サポートしており、Next.js の fetch キャッシュや ISR も内部的に同じ考え方で古いページを返しつつ再生成する。Redis を cache-aside で使う場合は同じ機構が標準搭載されていないため、TTL に数秒のランダムな幅(jitter)を持たせて失効タイミングを分散させたり、再計算処理に分散ロックをかけて同時実行を 1 件に絞ったりする実装で同じ問題に対処する。

⌨️ 手を動かす(5 分)

実在のサイトが返す Cache-Control ヘッダーを覗いて、max-agestale-while-revalidate がどんな値で使われているか確認する。

curl -sI https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control | grep -i cache-control

Cache-Control 行に max-age や、CDN 経由なら s-maxage、場合によっては stale-while-revalidate が並んでいるのが観察できる。手元の別サービスのレスポンスと見比べると、静的アセットと動的 API とで TTL の桁がまったく違うことにも気づけるはずだ。

🙅 よくある誤解
  • TTL は長ければ長いほど良い — TTL を伸ばすとオリジンへの負荷は減るが、元データの変更が利用者に届くまでの遅延も同じだけ伸びる。用途ごとに許容できるずれの大きさで決めるトレードオフである。
  • no-cache は「キャッシュしない」という意味no-cache は保存はするが再利用の前に必ず検証を要求するという意味で、保存そのものを禁止するのは no-store である。
  • stale-while-revalidate は常に最新のデータを返す仕組み — 実際には失効後の指定期間、古いレスポンスをそのまま返す。裏で再検証している間もユーザーには古い値が見えている。
📖 用語ミニ辞典
TTL
Time To Live。キャッシュを新鮮とみなす残り秒数。元データとのずれをどこまで許すかを表す。
キャッシュキー
保存したレスポンスを引き当てるための識別子。既定では URL とメソッドで決まる。
Vary ヘッダー
レスポンスがリクエストのどのヘッダーによって変わるかを示し、それらをキャッシュキーに含めさせる指定。
キャッシュスタンピード
人気キーの TTL 失効直後に大量のリクエストが同時にキャッシュ miss し、オリジンへ殺到する現象。サンダリングハードとも呼ぶ。
stale-while-revalidate
新鮮期間終了後も指定秒数の間、古いレスポンスを返しつつ裏で 1 回だけ再検証する Cache-Control 拡張。
🔗 もっと深く