Ruby on Rails のフラグメントキャッシュのキャッシュキーはどのように決まるか

TAGAWA Takao
locotabi-tech
Published in
28 min readAug 29, 2019

※2020年1月28日に「トラベロコ」は「ロコタビ」にサービス名称を変更しました。詳しい経緯などは代表の note 記事をご覧ください。

こんにちは。ロコタビ(旧トラベロコ)の開発チーム所属の多川です。先々週から突然株式会社トラベロコのエンジニアブログが始まりました。

株式会社トラベロコは、ロコタビ(旧トラベロコ)という海外在住日本人と日本在住日本人をつなぐスキルシェアリングサービスを運営している会社で、サービス自体であったり、創業時からフルリモートワークだったり、世界中にスタッフがいたり(例えば、日本から遠いところではポーランドやコロンビア、アルゼンチンなど)、といった働き方などがピックアップされがちなのですが、サービスの開発に関わるエンジニアたちの日々の苦労(?)をブログ記事にしていくと面白いのでは、ということで、エンジニアブログが始まりました。

もちろん、エンジニアも全員、フルリモートワークです。

2017年9月28日に私が書いたブログ記事の時点では日本人のエンジニアは私を含めて3人だけでしたが、本エントリーの執筆時点では、インターンも含めると9人になりました。

年齢も、これまでのキャリアも、住んでいる国や地域もバラバラなトラベロコのエンジニアたちの、日々の開発のあれこれについて、共感してもらえたら嬉しく思います。

さて、今回の私の記事は Ruby on Rails のフラグメントキャッシュについてです。以前、サービス内にフラグメントキャッシュを導入した際にめちゃくちゃいろいろ調べたのに、数ヶ月後に改めてフラグメントキャッシュを使おうと思ったら調べたことをすっかり忘れてしまっていたので、未来の私に向けて、調べたことをまとめておきたいと思います 😁

Ruby on Rails のフラグメントキャッシュとは

Ruby on Rails には標準でキャッシュ機構が組み込まれており、いくつかのレイヤーでキャッシュを利用できるのですが、 view 部分のキャッシュのことを「フラグメントキャッシュ」( Fragment Caching )と呼びます。

フラグメントキャッシュの利用方法は、 view ファイルの中でキャッシュしたい内容を cachecache_ifcache_unlessなどのヘルパーメソッドのブロックで囲むことで、囲んだブロック内が初回アクセス時にキャッシュとして保存され、2回目以降はキャッシュが利用されます。

基本的な使い方はオフィシャルの日本語ドキュメントが詳しいです。

このブログ記事では、 cacheメソッドの第一引数( cache_ifメソッドや cache_unlessメソッドだと第二引数)にどのような値を渡したときに、どのようなキャッシュキーになるか、を詳しく見ていきたいと思います。

ベースとなる Rails アプリケーション

Area に複数の User が所属している、という検証用の Rails アプリケーションを新しく作成しました。 Rails アプリケーション作成時に、検証がしづらいそうだったので、 turbolinks は無効化しました。

検証に利用した Ruby および Rails のバージョンは下記の通りです。

  • Ruby: 2.6.3
  • Ruby on Rails: 5.2.3

cache メソッドの第一引数を省略した場合

まずは cacheメソッドの第一引数を省略した場合を見ていきたいと思います。

Area 一覧の view ( index.html.erb )は下記のように用意しました。

<h1>Area 一覧</h1>
<% cache do %>
<ul>
<% @areas.each do |area| %>
<li><%= link_to area.name, area %></li>
<% end %>
</ul>
<% end %>

この状態で rails server を立ち上げて、ブラウザでアクセスすると、下記のようにログが出力されます。

