🦓

[Rails]キャッシュについて

2023/09/11に公開

はじめに

Railsにはキャッシュを管理するための機能がいくつ組み込まれています。
初期設定と例を使ってキャッシュの導入についてまとめてみました。

環境

Rails 7.0.7
ruby 3.2.1

キャッシュとは

「キャッシュ(caching)」とは、リクエスト・レスポンスのサイクルの中で生成されたコンテンツを保存しておき、次回同じようなリクエストが発生したときのレスポンスでそのコンテンツを再利用することを指します。

https://railsguides.jp/caching_with_rails.html

Railsのキャッシュ

  1. フラグメントキャッシュ (Fragment Caching):

フラグメントキャッシュは、ビューファイル内の特定の部分をキャッシュするための機能です。例えば、特定のビュー部分(部分テンプレート)が頻繁に変更されない場合、それをキャッシュして再利用することで、ページのレンダリング時間を短縮できます。cache ヘルパーメソッドを使用してキャッシュを定義します。

<% @products.each do |product| %>
  <% cache product do %>
    <%= render partial: "product", locals: { product: product } %>
  <% end %>
<% end %>
  1. ページキャッシュ (Page Caching):

ページキャッシュは完全なページをHTMLファイルとして保存し、再リクエスト時にそれを提供することで高速化を実現します。caches_page メソッドを使用して特定のコントローラーアクションのページキャッシュを有効にできます。

class ProductsController < ApplicationController
  caches_page :show
end

ページキャッシュ機能は、Rails 4で本体から削除されてgem化されました。actionpack-page_caching gemを参照してください。

  1. アクションキャッシュ

ページキャッシュは、before_filterのあるアクション(認証の必要なページなど)には適用できません。アクションキャッシュは、このような場合に使います。

アクションキャッシュの動作は、ページキャッシュと似ていますが、WebサーバーへのリクエストがRailsスタックに到達したときに、before_filterを実行してからキャッシュを配信する点が異なります。これによって、認証などの制限をかけながらキャッシュを配信できるようになります。

アクションキャッシュ機能は、Rails 4から削除されました。詳しくはactionpack-action_caching gemを参照してください。

  1. 低レベルキャッシュ API:

Railsは低レベルなキャッシュAPIも提供しており、Railsキャッシュストアを直接操作できます。
サポートされているキャッシュストアには、メモリ、Memcached、Redisなどがあります。

商品の平均価格を一時間キャッシュされ、リクエストごとに計算されることなく、データベースへの負荷を減らし、パフォーマンスを向上させることができます。
ただし、キャッシュが見つからない場合、ブロック内のコードが実行され、その結果がキャッシュに保存されます。

class Product < ApplicationRecord
  def self.average_price
    Rails.cache.fetch("products/average_price", expires_in: 1.hour) do
    average(:price)
    end
  end
end
キャッシュタイプ メリット デメリット 使用シーン
フラグメントキャッシュ - 特定のビュー部分の高速化
- 変更頻度の低いコンテンツをキャッシュ可能
- キャッシュキーの管理が必要
- キャッシュの有効期限の管理が必要
- 個々のビュー部分のレンダリングがコストが高い場合
- 頻繁に変更されないコンテンツを表示する場合
ページキャッシュ - 完全なHTMLページの高速化
- シンプルな設定
- 動的なコンテンツには適さない
- ユーザー固有の情報を含むページには適さない
- 変更頻度の低いページや静的なコンテンツを表示する場合
- ログインなどの承認が不要なページ
低レベルキャッシュ - カスタムのキャッシュロジックを実装可能 - キャッシュの設定と管理が複雑
- キャッシュストアの選択が必要
- より高度なキャッシュ制御が必要な場合
- キャッシュの有効期限をカスタマイズしたい場合
アクションキャッシュ - 特定のコントローラーアクションの高速化 - 承認が必要なコンテンツには適さない
- キャッシュのキーの設計が重要
- 特定のコントローラーアクションの出力をキャッシュしたい場合
- ユーザーに依存しないコンテンツの高速化が必要な場合

ページ内の一部のコンテンツをキャッシュするために使用されるフラグメントキャッシュについてもう少しみていきます。

cacheヘルパーメソッド

cache(name = {}, options = {}, &block)

