RailsのActiveSupport::Cacheの実装とデータの保存形式について調べてみた

これは Ruby on Rails その2 Advent Calendar 2018 - Qiita20日目の記事です。

※ 実は PeXのRailsを2年ぶりに4.2から5.0にアップデートしました。キャッシュやセッションまわりでのエラーにご注意! - VOYAGE GROUP techlog のキャッシュでハマったことの補足にもなっています。よかったらこちらも見ていただけると嬉しいです!

Railsアプリケーションではキャッシュ機能を利用する時 ActiveSupport::Cache を使って実装すると思います。アプリケーションから利用する時は Rails.cache.writeRails.cache.read ですね。 DBへ重いクエリを投げるところの負荷をさげるため、クエリ結果であるActiveRecordをキャッシュするというのが最も多い利用方法かなと思います。

そのキャッシュ部分について ActiveSupport::Cache の内部実装について踏み込んで見ていきます!

キャッシュにどのように格納されるのか?

他の言語やフレームワークでキャッシュを利用したことがある方であれば、何かしらシリアライズされたフォーマットでRedisなりMemcachedなどのキャッシュストアに保存されるのだろうな、というのは想像出来ることだと思います。

ActiveSupport::Cache で保存するときは Marshal を使ってマーシャルデータがキャッシュに保管されます。

マーシャルデータの構造については詳しく触れないのですが、Marshal フォーマット (Ruby 2.5.0) を見ると、ふむふむよくわからんってなると思います。

雑に説明するとActiveRecordのようなgemで定義されているクラスは「instance of the user class」の「上記以外では、'o' になる。」にあたり、シリアライズされたデータそのものにクラス名を含みます(←重要)。

実際にコードを見てみる

キャッシュストアとしてRedisを使う場合のコードを追ってみます。

Rails4.2〜5.1時点ではredis-storeはRailsに取り込まれておらず、redis-rails を使うことになります。gem内部で redis-store redis を使っているのでそのコードを追ってみます。

以下が実際にシリアライズしている部分です。 https://github.com/redis-store/redis-store/blob/v1.6.0/lib/redis/store/serialization.rb

      def set(key, value, options = nil)
        _marshal(value, options) { |v| super encode(key), encode(v), options }
      end

...snip...

      private
        def _marshal(val, options)
          yield marshal?(options) ? @serializer.dump(val) : val
        end

@serializer というのはなんでしょう。 redis を継承してるのでそちらも見ます。

https://github.com/redis-store/redis-store/blob/614d79c80f25a78601277176d0ed7a148f11a636/lib/redis/store.rb#L30

      @serializer = options.key?(:serializer) ? options[:serializer] : Marshal

@serializer はデフォルトでは Marshal になるようです。つまり、 Marshal.dump の内容が格納されることになります。

Rails5.2ではredis storeが標準に取り込まれていますが、そこでも実装は同じようです。

https://github.com/rails/rails/blob/5-2-stable/activesupport/lib/active_support/cache/redis_cache_store.rb#L431-L437

        def serialize_entry(entry, raw: false)
          if raw
            entry.value.to_s
          else
            Marshal.dump(entry)
          end
        end

実際に格納されているデータを見てみる

Rails5.2の環境で、こんな属性を持った簡単なActiveRecordインスタンスをキャッシュしてみます。

#  id         :integer          not null, primary key
#  code       :string(255)      not null
#  name       :string(255)      not null
#  created_at :datetime         not null
#  updated_at :datetime         not null

こんなデータです。

[4] pry(main)> category = Category.last

+----+------+------+---------------------------+---------------------------+
| id | code | name | created_at                | updated_at                |
+----+------+------+---------------------------+---------------------------+
| 4  | news | ニュース | 2018-12-14 18:12:54 +0900 | 2018-12-14 18:12:54 +0900 |
+----+------+------+---------------------------+---------------------------+
1 row in set

