ウェブアプリケーションのパフォーマンスとスケーラビリティの向上には、キャッシュが必要不可欠。Ruby on Railsのキャッシュも例に漏れません。高度な計算やデータベースクエリの結果を保存し再利用することで、ユーザーリクエストへの対応に要する時間とリソースを大幅に削減することができます。

今回はフラグメントキャッシュやロシアンドールキャッシュなど、様々なタイプのキャッシュをRailsで実装する方法をご紹介します。またキャッシュの依存関係を管理し、キャッシュストアを選択する方法、そしてRailsアプリでキャッシュを効果的に使用するためのベストプラクティスも見ていきます。

なお、Ruby on Railsの操作およびRailsビューの使用に慣れていること、Railsバージョン6以降を使用することを前提とします。新規または既存のビューテンプレート内でキャッシュを使用する方法をコード例を用いて解説していきます。

Ruby on Railsキャッシュの種類

Ruby on Railsアプリケーションでは、キャッシュするコンテンツのレベルと粒度に応じて、いくつかのタイプのキャッシュが利用できます。最近では、主に以下のキャッシュが使用されています。

  • フラグメントキャッシュ─ヘッダー、フッター、サイドバー、静的コンテンツなど、頻繁に変更されないページの一部をキャッシュ。リクエストごとにレンダリングされる部分やコンポーネントの数を減らすことが目的。
  • ロシアンドールキャッシング─コレクションやアソシエーションなど、互いに依存し合うページのネストされたフラグメントをキャッシュ。不必要なデータベースクエリを防ぎ、変更されないキャッシュされたフラグメントの再利用が容易になる。

また、以前はRuby on Railsの一部で、現在はgem化された以下のキャッシュもあります。

  • ページキャッシュ─ページ全体をサーバー上の静的ファイルとしてキャッシュし、ページレンダリングのライフサイクル全体をバイパス。
  • アクションキャッシュ─アクション全体の出力をキャッシュ。ページキャッシュとの違いとして、認証のようなフィルターを適用できる。

この2種類のキャッシュは使用されることはあまりなく、最近のRailsアプリでは、ほとんどの用途で推奨されていません。

フラグメントキャッシュ

フラグメントキャッシュを使うと、頻繁に変更されないページの一部をキャッシュできます。例えば、商品の一覧と各商品の価格や評価を表示するページで滅多に変更されない情報などに有用です。

コメントやユーザーレビューのような動的な部分は、ページの読み込みのたびに再レンダリングすることもできます。キャッシュを頻繁に更新するのには負荷が生じるため、ビューの基礎となるデータが頻繁に変更される場合にはあまり役立ちません。

フラグメントキャッシュはRailsのキャッシュの中で最もシンプルで、パフォーマンス改善を目的としたキャッシュの導入において、最初の選択肢になります。

Railsでフラグメントキャッシュを使用するには、ビューでcacheヘルパーメソッドを使います。例えば、ビューで商品情報をキャッシュするには、以下のようなコードを書きます。

<% @products.each do |product| %>
  <% cache product do %>
    <%= render partial: "product", locals: { product: product } %>
  <% end %>
<% end %>

cacheヘルパーは、各要素のクラス名id、およびupdated_atのタイムスタンプ(products/1-20230501000000など)に基づいてキャッシュキーを生成。次に買い物客が同じ商品をリクエストすると、cacheヘルパーがキャッシュストアからキャッシュされたフラグメントを取得し、データベースからデータを読み込むことなく商品が表示されます。

cacheヘルパーにオプションを渡すことで、キャッシュキーをカスタマイズすることも可能です。例えば、以下はバージョン番号やタイムスタンプをキャッシュキーに含める例です。

<% @products.each do |product| %>
  <% cache [product, "v1"] do %>
    <%= render partial: "product", locals: { product: product } %>
  <% end %>
<% end %>

以下のように有効期限を設定することも可能です。

<% @products.each do |product| %>
  <% cache product, expires_in: 1.hour do %>
    <%= render partial: "product", locals: { product: product } %>
  <% end %>
<% end %>

1つ目の例では、キャッシュキーにv1を追加しており(products/1-v1など)、部分テンプレートやレイアウトを変更した際にキャッシュを無効にするのに便利です。2つ目は、キャッシュエントリーに有効期限(1時間)を設定しています。

ロシアンドールキャッシュ

ロシアンドールキャッシュは、Ruby on Railsの強力なキャッシュで、キャッシュを互いに入れ子にすることでアプリケーションのパフォーマンスを最適化するものです。Railsのフラグメントキャッシュとキャッシュの依存関係を使用して、冗長な作業を最小限に抑え、読み込み時間を改善します。