特定の部分テンプレートをコレクション内の各要素に対して個別にキャッシュします。
各要素の変更は他の要素には影響しません。

<%= render partial: 'projects/project', collection: @projects, cached: true %>

指定された変数(project)に関連付けられたビュー全体をキャッシュします。変数が変更されるたびにキャッシュが無効になり、全体のビューが再描画される必要があります。

<% cache [ project, current_user ] do %>
  <%= render @projects %>
<% end %>

生成されたキャッシュ:DBからレコードを取得するより、キャッシュキーを通してコレクションを取得しています。

views/template/action:7a1156131a6928cb0026877f8b749ac9/projects/123
      ^template path  ^template tree digest            ^class   ^id

パーシャルに変更があるう場合キャッシュが無効になります。
skip_digest: trueはディジェスト(ファイルの内容に基づいたキャッシュの更新のトリガー)を無効にするオプションです

<% cache project, skip_digest: true do %>

また、キャッシュの有効期限を指定することもできます。

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

https://api.rubyonrails.org/classes/ActionView/Helpers/CacheHelper.html

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

フラグメントキャッシュの中に更にフラグメントキャッシュをネストしたい時に使います。
つまり、ビュー全体がキャッシュされ、ビュー内の特定の要素もまたキャッシュされることになります。これにより、要素ごとに独立したキャッシュが行われ、要素の更新時に必要な部分のみ再キャッシュできます。
カテゴリ、サブカテゴリ、その中の商品をそれぞれキャッシュした場合、他のカテゴリーのキャッシュに影響を与えないで一つのサブカテゴリに更新を行うことができます。

<% @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 %>

Railsはcreated_at, column_atカラムのデータを使用しキャッシュを管理します。

touch: true

商品が更新されたら、商品が所属するカテゴリ(Category)も自動的に更新される場合touch: trueオプションをつけて親オブジェクトのキャッシュも無効にします。。

touch: trueは、Active Recordモデルにおいて、関連付けられたオブジェクトが更新された際に、親オブジェクト(belongs_toで関連付けられたオブジェクト)も自動的に更新(タッチ)するためのオプションです。

関連付けの設定に追加します:

class Product < ApplicationRecord
  belongs_to :category, touch: true
end

class Category < ApplicationRecord
  has_many :products
end

キャッシュストア

キャッシュストアは、アプリケーションがデータを一時的に保存し、後で高速にアクセスできるようにするための仕組みやデータストレージのことです。
Railsで使われる主要なキャッシュストア4種類を紹介します。

  1. メモリストア(Memory Store):
    メモリストアは、データをメモリ内にキャッシュする方法です。Railsではconfig.cache_storeを設定して使用できます。
# config/environments/development.rb
config.cache_store = :memory_store

データベースからのクエリ結果をメモリにキャッシュします:

# クエリ結果をキャッシュ
@products = Rails.cache.fetch("all_products", expires_in: 1.hour) do  
  Product.all
end
  1. ディスクストア(Disk Store):
    ディスクストアは、データをディスク上のファイルにキャッシュする方法です。ファイルは永続的で、再起動後もデータが保持されます。
# config/environments/development.rb
config.cache_store = :file_store, "/path/to/cache/directory"

画像のリサイズ済みバージョンをディスクにキャッシュすることができます。

# 画像のリサイズ済みバージョンをキャッシュ
resized_image = Rails.cache.fetch("resized_image_#{image.id}", expires_in: 1.day) do
  image.resize_to_fit(200, 200)
end
  1. Redis:
    Redisは、高速で分散キャッシュを提供するインメモリデータベースです。RailsではRedisをconfig.cache_storeに設定して使用できます。
# config/environments/development.rb
config.cache_store = :redis_cache_store, { url: "redis://localhost:6379/0" }

例えば、ユーザーセッション情報をRedisにキャッシュすることができます。セッションデータは高速にアクセスできます。

# ユーザーセッション情報をRedisにキャッシュ
session_data = Rails.cache.fetch("user_session_#{self.id}", expires_in: 1.hour) do
   user_id: self.id,
   username: self.username
   ...
end
  1. Memcached:
    Memcachedも高速な分散キャッシュストアで、Railsで使用することができます。config.cache_storeに設定します。
