Railsのマスタ的なModelのキャッシュについて[Redis]


Railsで中規模なサイトを作っていく上で
避けて通れないのが、増えてきたモデルを適切にキャッシュするしくみのように思えます。

特に変更が少ないマスタ的なテーブルに対して、『多対多』で関連付け(アソシエーション: association)がある場合などは、
それなりのSQLの発行コストになることがあります。そこを適切にキャッシュすることでDBへの負荷が減り、
ユーザーへのレスポンスが改善されると思います。

今回は、最近実装しているキャッシュの方法について、紹介したいと思います。
(というか偉い人、ぜひいい方法教えてください><)


🍣 前提条件: RailsからRedisにキャッシュ

今回は前提条件として、Railsのアプリケーションから『redis-store/redis-rails - GitHub
のGemを使って、Redisにキャッシュをされているとします。
セットアップ方法は『redis-store/redis-rails - GitHub
のREADMEを御覧ください。

またRedis自体のインストールについては拙著
CentOS/Mac OSXへのRedis導入手順 - memcacheライクなKey-Value方式と、永続化対応のインメモリDB
をよかったら御覧ください。

🐰 Railsでのキャッシュ(Active Support::Cache)

Rails内では次のように書くことでRedisにxxxというキーでキャッシュ済の場合は、Redisのキャッシュを取得します。
Redisにxxxというキーがない場合は中の処理を実行してキャッシュにセットしたうえで値を取得します。

array = Rails.cache.fetch('xxx') do
["hoge", "fuga"] # 実際には重い処理
end
puts array.to_s #=> ["hoge", "fuga"]

array = Rails.cache.fetch('xxx') do
["hoge", "fuuuga"]
end
# キャッシュが残っているので前の値が使われる
puts array.to_s #=> ["hoge", "fuga"]

ちなみに、Redis側に格納されたデータは次のようになります。

$ redis-cli
127.0.0.1:6379> GET xxx
"\x04\bo: ActiveSupport::Cache::Entry\b:\x0b@value[\aI\"\thoge\x06:\x06ETI\"\tfuga\x06;\aT:\x10@created_atf\x171446337812.0613928:\x10@expires_inf\n5.4e3"

Redis側にはActive Support::Cache::Entryというオブジェクトとして、値が格納されるようです。

🎃 Active Record::Relation.to_a (1回呼び出して)のキャッシュ

いよいよ本題です。まずは、ActiveRecord::Relation.to_aってやるとキャッシュできるか試してみました。

Rails.cache.fetch('prefecture') do
Prefecture.all.to_a
end
$ redis-cli

> GET prefecture
"\x04\bo: ActiveSupport::Cache::Entry\b:\x0b@value[6o:\x0fPrefecture\x10:\x10@attributeso:\x1fActiveRecord::AttributeSet\x06;\bo:$ActiveRecord::LazyAttributeHash\n:
..(省略)..eated_atf\x161446338581.033632:\x10@expires_inf\n5.4e3"

キャッシュされてた。どうやら、Rails 4.2.1以降はActiveRecord::LazyAttributeHash というオブジェクトでキャッシュされているっぽい。
Railsすごいな。.. (一部の環境でArelまでしかキャッシュされないことがある気がしますが、ライブラリとかのバージョン依存なのか、実装がしょぼいのか。..)

あと、これとは別で状態が変化するオブジェクトをキャッシュするのはいかがなものかという議論もあります。

Confusion caching Active Record queries with Rails.cache.fetch - Stack Overflow

この方が言っていることは至極もっともだと思います。あくまで変化が殆どなどような、マスタデータに
関するキャッシュを想定しています。

😎 最近Active Record / DB周りに対して思うこと

  • Active Record、まじ洗練されてすごい。でもそのぶん、レコード数、カラム数が多くなるとオブジェクトの生成コストつらい気がする
  • パフォーマンス保つためにもできるだけSQLは発行したくないよね
  • 仮にN+1をさけて、includeしてもDB側のSQLのコストはそれなりに高い(index次第だけど)
    • SQLの発行コストや発生頻度は常に意識する必要がある
    • コストを正しく把握したうえで、SQLを発行するかどうか選択すべき
  • ただし、トレードオフとして生産性がある。Active Recordの便利な機能使えないってことは生産性が下がる
  • 生産性を犠牲にはし過ぎないようにしたい。生産性を犠牲にしない程度にキャッシュを有効活用したい

🎂 ライブラリに依存すべき?

それっぽいGemがないかなと思って探していたら、shopify謹製のライブラリを発見しました。更新も頻繁に行われているようです。

shopify/identity_cache

便利そうなGemがあることはあるけど。..

- 学習コストが高いライブラリはやっぱり怖い(チーム開発で使いづらい)
- 実装をちゃんと読みきらないとブラックボックス化して怖そう...

🎉 models/concernでのキャッシュ実装の提案

Railsが前提にはなりますが、models/concern/cache_support.rb を実装して、プロジェクトで使いやすい形、
チームメンバーが簡単に使える形で実装していくという提案です。