一般的なRailsアプリでは、商品のコレクションをレンダリングすることが多く、それぞれが複数の子コンポーネントを持ちます。1つの商品を更新する際には、コレクション全体や影響を受けない商品の再レンダリングを避けるのが賢明です。階層構造やネストされたデータ構造を扱う際、特にネストされたコンポーネントが独立して変更される可能性のある独自の関連データを持つ場合には、ロシアンドールキャッシュを使用するのが効果的です。

このキャッシュの欠点は、複雑になることです。狙いどおりの商品データをキャッシュするには、キャッシュする商品のネストされたレベル間の関係を把握しなければなりません。場合によってはActive Recordに関連付けし、キャッシュされたデータ項目間の関係を推測できるようにする必要があります。

フラグメントキャッシュと同様、ロシアンドールキャッシュでもcacheヘルパーメソッドを使用します。例えば、ビューでカテゴリとそのサブカテゴリと商品をキャッシュするには、以下のようになります。

<% @categories.each do |category| %>
  <% cache category do %>
    <h2><%= category.name %></h2>
    <% category.subcategories.each do |subcategory| %>
    <% cache subcategory do %>
    <h3><%= subcategory.name %></h3>
    <% subcategory.products.each do |product| %>
    <% cache product do %>
        <%= render partial: "product", locals: { product: product } %>
        <% end %>
    <% end %>
    <% end %>
    <% end %>
  <% end %>
<% end %>

cacheヘルパーは、それぞれのネストされたレベルをキャッシュストアに個別に保存します。次に同じカテゴリがリクエストされると、キャッシュされたフラグメントがキャッシュストアから取得され、レンダリングすることなく表示されます。

ただし、サブカテゴリや商品の詳細情報(名前や説明など)が変更されると、キャッシュされたフラグメントは無効になり、更新後のデータで再レンダリングされます。そのため、1つのサブカテゴリや商品を変更する際、カテゴリ全体を無効にする必要はありません。

キャッシュの依存関係を管理する

キャッシュの依存関係は、キャッシュされたデータとその基礎となるソースとの関係を意味し、管理は容易ではありません。ソースデータが変更されると、関連するキャッシュデータはすべて失効してしまいます。

Railsでは、タイムスタンプを使用して、ほぼすべてのキャッシュの依存関係を自動管理することができます。すべてのActive Recordには、キャッシュがいつレコードを作成したか(created_at))、いつ最後に更新したか(updated_at)を示す属性があります。自動的にキャッシュ管理を行うには、Active Recordのリレーションシップを次のように定義します。

class Product < ApplicationRecord
  belongs_to :category
end
class Category < ApplicationRecord
  has_many :products
end

このコードには、以下のような特徴があります。

  • 商品レコードを更新すると(価格を変更するなど)、updated_atタイムスタンプが自動的に変更される。
  • このタイムスタンプをキャッシュキーの一部として使用する場合( products/1-20230504000000など)、キャッシュされたフラグメントも自動的に無効になる。
  • 商品レコードを更新したときにカテゴリのキャッシュフラグメントを無効にするには、(平均価格などの集計データを表示するために)コントローラでtouchメソッドを使用するか(@product.category.touch)、関連付けでtouchオプションを追加する(belongs_to :category touch: true)。

キャッシュの依存関係を管理する別の方法は、fetchwriteといった低レベルのキャッシュメソッドをモデルやコントローラで使用することです。これによって、任意のデータやコンテンツをカスタムキーやカスタムオプションを使ってキャッシュストアに保存することができます。例を見てみましょう。

class Product < ApplicationRecord
  def self.average_price
    Rails.cache.fetch("products/average_price", expires_in: 1.hour) do
    average(:price)
    end
  end
end

このコードでは、fetchメソッドにカスタムキー(products/average_price)と有効期限(expires_in: 1.hour)を指定して、計算されたデータ(全商品の平均価格など)を1時間キャッシュします。

fetchメソッドは、まずキャッシュストアからデータを読み込みます。データが見つからない、またはデータの有効期限が切れている場合には、ブロックを実行し、結果をキャッシュストアに保存します。

有効期限が切れる前にキャッシュエントリを手動で無効にするには、以下のようにforceオプションを指定してwriteメソッドを使用します。

Rails.cache.write("products/average_price", Product.average(:price), force: true))

キャッシュストアとキャッシュバックエンド