このActiveRecordインスタンスMarshal.dump した中身を見てみましょう。(見づらいので適宜改行をいれています

[6] pry(main)> Marshal.dump category
=> "\x04\bo:\rCategory\x0F:\x10@attributes
o:\x1EActiveModel::AttributeSet\x06;\x06U:#ActiveModel::LazyAttributeHash[\n}\nI\"\aid\x06:\x06ET
o:\x1FActiveModel::Type::Integer\t:\x0F@precision0:\v@scale0:\v@limiti\t:\v@range
o:\nRange\b:\texclT:\nbeginl-\a\x00\x00\x00\x80:\bendl+\a\x00\x00\x00\x80I\"\tcode\x06;\tT
o:HActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlString\b;\v0;\f0;\ri\x01\xFFI\"\tname\x06;\tT@\x10I\"\x0Fcreated_at\x06;\tTU:JActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter[\t:\v__v2__[\x00[\x00
o:!ActiveRecord::Type::DateTime\b;\vi\x00;\f0;\r0I\"\x0Fupdated_at\x06;\tTU;\x14[\t;\x15[\x00[\x00@\x17
o:\x1DActiveModel::Type::Value\b;\v0;\f0;\r0{\nI\"\aid\x06;\tTi\tI\"\tcode\x06;\tTI\"\tnews\x06;\tTI\"\tname\x06;\tTI\"\x11\xE3\x83\x8B\xE3\x83\xA5\xE3\x83\xBC\xE3\x82\xB9\x06;\tTI\"\x0Fcreated_at\x06;\tTIu:\tTime\r\xC9\xAD\x1D\xC0\x00\x00`3\x06:\tzoneI\"\bUTC\x06;\tFI\"\x0Fupdated_at\x06;\tTIu;\x18\r\xC9\xAD\x1D\xC0\x00\x00`3\x06;\x19I\"\bUTC\x06;\tF{\x00{\x06@\n
o:)ActiveModel::Attribute::FromDatabase\t:\n@name@\n:\x1C@value_before_type_cast0:\n@type@\v:\x18@original_attribute0{\n@\no;\x1A\n;\e@\n;\x1Ci\t;\x1D@\v;\x1E0:\v@valuei\t@\x0Fo;\x1A\n;\e@\x0F;\x1C@!;\x1D@\x10;\x1E0;\x1FI\"\tnews\x06;\tT@\x11o;\x1A\n;\e@\x11;\x1C@#;\x1D@\x10;\x1E0;\x1FI\"\x11\xE3\x83\x8B\xE3\x83\xA5\xE3\x83\xBC\xE3\x82\xB9\x06;\tT@\x12o;\x1A\n;\e@\x12;\x1C@&;\x1D@\x13;\x1E0;\x1FU: ActiveSupport::TimeWithZone[\bIu;\x18\r\xC9\xAD\x1D\xC0\x00\x00`3\x06;\x19I\"\bUTC\x06;\tFI\"\nTokyo\x06;\tTIu;\x18\r\xD2\xAD\x1D\xC0\x00\x00`3\x06;\x19I\"\bUTC\x06;\tF@\x18o;\x1A\n;\e@\x18;\x1C@);\x1D@\x19;\x1E0;\x1FU; [\bIu;\x18\r\xC9\xAD\x1D\xC0\x00\x00`3\x06;\x19I\"\bUTC\x06;\tF@8Iu;\x18\r\xD2\xAD\x1D\xC0\x00\x00`3\x06;\x19I\"\bUTC\x06;\tF:\x17@aggregation_cache{\x00:\x17@association_cache{\x00:\x0E@readonlyF:\x0F@destroyedF:\x1C@marked_for_destructionF:\x1E@destroyed_by_association0:\x10@new_recordF:\x1E@_start_transaction_state{\x00:\x17@transaction_state0"

先に見た、マーシャルデータのフォーマットと照らし合わせて雰囲気で見てみると ActiveModel::Attribute::FromDatabase だとか ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter というクラス名が見えてくると思います。 ActiveRecordインスタンス以外にも、attiributeとして保持している関連オブジェクトのインスタンスのオブジェクトも一緒に格納されていて、キャッシュからロードしてオブジェクトを復元するときは、ここに文字列として保存されているクラス名をもとに各オブジェクトが復元されます。

オブジェクトをキャッシュから復元する時の問題と回避策

マーシャルデータを復元するときに、そこに書かれているクラス名が存在しない環境でロードすると、クラスが見つからなくて当然エラーになります。

どういうときに起こるかというと例えばgemのバージョンアップを行った前後で内部クラスの構造が変化すると、クラス名が変わることはありえます。 具体的には、Railsのマイナーバージョンのアップデートを行うと、active_record以下のクラス構造が変更されていることはよくあるようです。 つまりいまのバージョンでは ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter というクラスがあるけど、次のバージョンでは別の名前になってる、ということがありえます。

しかし、ActiveRecordをキャッシュしたい要件としては、DBへの読み込み回数を抑えるのが目的なので、ActiveRecordそのものをキャッシュしないようにする、というのは難しいシーンも有るかと思います。

この場合の回避策としては、キャッシュのネームスペースを変えてしまって、完全にバージョン間で違うキャッシュ空間を利用するようにする、というのが回避策になるかと思います。 これをやるためには、キャッシュが吹っ飛んだ瞬間DBが耐えられなくなってサービスダウンしてしまう、ということがないように適切にインデックスを張っておくなど、普段からパフォーマンスにも気を使っておくのが必要となります。

別の対応策としてはキャッシュのTTLよりも長い時間サービスを停止してリリースするという方法もあるかと思いますが、サービス断を伴う場合はビジネス的にもユーザ的にも配慮が必要になり調整等に時間が取られてしまうので、できればサービス断をせずにバージョンアップできる方法をとりたいですよね。

さいごに

キャッシュってすごく便利で今日のウェブサービスで使わないことはほとんど無いと思うんですが、実際に中に何が入っててどういう問題が起きうるの?というところを調べて見たので今日はその点について書いてみました。参考になれば幸いです。

fetch_mldata('MNIST original') が出来ない

from sklearn.datasets import fetch_mldata
mnist = fetch_mldata('MNIST original')

 

とやっても mldata.org が落ちてて全然ダウンロードできないんだけどぉっていう時

mldata.org is down (for good?) · Issue #8588 · scikit-learn/scikit-learn · GitHub

を参考に mnist-original.mat を直接ダウンロードして ~/scikit_learn_data/mldata/ に置けばOK

このディレクトリがキャッシュとして利用されるので fetch_mldata でデータセットが読み込めます。