Ruby
Rails

Railsのフラグメントキャッシュについて調べてみた

はじめに

Railsの キャッシュ機能について調べてみました。 基本、Rails5.2の情報を元にしています。
フラグメントキャッシュ中心です。

キャッシュの種類

Rails ガイドによれば、の 以下の6つのキャッシュの方法がある模様。

  • ページキャッシュ
  • アクションキャッシュ
  • フラグメントキャッシュ
  • ロシアンドール・キャッシュ
  • 低レベル・キャッシュ
  • SQLキャッシュ

キャッシュ情報の格納先

Rails ガイドによれば、自分で格納先を作成することもできるみたいですが、Railsに既に組み込まれているものがいくつかあります。

  • メモリにストアする。 development環境でキャッシュ機能を有効にした場合、デフォルトで使われるようになってます。
  • ファイルにストアする。キャッシュ情報の格納先を指定しなかった場合に使われます。
  • memcached にストアする。 production 環境で最も良く使われる方法らしい。memcached を使う場合は、 dalli を使うことをRailsは想定しているみたいです。
  • キャッシュしない。 キャッシュ情報の格納先として :null_store ( ActiveSupport::Cache::NullStore ) を指定することにより、development環境や test環境で動作を確認する時に、 キャッシュさせないようにすることができます。

development 環境でキャッシュを使ってみる

今回は、memcached を使って、フラグメントキャッシュをやってみます。
memcached はあらかじめインストールして、使えるようにしておきます。(今回は、ローカル環境にインストールしている想定で話を進めます。)

dalli gem を追加

Gemfile に以下の1行を追加。

Gemfile
gem 'dalli'

bundle install を実行します。

$ bundle install

development 環境でキャッシュを有効にする

development 環境で、キャッシュを有効にするには、 rails コマンドを使います。

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

ちなみにキャッシュを無効にするには、同じコマンドを再度実行します。

$ bin/rails dev:cache
Development mode is no longer being cached.

なお、 bin/rails s でサーバーを起動した状態で、 bin/rails dev:cache を実行すると、サーバーが勝手に再起動してくれます。便利。

memcached を使うように設定を変更する

development 環境では、デフォルトでメモリにストアするようになってます。
config/environments/development.rb を修正して、memcached を使うようにします。

config/environments/development.rb
config.cache_store = :mem_cache_store

キャッシュの情報をログに出力するようにする

config/environments/development.rb に1行追加します

config/environments/development.rb
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true # この行を追加

今回の model

今回の model です。
Book モデルと Author モデルがあって、 Author モデルは複数の Bookモデルを持っています。

app/models/book.rb
class Book < ApplicationRecord
  belongs_to :author

  scope :recently, -> { order(updated_at: :desc) }
end
app/models/author.rb
class Author < ApplicationRecord
  has_many :books

  scope :recently, -> { order(updated_at: :desc) }
end

今回の controller

N+1問題がというツッコミは却下します。キャッシュを有効にするとどれだけ速くなるのか、その差が見たいため、N+1問題があるのは意図的です。

app/controllers/books_controller.rb
class BooksController < ApplicationController

  def index
    @books = Book.all.recently
  end

book の一覧ページでキャッシュする

bookの一覧ページは、 Book#title とそれに紐づく Author#name を表示してます。

Rails Guide では、フラグメントキャッシュのキーとして Model のインスタンスを使う例が説明されていますが、Modelのインスタンスではないものも指定できます。(ただし、キーに何を指定するかはちゃんと考える必要があります。)

良くありがちな以下のような一覧ページ(の一部)を cache するには <% cache(cache_key) %>
<% end %> を追加します。

app/views/books/index.html.erb
<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Author</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @books.each do |book| %>
      <tr>
        <td><%= book.title %></td>
        <td><%= book.author.name %></td>
        <td><%= link_to 'Show', book %></td>
        <td><%= link_to 'Edit', edit_book_path(book) %></td>
        <td><%= link_to 'Destroy', book, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

cache のキーとして book_index を指定してみます。(これはダメダメなキーです。理由は後述。)

app/views/books/index.html.erb
<% cache('book_index') %> ← これを追加する
<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Author</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @books.each do |book| %>
      <tr>
        <td><%= book.title %></td>
        <td><%= book.author.name %></td>
        <td><%= link_to 'Show', book %></td>
        <td><%= link_to 'Edit', edit_book_path(book) %></td>
        <td><%= link_to 'Destroy', book, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>
