こんにちは、hachi8833です。BigBinaryシリーズは、Rails 5のキャッシュに関する翻訳記事をお送りいたします。元記事はRails 5リリース直前の頃のものなので、Rails 5.1で変わっている部分についても反映しました。
概要
- 原文: Caching result sets and collection in Rails 5(米国BigBinary社のブログより)
- 原文公開日: 2016/02/02
- 著者: Mohit Natoo
訳文ではバージョンやリンクなどを現時点の内容に更新しています。
結果セットとコレクションのキャッシュ機能(翻訳)
Railsアプリを開発中に、Railsガイドに記載されているRails のキャッシュを使ってパフォーマンスを向上させたくなることがあります。Rails 5からは次のメソッドでレコードのコレクションをキャッシュできるようになります。
ActiveRecord::Relation.cache_key
コレクションのキャッシュについて
次の例で考えてみましょう。Miami city
にあるusers
のすべてのコレクションを取り出しています。
@users = User.where(city: 'miami')
@users
はレコードのコレクションであり、ActiveRecord::Relation
クラスのオブジェクトです。
上のクエリの結果は、以下の条件に応じて同じになることがあります。
- クエリのステートメントが変わらない場合は同じになります。たとえば市の名前をMiamiからBostonに変えると結果は変わります。
- レコードが削除されていない場合: コレクションのレコード数は変わりません。
- レコードが追加されていない場合: コレクションのレコード数は変わりません。
レコードのコレクションのキャッシュ機能がRailsコミュニティによって実装されました。ActiveRecord::Relation
に追加された#cache_key
メソッドは、クエリステートメント、updated_at
カラム、コレクションのレコード数といったさまざまな要素を考慮してキャッシュを行います。
ActiveRecord::Relation#cache_key
を理解する
ActiveRecord::Relation
クラスの@users
オブジェクトに対して#cache_key
メソッドを実行してみましょう。
@users.cache_key
# => "users/query-67ed32b36805c4b1ec1948b4eef8d58f-3-20160116111659084027"
出力の内訳は次のとおりです。
users
は現在保持しているレコードの種類を表します。上の例ではUser
クラスのレコードのコレクションを指しますので、users
レコードを保持していることがわかります。-
query-
の部分は固定です。 -
その後の
67ed32b36805c4b1ec1948b4eef8d58f
は、実行するクエリステートメントのダイジェストです。上の例ではMD5( "SELECT "users".* FROM "users" WHERE "users"."city" = 'Miami'")
の結果と等価です。 -
3
はコレクションのサイズです。 -
20160116111659084027
の部分はコレクションで最後に更新されたレコードのタイムスタンプです。デフォルトではupdated_at
がタイムスタンプカラムとして使われるので、コレクションで最も最近更新されたupdated_at
の値になります。
ActiveRecord::Relation#cache_key
を使ってみる
#cache_key
で実際にキャッシュされるところを観察してみましょう。
次のRailsアプリのコードで、city
がMiamiであるusers
のレコードをキャッシュします。
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.where(city: 'Miami')
end
end
# users/index.html.erb
<% cache(@users) do %>
<% @users.each do |user| %>
<p> <%= user.city %> </p>
<% end %>
<% end %>
1回目
Processing by UsersController#index as HTML
Rendering users/index.html.erb within layouts/application
(0.2ms) SELECT COUNT() AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."city" = ? [["city", "Miami"]]
Read fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms)
User Load (0.1ms) SELECT "users". FROM "users" WHERE "users"."city" = ? [["city", "Miami"]]
Write fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms)
Rendered users/index.html.erb within layouts/application (3.7ms)2回目
Processing by UsersController#index as HTML
Rendering users/index.html.erb within layouts/application
(0.2ms) SELECT COUNT(*) AS "size", MAX("users"."updated_at") AS timestamp FROM "users" WHERE "users"."city" = ? [["city", "Miami"]]
Read fragment views/users/query-37a3d8c65b3f0f9ece7f66edcdcb10ab-4-20160704131424063322/30033e62b28c83f26351dc4ccd6c8451 (0.0ms)
Rendered users/index.html.erb within layouts/application (3.0ms)
1回目はcount
クエリを実行して最新のupdated_at
とsize
をusers
コレクションから取得しています。
Railsの#cache_key
はこのcount
クエリからキャッシュを生成し、新しいキャッシュエントリを書き込みます。
2回目はcount
クエリを再度実行し、このクエリのcache_key
があるかどうかをチェックします。cache_key
がある場合は、SQLクエリを実行せずにデータを読み込みます。
テーブルにupdated_at
カラムがない場合は?
前述のとおり、#cache_key
メソッドではテーブルのupdated_at
カラムを使っています。#cache_key
には独自のカラムをパラメータとして渡せるオプションもあるので、その場合はコレクションにあるレコードの中でそのカラムの最も大きな値が使われます。
たとえば、products
テーブルのlast_bought_at
というカラムがビジネスロジックで(いわゆる)キャッシング購入の日時として使われている場合は、以下のようにカラムを指定できます。
products = Product.where(category: 'cars')
products.cache_key(:last_bought_at)
# => "products/query-211ae6b96ec456b8d7a24ad5fa2f8ad4-4-20160118080134697603"
気をつけたいエッジケース
#cache_key
を実際に使う前に、キャッシュの動作における若干のエッジケースについて知っておきましょう。
アプリのusers
テーブルに、city
がMiamiのエントリが5件あるとします。
#limit
を使うとコレクションが読み込まれない場合のサイズが実際と異なってしまう
次のクエリを実行すると、city
がMiamiのユーザーが3人読み込まれます。
users = User.where(city: 'Miami').limit(3)
users.cache_key
# => "users/query-67ed32b36805c4b1ec1948b4eef8d58f-3-20160116144936949365"
users
にはレコードが3つしかないので、#cache_key
ではコレクションのサイズが3件と認識されています。
続いて、レコードを事前にフェッチせずに同じクエリを実行してみましょう。
User.where(name: 'Sam').limit(3).cache_key
# => "users/query-8dc512b1408302d7a51cf1177e478463-5-20160116144936949365"
limit
で3を指定しているにもかかわらず、今度はキャッシュのカウントが5になっています。これは、ActiveRecord::Base#collection_cache_key
は実行時にコレクションのサイズをフェッチしていないためです。
訳注: BPS WebチームのakioさんがRails 5.1.1で挙動を確認したところ、以下のように先に
#limit
を呼んだ場合はなぜか正しいサイズを取得できました。
# Userに5件のデータがあるとする
users = User.all
users.cache_key # => "users/query-211ae6b96ec456b8d7a24ad5fa2f8ad4-5-20170517093650195306"
users.limit(3).cache_key # => "users/query-211ae6b96ec456b8d7a24ad5fa2f8ad4-5-20170517093650195306"
# limitを先に実行すると正常に動作する
users = User.all
users.limit(3).cache_key # => "users/query-e5d164039a35b778a6f43694a9940c43-3-20170517093650194483"
users.cache_key # => "users/query-211ae6b96ec456b8d7a24ad5fa2f8ad4-5-20170517093650195306"
コレクションの既存のレコードが置き換わってもキャッシュのキーが変わらない
以下のコードでは、id
を降順にしてユーザーを3人取得しています。
users1 = User.where(city: 'Miami').order('id desc').limit(3)
users1.cache_key
# => "users/query-57ee9977bb0b04c84711702600aaa24b-3-20160116144936949365"
ユーザーのidは[5, 4, 3]
になっています。
ここでid = 3のユーザーを削除します。
User.find(3).destroy
users2 = User.where(first_name: 'Sam').order('id desc').limit(3)
users2.cache_key
# => "users/query-57ee9977bb0b04c84711702600aaa24b-3-20160116144936949365"
users1
とusers2
のcache_key
が完全に一致しています。レコード数、クエリステートメント、最新レコードのタイムスタンプは、いずれもcache_key
に影響しないパラメータです。
cache_key
の一部にコレクションレコードのidを加えてはどうかという議論が#21503で行われていますので、この問題を解決するうえで参考になるかもしれません。
訳注: Rails 5.1.1 + Ruby 2.4.1環境で試してみたところ、5.1.1でも
#destroy
後にキャッシュは変化しませんでした。原文のコード例ではなぜかusers1とusers2のクエリが異なっていますので、以下のように同じクエリでも試してみましたが、やはりキャッシュは変化しませんでした。
users1 = User.where(city: 'Miami').order('id desc').limit(3)
users1.cache_key
# => "users/query-265ede4b5b3a003407f836b43a2d28d9-3-20170523082102806399"
User.find(3).destroy
users2 = User.where(city: 'Miami').order('id desc').limit(3)
users2.cache_key
# => "users/query-265ede4b5b3a003407f836b43a2d28d9-3-20170523082102806399"
group
クエリを使うとcache_key
のサイズが正しくならない
前述のlimit
の問題と少し似ていますが、cache_key
の動作は、データがメモリ上に読み込まれている場合と読み込まれていない場合とで挙動が異なります。
first_name
が'Sam'であるユーザーが2人いる場合を考えてみます。
コレクションがメモリに読み込まれない場合は次のようになります。
User.select(:first_name).group(:first_name).cache_key
# => "users/query-92270644d1ec90f5962523ed8dd7a795-1-20160118080134697603"
cache_key
のサイズは1になっています。キャッシュ機能の動作に従えば、サイズは1か5になります。この2つのサイズは、グループのサイズです。
続いて、コレクションを事前にメモリに読み込んだ場合を見てみましょう。
users = User.select(:first_name).group(:first_name)
users.cache_key
# => "users/query-92270644d1ec90f5962523ed8dd7a795-2-20160118080134697603"
#cache_key
のサイズは2になっています。両者のクエリの結果は完全に同じですが、サイズはメモリに読み込む前の値と異なっています。
コレクションがメモリに読み込まれると、サイズは(該当するグループのサイズではなく)グループの個数に等しくなります。このため、各グループ内にあるレコードが異なっていても#cache_key
の値が同じになる可能性があります。
訳注: Rails 5.1.1 + Ruby 2.4.1環境ではどちらのクエリもサイズが同じになりました↓。
User.select(:first_name).group(:first_name).cache_key
# => "users/query-b38de3cd3957533edc1278d89aef1deb-1-20170523040909796341"
users = User.select(:first_name).group(:first_name)
users.cache_key
# => "users/query-b38de3cd3957533edc1278d89aef1deb-1-20170523040909796341"
これまたakioさんが、このusersに対して
#to_a
を実行してから#cache_key
を呼ぶと、「ActiveModel::MissingAttributeError: missing attribute: updated_at」エラーになることを見つけました。私がpryで試しているとusers.cache_key
の部分でこのエラーが表示されましたが、irbで試すと正常に動作しました。「もしかするとpryでは#to_a
が暗黙に実行されているのかもしれない」とのことでした。
関連記事
- [Rails 5]モジュールやクラスレベルの変数をスレッドベースで作成する機能(翻訳)
- [Rails 5] モデルの継承元がActiveRecord::BaseからApplicationRecordに変更された
- [Rails 5] フォームごとに異なるCSRFトークンを受け取れるようになった(翻訳)
- [Rails 5] developmentモードのアセットログはデフォルトでオフになる(翻訳)
- [Rails 5] rails dev:cacheコマンドでdevelopmentモードでのキャッシュを簡単にオン・オフできる
- [Rails 5] コントローラの制約を受けずに任意のビューテンプレートをレンダリングする
- [Rails 5] rakeタスクがrailsコマンドでもできるようになった
- [Rails 5] Rails 5の新フレームワークデフォルト設定ファイルでアップグレード作業を軽減する
- [Rails 5] マイグレーション時にデータベースのカラムにコメントを追加する