Railsでは、キャッシュされたデータやコンテンツの保存に、さまざまなキャッシュストアやバックエンドを選択できます。Railsキャッシュストアはストレージシステムと対話するための共通インターフェースを提供する抽象化レイヤー。キャッシュバックエンドは、特定のストレージシステム用のキャッシュストアインターフェースを実装するものです。

Railsは以下のキャッシュストアやバックエンドを標準でサポートしています。

メモリストア

メモリストアは、メモリ内のハッシュをキャッシュストレージとして使用します。高速でシンプルですが、容量と永続性に限りがあります。開発環境やテスト環境、あるいは小規模でシンプルなアプリケーションに適しています。

ディスクストア

ディスクストアは、ディスク上のファイルをキャッシュストレージとして使用します。Railsの中で最も遅いキャッシュストアですが、容量と永続性に長けています。大量のデータをキャッシュする必要がありパフォーマンスが最優先事項でないアプリケーションにお勧めです。

Redis

Redisは、キャッシュストレージにRedisインスタンスを使用します。インメモリデータストアで、複数のデータタイプに対応しています。高速で柔軟ですが、別のサーバーと設定が必要になります。複雑なデータや頻繁に変更される動的なデータをキャッシュする必要があるアプリケーションに適しています。また、Kinstaを含め、一部のホスティングサービスでは、Redisを永続オブジェクトキャッシュとして提供しているため、クラウドでRailsアプリを実行する場合にも理想的です。

Memcached

Memcachedは、キャッシュストレージにMemcachedインスタンスを使用します。シンプルなデータ型と機能を備えたインメモリキーバリューストアです。高速でスケーラブルですが、Redis同様、個別のサーバーと設定が必要です。頻繁に更新される単純なデータや静的なデータをキャッシュする必要があるアプリケーションに適しています。

キャッシュストアは、Railsの環境ファイル(config/environments/development.rbなど)でconfig.cache_storeオプションを使用して設定します。Railsに組み込まれている各キャッシュメソッドの使用方法は以下のとおりです。

# メモリストアを使う
config.cache_store = :memory_store
# ディスクストアを使う
config.cache_store = :file_store, "tmp/cache"
# Redisを使う
config.cache_store = :redis_cache_store, { url: "redis://localhost:6379/0" }
# Memcachedを使う
config.cache_store = :mem_cache_store, "localhost"

config.cache_storeの呼び出しは、1つの環境ファイルにつき1回にとどめてください。複数ある場合、最後の1つだけが使用されます。

各キャッシュストアには、アプリケーションの要件や好みに応じて、それぞれ利点と欠点があります。用途と経験値に応じて、適切なものを選択してください。

Ruby on Railsキャッシュのベストプラクティス

Railsアプリでキャッシュを使用する際、以下のようなベストプラクティスに従うと、パフォーマンスとスケーラビリティを劇的に向上することができます。

  • 選択的にキャッシュする─頻繁にアクセスされるデータ、生成に負荷がかかるデータ、更新頻度の低いデータのみをキャッシュしましょう。過剰なメモリ使用、古いデータのリスク、パフォーマンスの低下を防ぐために、過剰なキャッシュは避けるのが賢明です。
  • キャッシュエントリを失効させる─無効または無関係なエントリを失効させることで、データの陳腐化を防ぎます。タイムスタンプや有効期限オプションを使用するか、手動で無効化してください。
  • キャッシュパフォーマンスの最適化─アプリケーションの要件に適したキャッシュストアを選択し、サイズ、圧縮、シリアライズなどのパラメータを微調整して、最高のパフォーマンスを実現しましょう。
  • キャッシュが与える影響の監視とテスト─キャッシュの動作(ヒット率、ミス率、レイテンシなど)を評価し、パフォーマンス(応答時間、スループット、リソース使用量)に与える影響を監視しましょう。New RelicRailsのログActiveSupportの通知機能rack-mini-profilerなどのツールが役立ちます。

まとめ

Ruby on Railsのキャッシュを使用して、頻繁にアクセスされるデータやコンテンツを効率的に保存して再利用することで、アプリケーションのパフォーマンスとスケーラビリティを改善することができます。キャッシュ技術について知識を深め、より高速なRailsアプリをユーザーに提供しましょう。

最適化されたRailsアプリをデプロイするには、Kinstaのアプリケーションホスティングがお勧めです。ホビープランは無料で利用し始めることができます。Ruby on Railsアプリのセットアップ方法も詳しくご紹介しています。

Steve Bonisteel Kinsta

Kinstaのテクニカルエディター。救急車や消防車を追いかける記者としてキャリアをスタート。1990年代後半からインターネット関連の技術情報を担当している。