酒と泪とRubyとRailsと

Ruby on Rails と Objective-C は酒の肴です!

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でのキャッシュ(ActiveSupport::Cache)

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

1
2
3
4
5
6
7
8
9
10
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側に格納されたデータは次のようになります。

1
2
3
$ 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側にはActiveSupport::Cache::Entryというオブジェクトとして、値が格納されるようです。

ActiveRecord::Relation.to_a (一回呼び出して)のキャッシュ

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

1
2
3
Rails.cache.fetch('prefecture') do
  Prefecture.all.to_a
end
1
2
3
4
5
$ 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

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

最近 ActiveRecord / DB周り に対して思うこと

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

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

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

shopify/identity_cache

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# キャッシュ制御に関するモジュール
#
# ■ 背景・目的
# - ActiveRecord便利なんだけど name 取得だけなのに SQLを発行しすぎな気がする
# - model自体に大したレコード数がない場合 & 特定の値を取りだけの場合、
#   => この機能を使って Redis にキャッシュしましょう
#   => 上の条件を満たさない場合は使わない方が幸せだと思います
#
# ■ 制約条件
# - id(Integer) のないテーブルでは使えません
# - キャッシュは CACHE_EXPIRE_IN (1時間) でクリアされます
#
# ■ 使い方
# - (1) Model にこのモジュールを include
# - (2) キャッシュしたいカラムを CACHED_COLUMNS = %i(xxx) とmodelに定義
# - (3) Class.cached_xxx_of(id) って呼び出す
module CacheSupport
  extend ActiveSupport::Concern

  module ClassMethods
    CACHE_EXPIRE_IN = 1.hour.freeze # キャッシュをクリアする秒数

    # キャッシュされた値を取得するメソッドが呼び出されたタイミングで、
    # そのメソッドが定義されていない場合のみ、メソッドを定義する
    def method_missing(method, *args, &block)
      if defined?(self::CACHED_COLUMNS) && is_cached_method?(method)
        define_singleton_method method do |arg_id|
          column = method.to_s.scan(/^cached_(.+)_of$/).flatten.first
          cached_hash[arg_id.to_i].try(:fetch, column.to_sym)
        end
        public_send(method, args.first)
      else
        super
      end
    end

    # キャッシュされたハッシュ
    def cached_hash
      Rails.cache.fetch("/models/cache_support/#{self.to_s.underscore}/cached_hash", expires_in: CACHE_EXPIRE_IN) do
        self.pluck(*cached_columns)
            .map { |_| Hash[*cached_columns.zip(_).flatten] }
            .map { |_| [_[:id], _.except(:id)] }.to_h
      end
    end

    private
    # キャッシュするカラムのリスト
    def cached_columns
      self::CACHED_COLUMNS.dup.map(&:to_sym).push(:id)
    end

    # キャッシュされている カラム を呼び出すメソッドなら true , 違えば false を返す
    def is_cached_method?(method)
      self::CACHED_COLUMNS.each do |col|
        return true if "cached_#{col}_of" == method.to_s
      end
      false
    end

  end
end

でこれを使うモデルでは CACHED_COLUMNS を追加するとき、 もしくは、キャッシュを呼び出すメソッド 使うときは必ずテスト(RSpec)書くって運用はどうでしょう?

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


Special Thanks

押さえておきたい書籍

いかがだったでしょうか?
もし説明がわかりにくかったり、間違っている場所があればぜひ一言!

Comments