Tech Racho エンジニアの「?」を「!」に。
  • 開発

[Rails 5] cache_keyによる結果セットとコレクションのキャッシュ機能(翻訳)

こんにちは、hachi8833です。BigBinaryシリーズは、Rails 5のキャッシュに関する翻訳記事をお送りいたします。元記事はRails 5リリース直前の頃のものなので、Rails 5.1で変わっている部分についても反映しました。

概要

訳文ではバージョンやリンクなどを現時点の内容に更新しています。

結果セットとコレクションのキャッシュ機能(翻訳)

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_atsizeusersコレクションから取得しています。

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"

users1users2cache_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が暗黙に実行されているのかもしれない」とのことでした。


関連記事


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。