「キャッシュは麻薬」という標語からの脱却

これは はてなエンジニア Advent Calendar 2023 の 18 日目の記事です。昨日は id:gurrium による private-isuで70万点取るためにやったこと - ぜのぜ でした。私は 50 万点ぐらいで満足してしまっていたので、しっかり詰めていて凄いなと思う。

developer.hatenastaff.com

Web アプリケーション開発において、「キャッシュは麻薬」という言葉がインターネット上をよく飛び交っています。YAPC::Kansai OSAKA 2017 の id:moznion のトークでよく知られるようになったワードじゃないかな。

初出はちゃんとは分からないんですが、少なくとも 2011 年には言われていますね。

キャッシュに頼ると即時的にパフォーマンスが解決する。この即時効果に魅了されてしまい、根本的なパフォーマンス問題を覆い隠したままキャッシュに依存するようになり、そして脱出するのが難しい、という性質をよく表している言葉です。ですが、本当にキャッシュに依存するのは間違っているんでしょうか。

「キャッシュは麻薬」という言葉により「キャッシュ=使うべきではない」と思考停止してしまう、しまっているのではないか。十把一絡げに「麻薬」と言うのではなく、キャッシュをパターン化して乗りこなすというのが望ましい姿でしょう。現代的な Web アプリケーション開発において、キャッシュを使うのはむしろ前提としないと機能しないと私は考えます。

パターン化は moznion の発表でもされていたんだけど、私の目線からも分類してみます。

HTTP Response キャッシュ (我々が server のとき)

レスポンスをまるっと CDN 等でキャッシュしてしまうパターン。現代の Web アプリケーションでは基本的に CDN は挟む前提でしょうから、これはもちろん許容されているキャッシュでしょう。

ありがちな失敗として、認証が必要なページをキャッシュしてしまうというのがある。また、PC とスマホとで出し分けるときなど、CDN のキャッシュキーとアプリケーションの出し分け条件が異なっていて、違う環境向けのコンテンツをキャッシュしてしまうミスも時々発生している。

一度キャッシュしてしまったらアプリケーションの制御下よりも手前で返してしまうため、キャッシュのパージがそこそこ難しい。

べき等な、副作用の無い処理ならキャッシュ可能という考え方で扱うんだけど、副作用のあるリクエストでも Idempotency-Key Header を利用して高速にレスポンスを返すパターンもある。これはクライアントからのリトライ時に使います。

HTTP Response キャッシュ (我々が client のとき)

サーバー側があったので、クライアント側も書いておく。自分が受け取ったレスポンスを保存(キャッシュ)しておくのは、速度以外だと主に以下の目的です。

  • 外部にリクエストを投げ過ぎないように
  • 外部が落ちていても運用に影響が出ないように

リクエストを投げすぎないようにするためには、最終取得日時を持って、比較しながらリクエストを投げれば良い。Squid 等の proxy や、 unique 制限付きの request queue (同じ URL に対する queue は 1 つにマージする的な) で対応することも多そう。

リクエスト先が落ちていてもキャッシュからどうにかするのは、SWR 的な考え方ですね。「stale でも緊急時に使って良いキャッシュ」というのはある。

カウンターキャッシュ

Rails Guide に書いてある程度には一般的なキャッシュ。belongs_to にオプションを追加するだけという、非常に手を出しやすいモデリングがされているので、これも使って良いキャッシュだろう。

guides.rubyonrails.org

例えば Entry -> Comment という関連があるときに、entries テーブルに comments_count カラムを持って、コメントが作られる/削除されるたびに comments_count カラムを更新する。

このように「子の要素数を親に持っておく」のは非常に一般的な要件だと思うんだけど、カウンターキャッシュ以外に名前付いているんですかね?このパターンの名前を他に聞かないなと思っている。

ところで Rails でカウンターキャッシュを実現するときに、ActiveRecord に組み込みのものと、より柔軟に扱える counter_culture gem とがあります。counter_culture はコード読むのとてもオススメです。README だけでも「カウンターキャッシュ」にだいぶ色んな要件があるのが分かって面白いと思う。

データを引きやすい形に加工したキャッシュ

どう言えばいいのか分かんなくてパターン名を付けられていない。イメージはカウンターキャシュと同じ。ただカウンターキャッシュがカウンターに特化しているのと比べ、必要なデータを必要な形に加工して保存しておくというのが特徴。

例えば Markdown で書いたブログの HTML Body がこのパターンに当たる。マスタとなるデータを更新するときに、一緒に or 非同期でキャッシュデータを生成する。

アクセス数ランキングもそうですね。アクセスログというイベントデータと、その加工先としての本日のアクセス数ランキング。ランキング表示時にアクセスログを毎回舐めたりはせず、事前に集計しておいた、引きやすい形に加工したものを持っているはずです。

アクセス数ランキングを想像すると、イベントソーシングの考え方とも近しい存在であるというのが分かると思います。イベントを全てアプライしたリソースは、仮に失ってもイベントから再生成できるので、キャッシュのようなものですね。