Started GET "/areas" for ::1 at 2019–08–19 11:11:40 +0900
Processing by AreasController#index as HTML
Rendering areas/index.html.erb within layouts/application
Read fragment views/areas/index:aa2673bd2355d2048fb1537c03182bd1/localhost:3000/areas (0.1ms)
Area Load (0.2ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:4
Write fragment views/areas/index:aa2673bd2355d2048fb1537c03182bd1/localhost:3000/areas (0.2ms)
Rendered areas/index.html.erb within layouts/application (10.3ms)
Completed 200 OK in 35ms (Views: 26.1ms | ActiveRecord: 0.8ms)

Read fragmentの部分でキャッシュを探し、見つからなかったため、 Area Load (0.2ms) SELECT "areas".* FROM "areas"を実行しています。そのあと、 Write fragmentでキャッシュへの書き込みを行なっています。

次に2回目のアクセスのログを見ていきます。

Started GET "/areas" for ::1 at 2019–08–19 11:11:42 +0900
Processing by AreasController#index as HTML
Rendering areas/index.html.erb within layouts/application
Read fragment views/areas/index:aa2673bd2355d2048fb1537c03182bd1/localhost:3000/areas (0.1ms)
Rendered areas/index.html.erb within layouts/application (2.1ms)
Completed 200 OK in 19ms (Views: 18.4ms | ActiveRecord: 0.0ms)

Read fragmentでキャッシュが見つかったため、 SQL の発行は行わず、キャッシュを利用してレンダリングを行なっています。そのあと、キャッシュへの書き込みも行なっていません。

次に、 view ファイルを編集して保存したあとにブラウザで再度、アクセスした場合のログを見ていきます。

Started GET "/areas" for ::1 at 2019–08–19 11:12:11 +0900
Processing by AreasController#index as HTML
Rendering areas/index.html.erb within layouts/application
Read fragment views/areas/index:766cd21b9c5766e8256975f6a78509fb/localhost:3000/areas (0.1ms)
Area Load (0.2ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:4
Write fragment views/areas/index:766cd21b9c5766e8256975f6a78509fb/localhost:3000/areas (0.1ms)
Rendered areas/index.html.erb within layouts/application (3.9ms)
Completed 200 OK in 22ms (Views: 19.6ms | ActiveRecord: 0.2ms)

view ファイルが更新されたため、 views/areas/index:のあとの32文字の英数字部分が変わり、古いキャッシュは利用されませんでした。

ここまでは Area 一覧ページの動作を見てきましたが、次に Area 詳細ページの動作を見ていきます。

Area 詳細の view ( show.html.erb )を下記のように用意しました。

<h1>Area 詳細</h1>
<% cache do %>
<ul>
<li>エリア名: <%= @area.name %></li>
<li>作成日時: <%= @area.created_at %></li>
<li>更新日時: <%= @area.updated_at %></li>
</ul>
<% end %>

初回アクセスのログは下記の通りでした。

Started GET "/areas/1" for ::1 at 2019–08–19 11:13:13 +0900
Processing by AreasController#show as HTML
Parameters: {"id"=>"1"}
Area Load (0.3ms) SELECT "areas".* FROM "areas" WHERE "areas"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/controllers/areas_controller.rb:7
Rendering areas/show.html.erb within layouts/application
Read fragment views/areas/show:848ca6833bea261b8f5af75b912ee11a/localhost:3000/areas/1 (0.1ms)
Write fragment views/areas/show:848ca6833bea261b8f5af75b912ee11a/localhost:3000/areas/1 (0.1ms)
Rendered areas/show.html.erb within layouts/application (2.9ms)
Completed 200 OK in 35ms (Views: 22.4ms | ActiveRecord: 0.3ms)

見ていただきたい箇所は Read fragmentの行で、 Area 一覧では localhost:3000/areasとなっていた箇所が localhost:3000/areas/1となっています。

ここまででわかることとしては、 cacheメソッドの第一引数を省略した場合は、キャッシュキーは {view ファイルのパス}:{32文字の英数字}/{プロトコルを除いた URL}となっていました。 {32文字の英数字}は view ファイルの更新日時から一意の文字列を生成しているようです。

一見すると第一引数を省略しても問題ないように見えますが、第一引数を省略した場合、

  • DB に新しいレコードを挿入してもキャッシュは更新されない
  • DB の既存のレコードを更新しても( updated_at が更新されても)キャッシュは更新されない

といった問題が発生することが考えられます。

通常、 areas テーブルの内容が変わったら変更された内容を表示して欲しいので、この方法はあまり良くなさそうです。( URL をキーとして、 DB の更新は考えず一定期間キャッシュしたい、といった用途には使えそうです。)

cache メソッドの第一引数に文字列を指定した場合

次は cacheメソッドの第一引数に文字列を指定した場合を見ていきます。

view ( index.html.erb )の cacheメソッドの第一引数に areasという文字列を指定してみます。

<h1>エリア一覧</h1>
<% cache 'areas' do %>
<ul>
<% @areas.each do |area| %>
<li><%= link_to area.name, area %></li>
<% end %>
</ul>
<% end %>

初回アクセス時のログは下記の通りです。

# (一部省略)
Read fragment views/areas/index:07c72219a48d9da13a54ec7693c6f7f7/areas (0.1ms)
Area Load (0.2ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:4
Write fragment views/areas/index:07c72219a48d9da13a54ec7693c6f7f7/areas (0.1ms)
Rendered areas/index.html.erb within layouts/application (15.5ms)

2回目のアクセス時のログは下記の通りです。

# (一部省略)
Read fragment views/areas/index:07c72219a48d9da13a54ec7693c6f7f7/areas (0.1ms)
Rendered areas/index.html.erb within layouts/application (1.7ms)

見ていただきたいのは Read fragmentの行で、 cacheメソッドの第一引数を省略した場合のときと {view ファイルのパス}:{32文字の英数字}/までは同じで、そのあとが引数に指定した文字列になりました。

cacheメソッドの第一引数に文字列を指定した場合、 cacheメソッドの引数を省略した場合と同様、

  • DB に新しいレコードを挿入してもキャッシュは更新されない
  • DB の既存のレコードを更新しても( updated_at が更新されても)キャッシュは更新されない

といった問題が発生することが考えられます。

cache メソッドの引数にモデルのインスタンスを指定した場合

次は cacheメソッドの第一引数にモデルのインスタンス、例えば Area.allで取得した Area 一覧を指定した場合を見ていきます。

AreasController は下記のように記述しました。

def index
@areas = Area.all
end

view ( index.html.erb )は下記の通りで、 cacheメソッドの第一引数を @areasとしています。

<h1>Area 一覧</h1>
<% cache @areas do %>
<ul>
<% @areas.each do |area| %>
<li><%= link_to area.name, area %></li>
<% end %>
</ul>
<% end %>

この場合の初回アクセスのログは下記の通りです。

Started GET "/areas" for ::1 at 2019–08–19 13:55:05 +0900
Processing by AreasController#index as HTML
Rendering areas/index.html.erb within layouts/application
(0.5ms) SELECT COUNT(*) AS "size", MAX("areas"."updated_at") AS timestamp FROM "areas"
↳ app/views/areas/index.html.erb:2
Area Load (0.1ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:2
Read fragment views/areas/index:38a39dc611fce5d0106a20bbaa82693f/areas/query-278ec56659165b7d60ec0dbd85df284f-3–20190819022935879662 (26.0ms)
Write fragment views/areas/index:38a39dc611fce5d0106a20bbaa82693f/areas/query-278ec56659165b7d60ec0dbd85df284f-3–20190819022935879662 (0.1ms)
Rendered areas/index.html.erb within layouts/application (32.6ms)

上記のログでは Area は3つでした。

次に Area を一つ追加して、再度、アクセスしたログは下記の通りです。

Started GET "/areas" for ::1 at 2019–08–19 13:55:31 +0900
Processing by AreasController#index as HTML
Rendering areas/index.html.erb within layouts/application
(0.2ms) SELECT COUNT(*) AS "size", MAX("areas"."updated_at") AS timestamp FROM "areas"
↳ app/views/areas/index.html.erb:2
Area Load (0.1ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:2
Read fragment views/areas/index:38a39dc611fce5d0106a20bbaa82693f/areas/query-278ec56659165b7d60ec0dbd85df284f-4–20190819045527821007 (1.5ms)
Write fragment views/areas/index:38a39dc611fce5d0106a20bbaa82693f/areas/query-278ec56659165b7d60ec0dbd85df284f-4–20190819045527821007 (0.1ms)
Rendered areas/index.html.erb within layouts/application (3.0ms)

次に Area の一つを更新して( Area.first.touch )、再度、アクセスしたログは下記の通りです。

Started GET "/areas" for ::1 at 2019–08–19 16:57:53 +0900
Processing by AreasController#index as HTML
Rendering areas/index.html.erb within layouts/application
(0.2ms) SELECT COUNT(*) AS "size", MAX("areas"."updated_at") AS timestamp FROM "areas"
↳ app/views/areas/index.html.erb:3
Area Load (0.1ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:3
Read fragment views/areas/index:66b2f6d256139676c6fc592565a02677/areas/query-278ec56659165b7d60ec0dbd85df284f-4–20190819050857241886 (1.2ms)
Write fragment views/areas/index:66b2f6d256139676c6fc592565a02677/areas/query-278ec56659165b7d60ec0dbd85df284f-4–20190819050857241886 (0.2ms)
Rendered areas/index.html.erb within layouts/application (4.1ms)

見ていただきたいのはやはり Read fragmentの行で、それぞれのログを見比べると、 views/areas/index:{32文字の英数字}/areas/query-{32文字の英数字}- までは同じで、ログの SQL の通りですが、 areas テーブルの countMAX("areas"."updated_at")をタイムスタンプとしてキャッシュキーに使用しています。ちなみに、 query-のあとの32文字の英数字は SELECT "areas".* FROM "areas"の SQL 分から一意の文字列を生成しているようです。

これまでに見てきたように、「 cacheメソッドの第一引数を省略した場合」や「 cacheメソッドの第一引数に文字列を指定した場合」とは違い、 countupdated_atをキャッシュキーの一部に使用することで、「古いキャッシュを読みにいってしまいページが更新されない」といったことを防いでいます。

同様に Area 詳細ページの場合を見ていきます。

view ( show.htmlerb )は下記の通りで、 cacheメソッドの第一引数を @areaとしています。

<h1>Area 詳細</h1>
<% cache @area do %>
<ul>
<li>エリア名: <%= @area.name %></li>
<li>created_at: <%= @area.created_at %></li>
<li>updated_at: <%= @area.updated_at %></li>
</ul>
<% end %>

初回アクセス時のログは下記の通りです。

Started GET "/areas/1" for ::1 at 2019–08–19 16:51:19 +0900
Processing by AreasController#show as HTML
Parameters: {"id"=>"1"}
Area Load (0.2ms) SELECT "areas".* FROM "areas" WHERE "areas"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/controllers/areas_controller.rb:7
Rendering areas/show.html.erb within layouts/application
Read fragment views/areas/show:4bcb5ad040193c4a62aea5d187cd18c2/areas/1–20190819050857241886 (0.5ms)
Write fragment views/areas/show:4bcb5ad040193c4a62aea5d187cd18c2/areas/1–20190819050857241886 (0.1ms)
Rendered areas/show.html.erb within layouts/application (2.9ms)

/areas/2への初回アクセスのログは下記の通りです。

Started GET "/areas/2" for ::1 at 2019–08–19 16:51:48 +0900
Processing by AreasController#show as HTML
Parameters: {"id"=>"2"}
Area Load (0.1ms) SELECT "areas".* FROM "areas" WHERE "areas"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
↳ app/controllers/areas_controller.rb:7
Rendering areas/show.html.erb within layouts/application
Read fragment views/areas/show:4bcb5ad040193c4a62aea5d187cd18c2/areas/2–20190819022935876855 (0.2ms)
Write fragment views/areas/show:4bcb5ad040193c4a62aea5d187cd18c2/areas/2–20190819022935876855 (0.1ms)
Rendered areas/show.html.erb within layouts/application (1.9ms)

今度は少し変わって、 query-{32文字の英数字}countは利用されず、 idupdated_atのタイムスタンプが利用されています。

Area 詳細ページも Area 一覧ページと同様に idupdated_atをキャッシュキーの一部に使用することで、「古いキャッシュを読みにいってしまいページが更新されない」といったことを防いでいます。

cache メソッドの第一引数に中身は文字列の配列を渡した場合

次は cacheメソッドの第一引数に中身は文字列の配列を指定した場合を見ていきます。

view ( index.html.erb )の cacheメソッドの第一引数に %w(foo bar)という配列を指定してみます。

<h1>Area 一覧</h1>
<% cache %w(foo bar) do %>
<ul>
<% @areas.each do |area| %>
<li><%= link_to area.name, area %></li>
<% end %>
</ul>
<% end %>

初回アクセス時のログは下記の通りです。

# (一部省略)
Read fragment views/areas/index:6ad6a9c54f98aacd1ed6e47c151ec8d2/foo/bar (0.1ms)
Area Load (0.2ms) SELECT "areas".* FROM "areas"
↳ app/views/areas/index.html.erb:5
Write fragment views/areas/index:6ad6a9c54f98aacd1ed6e47c151ec8d2/foo/bar (0.1ms)
Rendered areas/index.html.erb within layouts/application (3.2ms)

結果は「 cacheメソッドの引数に文字列を指定した場合」とほぼ同じで、末尾の文字列が、配列の場合は文字列を / で結合したものになりました。

cache メソッドの第一引数に中身はモデルのインスタンスの配列を指定した場合

次は cacheメソッドの第一引数に中身はモデルのインスタンスの配列を指定した場合を見ていきます。

例えば、 Area 詳細ページで、所属している User の一覧も一緒にフラグメントキャッシュにしたい場合に、 cacheメソッドの引数に [@area, @area.users]のようにそれぞれがモデルのインスタンスな配列を指定します。( show.html.erb )

<h1>Area 詳細</h1>
<% cache [@area, @area.users] do %>
<ul>
<li>エリア名: <%= @area.name %></li>
<li>作成日時: <%= @area.created_at %></li>
<li>更新日時: <%= @area.updated_at %></li>
</ul>
<h2>所属ユーザー</h2>
<% @area.users.each do |user| %>
<ul>
<li>名前: <%= user.last_name %> <%= user.first_name %>( 作成日時: <%= user.created_at %>, 更新日時: <%= user.updated_at %>)</li>
</ul>
<% end %>
<% end %>

初回アクセス時のログは下記の通りです。

Started GET "/areas/1" for ::1 at 2019–08–19 14:30:31 +0900
Processing by AreasController#show as HTML
Parameters: {"id"=>"1"}
Area Load (0.2ms) SELECT "areas".* FROM "areas" WHERE "areas"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/controllers/areas_controller.rb:7
Rendering areas/show.html.erb within layouts/application
(0.2ms) SELECT COUNT(*) AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."area_id" = ? [["area_id", 1]]
↳ app/views/areas/show.html.erb:2
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."area_id" = ? [["area_id", 1]]
↳ app/views/areas/show.html.erb:2
Read fragment views/areas/show:9e82795170690db7608139468ae31b16/areas/1–20190819050857241886/users/query-7d20f7de2b9a71e2dd719ccf3d1bbd7e-2–20190819023722782392 (27.3ms)
Write fragment views/areas/show:9e82795170690db7608139468ae31b16/areas/1–20190819050857241886/users/query-7d20f7de2b9a71e2dd719ccf3d1bbd7e-2–20190819023722782392 (0.4ms)
Rendered areas/show.html.erb within layouts/application (101.2ms)

ログの通りですが、「 cacheメソッドの第一引数にモデルのインスタンスを渡した場合」で出てきた Area 詳細の「 idとタイムスタンプ」と Area 一覧の「 query-{32文字の英数字}countとタイムスタンプ」を /で結合した文字列がキャッシュキーになっています。

つまり、 @areaが更新された場合と @area.usersに更新や行の追加があった場合に、古いキャッシュを読みにいってしまうことを防いでいます

トラベロコでのフラグメントキャッシュのキャッシュキーの決め方

ここまで読んでいただいた方はもうお分かりかと思いますが、 cacheメソッドの第一引数は、下記のようなルールで運用すると良さそうで、実際にトラベロコでもそのようなルールで運用しています。

  • モデルのインスタンスの内容をキャッシュしたい場合はモデルのインスタンスを指定する
    — 例1: <% cache @areas %>
    — 例2: <% cache @area %>
  • 複数のモデルのインスタンスの内容をキャッシュしたい場合は、中身がモデルのインスタンスの配列を指定する
    — 例: <% cache [@area, @area.users] %>
    — または、ロシアンドールキャッシュを利用する
  • DB の更新状況に依存したくない場合は文字列を指定してもOK
    — 例えば、更新頻度が高いけど常に最新を表示しなくても良い場合など
    — ただし、その場合は適切な有効期限を設定する
    — 例: <% cache 'area_in_asia', expries_in: 1.hour %>

以上、 Ruby on Rails のフラグメントキャッシュのキャッシュキーがどのように決まるか、を詳しく調べてみた話でした。どなたかのご参考になれば幸いです。

トラベロコでは、キャッシュ利用した(キャッシュじゃなくてもいいけど)パフォーマンス改善に興味のある Web エンジニアを募集中です。

参考

--

--

TAGAWA Takao
locotabi-tech

トラベロコ所属のウェブエンジニア。個人ブログ → https://dounokouno.com Twitter → https://twitter.com/dounokouno