一応補足で、キャッシュのクリアを1時間に1回にしていますが、これはマスタ系のデータがほぼ更新されない
ような特殊な環境を想定しています。正しくやるのであれば、after_saveとかのcallbackを使って、キャッシュを
クリアして上げるしくみも一緒に実装してあげると幸せになれると思います。

# キャッシュ制御に関するモジュール
#
# ■ 背景・目的
# - ActiveRecord便利なんだけど name とるだけなのに SQLを発行しすぎとか気になるます
# - model自体に大したレコード数がない場合は、この機能を使ってキャッシュしましょう
#
# ■ 制約条件
# - id のないテーブルでは使えません
# - キャッシュは1時間でクリアされます
#
# ■ お願い
# - 定期的な更新があるようなデータを取り扱う場合は、after_save / after_destroy とかで、
# キャッシュをクリアする機構を取り付けてください
#
# ■ 使い方
# △ 前提
# - Model にこのモジュールを include
#
# △ キャッシュから特定のcolumn(xxx)の値を取得する
# - Class.cached_xxx_of(id) って呼び出す
#
# △ キャッシュから特定id の ActiveRecord を取得する
# - Class.cached_record_of(id) って呼び出す
#
# △ キャッシュから全レコード(ActiveRecord) を取得する
# - Class.cached_all_records って呼び出す
#
module CacheSupport
extend ActiveSupport::Concern

module ClassMethods
# ------------------------------------------------------------------
# Public Class Methods
# ------------------------------------------------------------------
# キャッシュされた値を取得するメソッド(cached_xxx_of)を必要なタイミングで動的に生成
def method_missing(method_name, *args, &block)
if cache_method?(method_name)
define_singleton_method(method_name) do |arg_id|
column = method_name.to_s.scan(/^cached_(.*)_of$/).flatten.first
refresh_cache! if need_refresh? # キャッシュを更新
Rails.cache.fetch(cache_key_record(arg_id)).try(column.to_sym)
end
public_send(method_name, *args)
else
super
end
end

# Logic for this method MUST match that of the detection in method_missing
# - http://docs.ruby-lang.org/ja/2.2.0/method/Object/i/respond_to_missing=3f.html
# - https://robots.thoughtbot.com/always-define-respond-to-missing-when-overriding
# @return [Boolean]
def respond_to_missing?(method_name, include_private = false)
cache_method?(method_name) || super
end

# キャッシュされたレコードを返すメソッド
def cached_record_of(id)
refresh_cache! if need_refresh? # キャッシュを更新
Rails.cache.fetch(cache_key_record(id))
end

# 全てのレコードを返すメソッド
def cached_all_records
refresh_cache! if need_refresh? # キャッシュを更新
ids = Rails.cache.read(cache_key_ids)
cache_keys = ids.map { |id| cache_key_record(id) }
Rails.cache.read_multi(*cache_keys).values
end

private
# ------------------------------------------------------------------
# Private Class Methods
# ------------------------------------------------------------------
# キャッシュを設定
def refresh_cache!
# レコード単位のキャッシュ
self.all.each do |record|
Rails.cache.write(cache_key_record(record.id), record)
end

# idの配列をキャッシュ
Rails.cache.write(cache_key_ids, self.pluck(:id))
end

# キャッシュすべきなら true、キャッシュすべきでないなら false
def need_refresh?
key = "/models/cache_support/need_refresh?/#{self.to_s.underscore}"
if Rails.cache.exist?(key, expires_in: 1.hour)
return false
else
Rails.cache.write(key, 1, expires_in: 1.hour)
return true
end
end

# キャッシュした値を取得するメソッドなら true, 異なれば false
def cache_method?(method)
column_names.each do |col|
return true if "cached_#{col}_of" == method.to_s
end
false
end

# レコード単位のキャッシュ名
def cache_key_record(id)
"/models/cache_support/cache_key_record/#{self.to_s.underscore}/#{id}"
end

# id一覧のキャッシュ名
def cache_key_ids
"/models/cache_support/cache_key_ids/#{self.to_s.underscore}"
end

end # ClassMethods
end # CacheSupport

11/8に少し更新をしました。

- カラムを限定する仕組みを取りの時期ました。チームでの使いやすさを重視しました。
- ハッシュではなく、ActiveRecordにしました。ActiveRecordに戻すコストはかかりますが、使いやすさ重視です。
- キャッシュをレコード単位に変更しました。RedisのIOとハッシュに戻す部分のコストが大きかったので減らすのが目的です。
- 全レコードを一括で取得するメソッドを追加しました。Rails.cache.read_multi 便利。

このあたりの実装でよりいいプラクティスとかあればぜひ教えてほしいです><

😸 参考リンク

🖥 VULTRおすすめ

VULTR」はVPSサーバのサービスです。日本にリージョンがあり、最安は512MBで2.5ドル/月($0.004/時間)で借りることができます。4GBメモリでも月20ドルです。 最近はVULTRのヘビーユーザーになので、「ここ」から会員登録してもらえるとサービス開発が捗ります!

📚 おすすめの書籍