<% end %> ← これを追加する

railsを実行して、ブラウザからアクセス

railsを起動して、ブラウザから /books ページにアクセスします。
1回目にアクセスした時は、キャッシュには何もないので、memcached にViewの一部(cach(...) ... endで囲まれた中身)を書き込みます。

$ bin/rails s
Started GET "/books" for ...
....
Write fragment views/books/index:403214db429cc3cf8941bbae328c8e95/book_index (2.9ms)
  Rendered books/index.html.erb within layouts/application (791.9ms)
Completed 200 OK in 814ms (Views: 789.4ms | ActiveRecord: 17.9ms)

ブラウザでリロードして、2回目にアクセスした時は、1回目の表示でmemcachedに書き込んだキャッシュを読んでいることがわかります。

Started GET "/books" for...
Read fragment views/books/index:403214db429cc3cf8941bbae328c8e95/book_index (2.5ms)
  Rendered books/index.html.erb within layouts/application (6.3ms)
Completed 200 OK in 45ms (Views: 35.4ms | ActiveRecord: 2.9ms)

1回目が、814ms なのに対し、2回目は、45ms と明らかに速くなってます。

ここで、403214db429cc3cf8941bbae328c8e95 はキャッシュ対象のフラグメントから計算された hash digest です。
views/books/index.html.erb のキャッシュ対象の部分を修正すると、hash_digest も変わるので、キャッシュのキーが変わります。したがって、キャッシュはヒットせず、Viewの修正内容が反映されます。

キャッシュされている内容を確認する

rails コンソールを開いてキャッシュの内容を確認することができます。

$ bin/rails c
irb(:main):001:0> Rails.cache.fetch('views/books/index:403214db429cc3cf8941bbae328c8e95/book_index')
 .... # (HTMLの一部がだーっと表示される)

キャッシュの有効期限を指定する

キャッシュの有効期限を設定するには、 :expires_in を指定します。

<% cache('book_index', expires_in: 10.seconds) %>

キャッシュが書き込まれた直後に、ブラウザでリロードした場合はキャッシュがきいて素早く表示されます。10秒以上経過してからリロードするとキャッシュのフラグメントが消えているため、表示するのに初回と同じくらい時間がかかります。

book_index がダメダメな理由

アプリの仕様にもよりますが、book_index はキャッシュのキーとしてはダメダメです。
キャッシュが有効だと、Bookのデータを追加、更新、削除された時に、その内容が即座に一覧画面に反映されません。
普通はデータが変わったらその内容が即座に一覧にも反映されるのが望ましい動作と考えられます。(スピード優先でタイムラグがあっても良い場合もあるでしょうが...)

ダメダメでないキャッシュのキーを考える

まずは、@books をそのままキャッシュのキーに指定します。

キャッシュが効いてないとき

Write fragment views/books/index:75ff4d7911aeebfbe80e0f816333f57a/books/query-6167f02f4ae8188b5665cebde53451bd-1001-20180519063757574979 (8.8ms)
  Rendered books/index.html.erb within layouts/application (984.1ms)
Completed 200 OK in 1009ms (Views: 980.5ms | ActiveRecord: 21.9ms)

キャッシュが効いているとき

  Rendering books/index.html.erb within layouts/application
   (0.5ms)  SELECT COUNT(*) AS "size", MAX("books"."updated_at") AS timestamp FROM "books"
   app/views/books/index.html.erb:6
  Book Load (4.0ms)  SELECT "books".* FROM "books" ORDER BY "books"."updated_at" DESC # これが気になる
   app/views/books/index.html.erb:6
Read fragment views/books/index:75ff4d7911aeebfbe80e0f816333f57a/books/query-6167f02f4ae8188b5665cebde53451bd-1001-20180519063757574979 (74.1ms)
  Rendered books/index.html.erb within layouts/application (76.5ms)
Completed 200 OK in 106ms (Views: 91.5ms | ActiveRecord: 7.1ms)

キャッシュのキーが変わっていることに注意

これなら、Bookのデータに何らかの変更があった場合、Viewの情報が最新の状態に保たれます。

とはいえ、SELECT "books".* FROM "books" ORDER BY "books"."updated_at" がSQLを発行しているのかどうか微妙に気になります。