このパターンの場合、今見ているキャッシュはどこまでをアプライしたものなのかが分かりづらくなるんですが、同期的に扱えていればこの悩みとは無縁です。なのでトランザクションを持つデータストアで、マスタデータとキャッシュとを同じデータストアで扱うと楽になります。そうでない場合は、頑張りましょう。(実装パターンは幾つかあるんだけど、余白が足りない)

フラグメントキャッシュ

HTML のパーツ単位でキャッシュする。これも Rails ガイドにありますね。

guides.rubyonrails.org

レンダリング時にキャッシュがあれば使う、なければキャッシュを作る、という動きをする。具体的には以下のようなコード。

<% @entries.each do |entry| %>
  <% cache entry do %>
    <%= render entry %>
  <% end %>
<% end %>

これは entry を render したものを、entry の id と updated_at をキーとしてキャッシュしている。updated_at がキーに含まれているので、更新するとキャッシュキーが変わる=キャッシュミスとなり、更新された新しいコンテンツで描画される。(実際はもっと複雑なことをしているけど、概要としては合ってるはず)

View やシリアライザでキャッシュするという考え方なので、HTML を離れて JSON API となっても活用することはできる。

ところで開発が活発な(?)アプリケーションでのフラグメントキャッシュでは、コンテンツの更新とテンプレートの更新との両方がキャッシュに影響します。Rails の場合はキャッシュキーにテンプレートの digest が入るようになりました(Rails 4 からなので 10 年前ですね)。このおかげで、コンテンツの更新だけを考えれば良くなっている。

透過的なキャッシュ

Webアプリケーションのキャッシュ戦略とそのパターン で言うところの Broker パターン。Primary なデータストアにアクセスする前に 必ず 通るキャッシュ。

透過的なので実装時に認識しづらく、デバッグも割と困難なため、あまり導入するべきではないが、ここぞと言うときに入れるとバグも少なく負荷が軽減される。ミドルウェアを挟むだけで実現できるのもまさしくいざというときに効く。

最初から設計していないとリロードするまで最新情報が表示されない等の副作用も出るので、いきなり導入するのは非常時の手段。

Read Through Cache、Write Through Cache と呼ばれることもあり、読み込み時のみならず、書き込み時も Broker を介して書き込む場合もある。これをやると書き込み直後のキャッシュが必ず最新を保てるが、常にダブルライトするのは無駄になることも多い。

Broker ではないが、データストアのレプリケーションを利用しての Read/Write Splitting も特徴がかなり似たキャッシュ形態だろう。Read/Write Splitting も飛び道具として機能することが多い。

レプリケーションであれば、「普段は replica 向きの connection を使う。保存系のクエリは primary に向け、そのリクエストの続き or そのユーザの数秒以内のリクエストでは読み込み系のクエリでも primary 向きの connection を使う」のような connection pooling の仕組みがあると、副作用を最小限にしていい感じに扱えるかもしれない。Rails の複数 DB はよくできているので、こちらもコードを読むのをオススメします。

アプリケーション的に自前で入れるキャッシュ

これが賛否両論あるヤツで、いわゆる麻薬です。下手に入れると本当に困る。

キャッシュ アサイド パターン - Azure Architecture Center | Microsoft Learn と言われる。

Caching Strategies and How to Choose the Right One | CodeAhoy や Webアプリケーションにおける正しいキャッシュ戦略 - Sansan Tech Blog でも同じ名前が当てられていますね。

アプリケーションコード内のあらゆる箇所でこの制御を入れようとすれば、あっという間にカオスになります。

が本当にそうなので、このパターンを使用するときは被害を最小限に食い止める設計と一緒じゃないと死です。無計画にやっていると、50 個を越えた辺りから不吉な臭いが漂ってくるだろう。この辺りから意図せず多段キャッシュになってしまい、どこのキャッシュをパージし忘れているせいでデータが更新されないのかを追えなくなっていく。

他にも色々

HTTP キャッシュは Web配信の技術 にすべてが書いてある。

また、フロントエンドのキャッシュもいっぱい話題はある(Apollo Client のキャッシュとか、Next.js のキャッシュとか。それぞれで 30 分トークができちゃう)んだけど、僕があんまり専門じゃないので他の人にお任せします。

ところでユーザリクエスト起因でキャッシュしているとキャッシュパージ時に非常に困るという話を以前したので、こちらも参考にどうぞ。

onk.hatenablog.jp

書いておかなければいけない宣伝

このエントリの骨格は YAPC::Hiroshima 2024 に CfP を出した ものでしたが、なんと前夜祭で、id:Soudai さんと一緒に話すことになりました。ここまでを下敷きとして、更に深い話をします。

まだチケットを購入可能なので、ぜひ来てください。

まとめ

キャッシュは麻薬と言われていますが、絶対に避けなければいけないものではありません。整合性が壊れやすくなることが問題なのです。そこでキャッシュの幾つかを分類し、これは使っても良いキャッシュであろうというパターンを挙げました。また、使うと後世で困りがちなキャッシュについて、気を付けるポイント(主にキャッシュアサイドパターンで多段キャッシュになるのが問題である)を書いてみました。

\キャッシュと上手に付き合おう!/

はてな Advent Calendar 2023、明日は id:tokizuoh さんです。

参考 URL