# config/environments/development.rb
config.cache_store = :mem_cache_store, "localhost"

例えば、あるページのHTMLレスポンスをMemcachedにキャッシュして、負荷を軽減することができます。

# あるページのHTMLレスポンスをMemcachedにキャッシュ
cached_html = Rails.cache.fetch("page_#{page_id}", expires_in: 1.hour) do
   render_to_string(template: "pages/page")
end

キャッシュ前後のパフォーマンスを比較する

キャッシュされる前のTodos一覧のレコードを取得してビューを描画するまでの時間を計測してみます。

benchmark.realは計測結果の実際の時間を取得し、それをミリ秒に変換しています。
realは実行時間(CPU時間ではなく実際の時間)を表します。

app/controllers/todos_controller.rb
class TodosController < ApplicationController
  def index
      benchmark = Benchmark.measure do
      @todos = Todo.all
      render
    end

    logger.info "Benchmark results: #{benchmark.real * 1000}ms."
  end
end

ログの出力:

16:05:20 web.1  | Benchmark results: 182.16400011442602ms.
16:05:20 web.1  | Completed 200 OK in 184ms (Views: 173.6ms | ActiveRecord: 8.6ms | Allocation

キャッシュを有効にする

開発環境ではデフォルトでキャッシュがOFFになってます。

config/development.rb
  # Enable/disable caching. By default caching is disabled.
  # Run rails dev:cache to toggle caching.
  if Rails.root.join("tmp/caching-dev.txt").exist?
    config.action_controller.perform_caching = true
    config.action_controller.enable_fragment_cache_logging = true

    config.cache_store = :memory_store
    config.public_file_server.headers = {
      "Cache-Control" => "public, max-age=#{2.days.to_i}"
    }
  else
    config.action_controller.perform_caching = false

    config.cache_store = :null_store
  end

キャッシュをONにする:

bin/rails dev:cache
Development mode is now being cached.

tmp/ディレクトリ内にこれらのファイルがあることを確認します。

キャッシュディレクトリ

デフォルトでは#{root}/tmp/cache/内に作成されます。
ディレクトリを変えたい場合設定することができます。

config.cache_store = :file_store, "/path/to/cache/directory"

パーシャルをキャッシュする

app/views/todos/index.html.erb
<% cache @todos do %>
   <%= render @todos %>
<% end %>

ページリロードしキャッシュされたことを確認します。
フラグメントキャッシュからtodos一覧を取得しています。
スピードもDBから取得するより早いですね。

16:28:26 web.1  | Write fragment views/todos/index:19cbc8c49d7957d857632bf2d21d5760/todos/query-725ae7453acd1711aadcb2397fc190b9-100-20230911063354506083 (0.1ms)
16:28:26 web.1  |   Rendered todos/index.html.erb within layouts/application (Duration: 132.4ms | Allocations: 109570)

16:30:32 web.1  | Benchmark results: 24.107000092044473ms.
16:30:32 web.1  | Completed 200 OK in 28ms (Views: 23.6ms | ActiveRecord: 0.6ms | Allocations: 16963)

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

app/views/todos/index.html.erb
<%= render partial: "todos/todo", collection: @todos %>

キャッシュ前のロードタイム:

16:33:31 web.1  | Benchmark results: 154.8949999269098ms.
16:33:31 web.1  | Completed 200 OK in 158ms (Views: 148.5ms | ActiveRecord: 6.4ms | Allocations: 115129)

cached: trueを追加します。

app/views/todos/index.html.erb
<%= render partial: "todos/todo", 
		    collection: @todos, 
+		    cached: true %>

キャッシュからコレクションをレンダリングして表示していることを確認します。

16:37:39 web.1  |   Rendered collection of todos/_todo.html.erb [100 / 100 cache hits] (Duration: 3.8ms | Allocations: 3235)

16:37:39 web.1  | Benchmark results: 23.6200001090765ms.
16:37:39 web.1  | Completed 200 OK in 27ms (Views: 22.8ms | ActiveRecord: 0.9ms | Allocations: 21173)

終わりに

キャッシュを導入することでパーフォマンスの向上を実感しました!
これからもキャッシュを使っていきたいと思います。

https://kinsta.com/blog/rails-caching/

Discussion