Stale-While-Revalidate ヘッダによるブラウザキャッシュの非同期更新
Intro
システムにおいてキャッシュの設計は永遠の課題であり、Web のパフォーマンスにおいても非常に重要である。
Web では、HTTP ヘッダを用いてブラウザやプロキシにキャッシュの制御を指定する。
Stale-While-Revalidate ヘッダは、このキャッシュ制御に選択肢を追加する新しい仕様である。
このヘッダの概要と、本サイトへの適用を解説する。
Web におけるキャッシュ
キャッシュの種類
まず、ブラウザが持つ従来のキャッシュの機構について整理する。
そもそも、キャッシュを行う意義は大きく二つある。
- リソースの取得を高速化する
- サーバへの負荷を減らす
これまでは HTTP ヘッダを用いて、キャッシュを管理させる方法を用いてきた。
Web における、キャッシュの指定には大きく二つの方式がある。
- ブラウザはリクエストを発行せず、保持するキャッシュを使用する(
Cache-Control
,Expires
) - ブラウザはリクエストを発行し、サーバにキャッシュの有効性を確認してから、キャッシュを使用する(
ETag
,Last-Modified
)
また、キャッシュは、「再利用」を行う目的でありながら、ある一定の範囲で「更新」を行いたいという、相反するコントロールが求められる。
筆者の認識として、キャッシュ設計の最も難しい点は、ここである。
これらは基本的/一般的な内容であり、キャッシュに関わるヘッダや機能は他にもある点、そしてブラウザは独自の判断でキャッシュを使う場合があることに注意されたい。
キャッシュを行う側としてブラウザ以外に Proxy もあるが、話を簡単にするため、今回は言及しない。
Cache-Control, Expires
Cache-Control
ヘッダで max-age
を指定するか、Expires
ヘッダで未来の時間を指定した場合、ブラウザはその期間内であればサーバに問い合わせることなくキャッシュを使用する。
つまり、この指定によるキャッシュがヒットする場合、ネットワークパケットは一切発生せず、理論上は最速でリソースを取得できる。
しかし、これらのヘッダに基づくキャッシュヒットはブラウザ内で完結してしまうため、期限が切れるまでサーバは介入することができない。
例えば、長い期間を指定してキャッシュさせた JS にバグがあった場合も、サーバから修正したスクリプトを配信することができなくなる。
かと言って、短い消極的な期間にしては、高頻度でリクエストが発生してキャッシュの効果が薄れる。
そこで、現実的には期間を長く、推奨される最大値の 1 年 などを指定し、更新があったらそのリソースの URL を変更するという運用がよく行われる。
例えば production.min.js
を 1 年間ブラウザにキャッシュさせる。
この JS を index.html
に指定する際は、以下のようにバージョンを含める。
<script src="production.min.js?ver=1"></script>
これで ver=1
を参照している間はキャッシュが使われる。
もし JS が更新されたらバージョンを変えることで、URL を以下のように変更する。
<script src="production.min.js?ver=2"></script>
ブラウザのキャッシュは基本的に URL 単位で行われるため、この URL を毎回変えてやれば、古いキャッシュが使われるのを避けることができる。
あくまで URL を変えることが目的なので、実際にサーバ側でこの値をハンドルする必要は必ずしもない。バージョンの代わりにタイムスタンプやハッシュを使っても良い。
ただし、この <script>
を含む、index.html
自体が長期間キャッシュされてしまうと、production.min.js
の URL も更新できない。
したがって、index.html
自体は長期間のキャッシュがしにくいという問題は残る。
Etag, Last-Modified
HTTP には、Conditional GET (条件付き GET) という仕組みがある。
これは、「既に保持しているキャッシュが今でも有効かどうか」をサーバに問い合わせる方法である。
具体的には、サーバは ETag
, Last-Modified
などのヘッダをレスポンスに付与することで、リソースに関する情報をクライアントに伝え、クライアントはその情報を次のコンテントネゴシエーションに利用し、キャッシュの再利用可否などを判断する。
- ETag
- そのリソースを一意に特定する値、要するにリソースのハッシュ値
- Last-Modified
- そのリソースが最後に更新されたタイムスタンプ。この値を保存したブラウザは、同じ URL へのリクエストに、キャッシュしたリソースに付与されていた値を設定してサーバに問い合わせる。サーバは、リクエストされたリソースについて各値を検証する。
- If-Non-Match
- ETag で受け取った値を付与、サーバはその値と現在のリソースの値を比較
- If-Modified-Since
- Last-Modified で受け取った値を付与、サーバはリソースの最終更新日を比較 これによって、ブラウザがキャッシュしたリソースが、まだ新鮮であるかどうかをサーバが判断できる。新鮮ならば
304 Not Modified
を返すことで、ブラウザにキャッシュが再利用できることを伝える。新鮮でなければ新しいリソースをレスポンスし、キャッシュは更新される。この仕組みは、キャッシュが有効と分かればレスポンスボディが空になるため、ペイロードサイズが大幅に減る。キャッシュが古い場合は、常に新しいリソースを提供できるため、更新が多いリソースで、最新のコンテンツを提供する場合に使用できる。ただし、あくまでサーバへの問い合わせ自体は発生するため、ラウンドトリップ自体の削減にはならない。
Stale-While-Revalidate
ここまでの二つの仕組みは、下手に設定すると更新されない、弱気になるとキャッシュが効かないという、設計の難しさをはらむ。
したがってヘッダのみを用いて、「キャッシュは効かせたいが、なるべく新鮮なリソースを提供したい。」などといった要望に対処するのが難しかった。
そこで提案されたのが Stale-While-Revalidate (SwR)という Cache-Control
の拡張である。
簡単に言えば「キャッシュから表示するが、裏で非同期にキャッシュを更新しておく」という仕組みである。
なお、現時点では Chrome のみに実装されており、flag を有効にすることで使用できる。
chrome://flags/#enable-stale-while-revalidate
まず、従来の方法で以下のヘッダがあった場合を考える。
すると、fetch したレスポンスは 3600s の間は fresh とみなされ、その期間はキャッシュヒットする。
しかし、3600s をすぎるとキャッシュは stale とみなされ破棄し、次のリクエストで fetch が走る。
すると、fetch から 3600s 経過したキャッシュは stale となるが、そこから 360s は、その stale なキャッシュを引き続き使用する。
しかし、一度 stale なキャッシュを使用したら、裏で非同期に fetch を行い、サーバにキャッシュの鮮度を問い合わせる(validate)。
もしサーバから新しいリソースを fetch したなら、そこに付与された新しいヘッダにしたがってキャッシュを更新する。
なんらかの理由で 360s の間に validate が完了しなければ、キャッシュを true stale とみなして破棄し、次のリクエストで fetch が走る。
したがって、この設定からであれば、導入はそこまで難しく無いと考えられる。
仕様にはもう一つ、 同じく すると、3600s でキャッシュは stale になり、次のリクエストで fetch が走る。
しかし、もしその fetch がサーバの 500 やネットワークエラーにより失敗した場合は、360s 間は stale cache を使用しても良い。
これにより、ブラウザのエラー画面が表示されるのを防ぐことができる。
もちろん、上記二つは組み合わせて使うことができる。
max-age
Cache-Control: max-age=3600;
stale-while-revalidate
Cache-Control
に stale-while-revalidate
を指定する。
Cache-Control: max-age=3600, stale-while-revalidate=360
stale-while-revalidate
の時間が過ぎれば必ず fetch が発生するということは、従来設定していた max-age
= max-age
+ stale-while-revalidate
の時間と設定すれば、従来との差異はキャッシュの新しさだけになる。
stale-if-error
stale-if-error
という拡張もある。
Cache-Control
に指定する。
Cache-Control: max-age=3600, stale-if-error=360
DEMO
動作するデモを以下に用意した。
執筆時点では、実装ブラウザは Chrome のみであり、フラグを有効にすることで使用できる。
chrome://flags/#enable-stale-while-revalidate
サーバは、アクセスの度に異なるシーケンス番号、タイムスタンプ、ランダムな文字列を返すようになっている。
そして、レスポンスに以下のヘッダを追加しているため、アクセスを繰り返せば挙動が確認できるだろう。
(Chrome はリロードではキャッシュを無視する場合があるため、画面に用意したリンクを踏むこと)
Cache-Control: max-age=5, stale-while-revalidate=10, stale-if-error=15
以下にデモのキャプチャを用意した。Chrome の dev tools とサーバ側のアクセスログを表示している。
サーバへのアクセスが発生し表示が更新されているが、全てキャッシュがヒットしていることが分かるだろう。
SwR を用いたキャッシュ戦略の考察
この仕組みを用いたキャッシュ戦略について考察する。
まず、SwR を用いると何が変わるのかを確認するため、極端な設定例を用いて考察する。
この設定では、キャッシュは 1 年間 fresh となる。
例えば、 キャッシュが途中で消されない理想状態においては、そのブラウザからサーバへのリクエストは 1 年間無いことになる。
ただし、取得されるリソースは常に最初に取得したものであり、最大で 1 年前のものとなる。
この設定は、キャッシュはすぐに stale となる。
しかし、1 年間はこの stale cache を使用することが許可されているため、次のリクエストはキャッシュヒットする。
そして、その裏で validate として fetch が走る。もしレスポンスが同じヘッダを持てば、そこからまた 1 年キャッシュが stale になる。
つまり、キャッシュは常に 1 度だけヒットし、最後にアクセスした直後の内容に更新されていることになる。
最初にキャッシュしてから 1 年間は、必ずキャッシュヒットするが、リソースの状態は最後にアクセスした時のもの、という状態になる。
両方を半年ずつ設定した場合、半年ずつ fresh / stale になる。
この場合 まだ 1 year fresh cache
Cache-Control: max-age=31536000
favicon.ico
や jquery.min.js
などといった更新が少ない、もしくは更新が無い(ある場合はファイル名が変わる) といった場合に設定が可能になる。
1 year stale cache
Cache-Control: max-age=1, stale-while-revalidate=3153600
max-age
との最大の違いは、サーバへの負荷になるだろう。この場合 fetch が行われる 回数 自体は、Cache-Control
が無かった状態と変わらない。fetch のタイミングが少し後ろにずれるだけである。
1 year fresh/stale cache
Cache-Control: max-age=15768000, stale-while-revalidate=15768000
stale-while-revalidate
に 対応していないブラウザ でも、半年はキャッシュが効く。
stale-while-revalidate
の実装が行き渡らないうちは、こうした両方での指定も考慮すべきだろう。
max-age
の割合を、リソースのコンテンツ頻度などを元に考慮することで、サーバへの負荷とキャッシュの鮮度のバランスを取ることができる。
本サイトへの適用
現状
本サイトでは、現状 Cache-Control
は WebFont 以外にはつけておらず、ETag による Conditional GET でのキャッシュを利用している。
これは、ブログの記事や、JS/CSS などの 修正がいち早く反映されて欲しい からである。
全体のアクセスもまだまだ多くはなく、バージョンの付与による URL の変更は、あまり使いたくは無い。
リクエストが頻発しても、もし実際にリソースの更新がないのであれば、304 を返すだけで足りる。
したがって、現状との飛躍が少ない状態で リクエストを減らすため のキャッシュは考慮になく、表示の最適化 のためのキャッシュを積極的に行いたい。
毎回キャッシュはヒットするが、極力最新の状態というのが理想である。
アクセスパターン
そして、ブログは平均週一回程度の更新であるため、ユーザのアクセスは以下のパターンがある。
- 更新された日に RSS などからアクセスし、多少うろついて帰る
- 長いスパンを開けて、検索などからアクセスし、多少うろついて帰る
現状、多くのサイトがキャッシュを設定しているため、ブラウザのローカルキャッシュは 2 日程度で消える と言われている。
そのため、後者の長いスパンの中で、前回アクセス時のキャッシュの適用を期待するのは難しい。
どちらかというと、その日のアクセス後の導線上でキャッシュが効き、かつ、アクセス中に筆者が修正を適用しても、ある程度の速さで反映されて欲しい。
合わせて、筆者の設定ミスなどでブログが落ちていたとしても、その日のうちはキャッシュが代替表示として十分に機能すると考える。
設定
結果、以下のように設定することとした。
リソースの種類によって設定を変えることも考えたが、基本的にどのリソースでも更新が短期間に反映されて欲しいため、リソースによって差はない。
- max-age=1sec : SwR 非対応ブラウザではキャッシュしない
- SwR=10min : そのとき滞在しているセッションの中ではキャッシュを使用
- SiE=1day : その日のうちは、エラーの代替表示として stale cache を利用
Cache-Control: max-age=1, stale-while-revalidate=600, stale-if-error=864000
非常に短期のセッションでキャッシュを有効にする設定である。
一方、長期のキャッシュは、どうしてもアクセスしてない期間に行われた更新を、バックグラウンドで反映したくなる。
そうした場合は、Service-Worker を使ったキャッシュ機構を適用するため、別途対応する。