また、Bookのデータに紐づく、Authorのデータのnameが変更された場合に、Viewの Author#name が最新になりません。

キャッシュキーを改善する

という訳で、キャッシュキーを controller で生成します。
最後に更新されたBookのデータと最後に更新されたAuthorのデータをキャッシュキーにしてみます。
(キャッシュキーに配列を指定することもできます)

app/controllers/books_controller.rb
  def index
    @flagment_cache_key = [Book.recently.limit(1).first, Author.recently.limit(1).first]

    @books = Book.all.recently
  end
app/views/books/index.html
<% cache(@flagment_cache_key) do %>

キャッシュが効いていないとき

Write fragment views/books/index:a5e83f8f6f07fcdcbce16d7161ae4dd4/books/1001-20180519063757574979/authors/2-20180519065250666509 (2.6ms)
  Rendered books/index.html.erb within layouts/application (798.0ms)
Completed 200 OK in 823ms (Views: 795.4ms | ActiveRecord: 19.7ms)

キャッシュが効いているとき

Processing by BooksController#index as HTML
  Book Load (1.7ms)  SELECT  "books".* FROM "books" ORDER BY "books"."updated_at" DESC LIMIT ?  [["LIMIT", 1]]
   app/controllers/books_controller.rb:7
  Author Load (0.5ms)  SELECT  "authors".* FROM "authors" ORDER BY "authors"."updated_at" DESC LIMIT ?  [["LIMIT", 1]]
   app/controllers/books_controller.rb:7
  Rendering books/index.html.erb within layouts/application
Read fragment views/books/index:a5e83f8f6f07fcdcbce16d7161ae4dd4/books/1001-20180519063757574979/authors/2-20180519065250666509 (2.6ms)
  Rendered books/index.html.erb within layouts/application (5.7ms)
Completed 200 OK in 40ms (Views: 30.6ms | ActiveRecord: 2.2ms)

これで、うまくいくようになりました。

ActiveRecordオブジェクトをキャッシュキーに指定したとき

ちなみに、ActiveRecordオブジェクトをキャッシュのキー(の一部)に指定した場合は cache_key_with_version メソッドが使われるのではないかと思われます。

bin/rails c
irb(main):001:0> book = Book.recently.limit(1).first # controller でキャッシュキーに指定したBookオブジェクト
  Book Load (1.0ms)  SELECT  "books".* FROM "books" ORDER BY "books"."updated_at" DESC LIMIT ?  [["LIMIT", 1]]
=> #<Book id: 1001, title: "test", author_id: 9, created_at: "2018-05-12 03:15:20", updated_at: "2018-05-19 06:37:57">
irb(main):002:0> book.cache_key_with_version  # Bookオブジェクトからキャッシュキー生成
=> "books/1001-20180519063757574979"

実戦では、改ページ処理があったり、検索条件があったり、一般ユーザーと管理ユーザーで表示できるデータに違いがあったりとか色々複雑なこともあるので、それらを考慮したキャッシュキーを決定する必要がありそうです。

まとめ

  • フラグメントキャッシュを使えば表示が速くなります。
  • 逆にフラグメントキャッシュを使うと、最新のDBの内容が反映されないなど、ちゃんと考えずに使うとバグでないのにバグと勘違いしそうです。
  • キャッシュの対象範囲を考慮した方が良いです。
  • キャッシュのキーと有効期限に何を設定するかしっかり考えた方が良いです。
  • 安易にキャッシュに頼るよりは、DBのインデックスやQueryの書き方など、他に改善の余地がないか検討するのが先。
  • SPAでは使えないので、やるなら低レベルキャッシュ( Rails.cache.fetch を使う)になりそう。(これは機会があれば別途)

調査できていないところ

今回は、色々試してみるだけで、Railsのソースを追いかけていないため、以下のあたりが良くわかってません。

  • rspec の feature を実行する時には、ファイルキャッシュが有効になるのか?
  • キャッシュキーにオブジェクトを指定したときのキャッシュキーへの変換方法。

参考資料

Google で検索するといろんなサイトがヒットしますが、情報が古かったりします。結局のところ、最も役に立ったのは、本家本元のRails Guideの以下のサイトです。

APIの説明も役に立ちました。

最近、公開された以下のサイトもかなり参考になりました。