Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: HTMLフラグメントキャッシュは極めて有効である(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: HTMLフラグメントキャッシュは極めて有効である(翻訳)

私は2005年からRuby on Railsを使っていますが、Webリクエストのキャッシュを真剣に考えなければならないようなアプリを扱ったことは一度もありませんでした。そうせずに済んだ理由は、私が一般に公開するものを作るときは静的サイトジェネレータとシンプルなアセットホスティングを使うのが通例だったからでしょう。
しかし今私が取り組んでいるアプリは、実際には「ほぼ」ログインなしでユーザーがアクセス可能です。つまり、以下に該当します。

  1. 特定のページで予測の難しいトラフィック急増が発生する可能性がある
  2. レンダリングされるマークアップは、誰がアクセスするときでもほぼ同じになる

まずは結果を見ていきましょう。以下は、Herokuのbasic dyno上で一般公開している、ほぼ空のページ(キャッシュなし)にアクセスした場合です。

Completed 200 OK in 281ms (Views: 201.9ms | ActiveRecord: 47.5ms | Allocations: 37082)

ここにキャッシュのセットアップを数行加えると、以下の結果になりました。

Completed 200 OK in 9ms (Views: 3.5ms | ActiveRecord: 1.6ms | Allocations: 2736)

何と「30倍も高速」です。しかもこれは非常に基本的なページの場合なので、Webサイトのコンテンツが揃ってくれば、さらに劇的な結果を得られることでしょう。

その方法をこれより解説します。

🔗 背景情報

最初にRailsガイドのキャッシュガイドに従った場合、ガイドの冒頭で説明されているページ全体のキャッシュ方法が実際には「古い方法」であると書かれていることに気付いたかもしれません。この方法は、actionpack-page_cachingというgemに切り出されましたが、このgemはめったに更新されていません。

rails/actionpack-page_caching - GitHub

さらに悪いことに、ガイドでその次に説明されているキャッシュ方法(アクションキャッシュ)もやはり非推奨で、これもactionpack-action_caching gemに切り出されています。

rails/actionpack-action_caching - GitHub

そういうわけで、Railsのキャッシュ機構について調べていてこれらの情報に気を取られたり脱線したりすることがあったとしても、あなたのせいとは思いません。

本当にうまみのあるキャッシュは「フラグメントキャッシュ(fragment cache)」です。これについてはDHHが以下の記事で素晴らしい主張を繰り広げています。

参考: How key-based cache expiration works – Signal v. Noise

「認証されていないページ全体を単にキャッシュする」方が、「細かなフラグメントを単に多数キャッシュする」よりも明らかに優れていることは直感的に理解できます。ただしそれは「キャッシュの無効化が非常に手軽に行える」限りにおいてであり、その仮定が長期間通用することはめったにありません。さらにこの方法は、たとえば一般公開ページを特権UI要素(例: 管理ツールバー)で拡張すると、途端にうまくいかなくなるでしょう。

フラグメントキャッシュを使っても、多数のデータベースクエリが「一般的に」不要になるわけではありません(ただし、キャッシュに依存してもよい場合は創意工夫を凝らすことでeager-loadingを緩和するのに役立ちます)。フラグメントキャッシュで実際に削減できるのは、単一のHTMLページを構成するときにレンダリングする必要のある数十〜数百個のパーシャルスニペットを組み立てるという、恐ろしく時間のかかる処理です。

🔗 インストール

私の場合、Herokuに無料枠がたっぷりあるMemcached Cloudアドオンを使いました(無料枠30MBは、HerokuのMemCachierアドオンといい勝負ですが、有料でないと接続制限が厳しくなるようです)。

アドオンをインストールするには以下を実行します。

$ heroku addons:create memcachedcloud:30

続いて、GemfileにDalliを追加します。

gem "dalli", group: "production"

さらに、以下の小さなスニペットをconfig/environments/production.rbファイルに追加します(おそらくデフォルトのcache_storeコメントを置き換える形になるでしょう)。

# See: https://devcenter.heroku.com/articles/memcachedcloud#using-memcached-from-ruby
if ENV["MEMCACHEDCLOUD_SERVERS"]
  config.cache_store = :mem_cache_store, ENV["MEMCACHEDCLOUD_SERVERS"].split(","), {username: ENV["MEMCACHEDCLOUD_USERNAME"], password: ENV["MEMCACHEDCLOUD_PASSWORD"]}
end

以上でおしまいです!

🔗 シンプルな利用法

実際にAction Viewでキャッシュを指定するには、Railsガイドのキャッシュガイドを参照してください。cacheメソッドの使い方がよくわかります。

私の場合、Instagramのユーザービューに似たページを表示しています(上半分にプロフィールの詳細、下半分にサムネイルのコレクションを表示)。プロフィール部分をキャッシュするには、以下のようにHTMLをラップするだけで簡単に行えます。

<% cache profile do %>
<% end %>

パーシャルがprofileでデータだけを読み出し、かつprofilecache_keyに応答するActive Modelであれば、安全にキャッシュできます(つまりキャッシュにヒットするとページのコンテンツが正しく返されます)。

🔗 コレクションをキャッシュする

さらに複雑なのは、コレクションをループで回したり、関連付けを扱う場合です。この場合は多数のPostモデルを反復処理することになるので、postパーシャルで以下のようなコレクションのショートハンド構文を使うことにしました。

<%= render partial: "feeds/post", collection: @posts, cached: true %>

個別のpostパーシャルをキャッシュするのに必要なのは、cached: trueオプションを指定することだけです。

🔗 関連付けのネストを扱う

Post自体のshowアクションでは、個別のpostに多数のVisual要素が含まれます。このPostレコードと Visualレコードは独立して更新できるので、以下のように両方を包含する単一のキャッシュキーにビュー全体をラップしました。

<% cache([@post, @post.visuals.map(&:updated_at).max]) do %>
<% end %>

コントローラではvisuals関連付けをincludesでeager-loadingしているので、それらに含まれるupdated_at値をマッピングする形でキャッシュキーを構成してもコストはまったくかかりません。ただし、100回のうち1回でもパーシャルを実際にレンダリングしなければならなくなるようなキャッシュが設定されると、コントローラで全体をeager-loadingする形でN+1クエリを回避する方法は、もはや決定的な勝利とは言えません。

キャッシュがヒットした場合でも必要にならない関連付けを大量にeager-loadingするのは無駄なので、本当に高速化したいのであれば、代わりにカスタムのselect()を取り入れて、@postを読み込むときにlatest_visual_updated_atなどの名前の値をSQLで計算し、それを代わりに参照する方法が考えられます。続いて、ビューのキャッシュ済みセクション内で、キャッシュミスが発生した場合はeager-loadをすべて遅延するメソッドを呼び出せるでしょう。

🔗 ユーザー固有の要素を含むページの場合

HTMLが同一でなければならないユーザーのロール(role)全体が存在する場合(私の場合はadmin)は、以下のようにキャッシュキーに追加するだけで済みます。

<% cache([@post, @post.visuals.map(&:updated_at).max, current_admin.present?]) do %>
<% end %>

これで、admin以外のユーザーはあるキャッシュキーに解決され、adminユーザーは別のキャッシュキーに解決されます。私の場合、どちらの場合もキャッシュヒットの恩恵を受けられるということを意味しますが、adminユーザーの場合にのみ投稿の横に"Edit"ボタンが表示されます。素晴らしい!

🔗 キャッシュは素晴らしい

特に、キャッシュについて考える機会自体が最近までなかった(私のような)人がもろもろの作業を楽に行えるのは、ひとえにRailsのように必要なものが揃っている成熟したフレームワークのおかげです。Railsを構築して共有してくれた皆さんに感謝いたします💚

お知らせ: 最新情報をチェックしたい方へ

ラッキーなことに、このWebサイトはRSSでもMastodonでも購読可能です。他の記事も読みたい方は、私のニュースレターにお申し込みいただければ、いい感じのエッセイを月イチで配信いたします。もちろん私単独でのポッドキャストも運営しています。

関連記事

Rails 7でSMTPメールをAWS SESで送信する正しい方法(翻訳)

新規Rails 7アプリがHerokuのメモリクォータを超える問題(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。