読者です 読者をやめる 読者になる 読者になる

RailsアプリケーションにおけるModelキャッシュの実装

Ruby Ruby on Rails

こんにちは、バックエンドエンジニアのjoeです。主にAPIを担当しています。 VASILYのAPIでは、速度向上のためにModelオブジェクトをキャッシュしています。 最近、Modelキャッシュの仕組みを実装したので、その実装方法を紹介します。また、既存ライブラリとの比較についても書きたいと思います。

Modelキャッシュとは

Modelキャッシュを簡単に言うと、下記の結果をキャッシュすることです。

> Item.find(1)
=> #<Item:0x007fdfe398a678>

このように、1レコード単位のActiveRecordをキャッシュすることを本記事ではModelキャッシュと呼びます。ActiveRecordをキャッシュすることで、データベースへの読み込み回数を減らし、レスポンス速度を向上させることができます。

既存ライブラリの紹介と問題点

Modelキャッシュを実現できるGemとして、IdentityCacheというShopify社の優秀なライブラリが存在します。 ActiveRecordをキャッシュする際、has_manyhas_oneなどのリレーションで紐付けられたレコードも同時にキャッシュで引けたり、LRUキャッシュで実装してあったりと機能が豊富です。

その他にも、

  • CachingPolymorphicAssociations(複数外部キーの関連性の指定)
  • CachingAttributes(キャッシュするカラムの指定)
  • SecondaryIndexes(primary-key以外での検索)
  • MemoizedCacheProxy(メモ化機能)

等があり、様々な機能が提供されています。

ただし、削除が失敗して情報が更新されないことがあります。(IdentityCacheの説明文では、「プロセスが死んだり、ネットワークの不調などで削除が失敗することがあるので一貫性を保証できない。大事な場所では使うな」と書いてあります。)

This is because there is no way IdentityCache is ever going to be 100% consistent. Processes die, execeptions happen, and network blips occur, which means there is a chance that some database transaction might commit but the corresponding memcached DEL operation does not make it. This means that you need to think carefully about when you use fetch and when you use find. For example, at Shopify, we never use any fetchers on the path which moves money around, because IdentityCache could simply be wrong, and we want to charge people the right amount of money.

情報の更新が失敗することや、現時点で使っていない機能が多かった事から、今回はModelオブジェクトをキャッシュしてシンプルに主キーでキャッシュを引くだけの実装を試してみました。

Modelキャッシュの仕組み

仕組みはシンプルで、ActiveRecordオブジェクトの必要な部分をキャッシュするだけです。 使うときは、キャッシュした内容をもとにActiveRecordオブジェクトを復元して使います。

ActiveRecordオブジェクトに復元することで、レコードの要素に簡単にアクセスできたり、Modelに実装してあるメソッドを使えるのでとても便利です。

f:id:vasilyjp:20161129122718p:plain

例えばModelキャッシュの仕組みをfetchというメソッド名で実装したとしたら下記の例のようにActiveRecordと全く同じ用途で使えます。

class User < ActiveRecord::Base
  include ModelCache
  
  has_many :my_book
  
  def young?
    age < 18
  end
end
>user = User.fetch(1)
=> #<User:0x007fdfe398a678>  # UserのActiveRecordオブジェクト
>user.id
=> 1
>user.age
=> 11
>user.young?
=> true
>user.my_book
=> [#<Book:0x00394fd87987639>, #<Book:0x007fdfe398a679>]

実装の概要

下記の実装例ではActiveRecordの必要な情報を抜き出したものをcoder, ActiveRecordそのままのオブジェクトをrecordという変数に置いています。

# ./lib/model_cache.rb
module ModelCache
  extend ActiveSupport::Concern
  
  # 各レコードが更新された際のキャッシュ削除
  included do |base|
    base.after_commit do |_record|
      self.class.expire_model_cache(id)
    end
  end
    
  module ClassMethod
    def fetch(id)
      coder = memcache.get(key)
      
      # coderをrecordに変換して返す
      return record_from_coder(coder) if coder.present?
      
      record = find(id)
      
      # recordをcoderに変換してキャッシュする
      coder = coder_from_record(record)
      memcache.set(coder)
      
      record
    end
    
    # 複数IDを受け取るメソッド
    def fetch_multi(*ids)
      # キャッシュにキーがないidは一括でデータベースに問い合わせて渡されたid順に並べてActiveRecordオブジェクトを返す
    end
    
    # ActiveRecordの必要最低限に絞る
    def coder_from_record(record)
      {
        attributes: record.attributes_before_type_cast.dup,
        record_class: record.class
      }
    end
    
    # ActiveRecordの復元
    def record_from_coder(coder)
      klass = coder[:record_class]
      klass.instantiate(coder[:attributes].dup)
    end
   
    
    def expire_model_cache(id)
      # キャッシュの削除
    end
  end
end

あとは実際にincludeするのみです。ActiveRecordオブジェクトを復元する部分とActiveRecordから必要な情報を取り出す部分以外はキャッシュして取り出すだけの実装なので、難しくありません。 (今回はざっくり書きましたが、後日コードを公開します。)

Modelをキャッシュして主キーで引く(単一のキーと複数のキーが存在する)という用途で必要なのはこれだけです。 様々な機能を削りましたが、アプリケーションはこれだけでも正常に動きます。

既存ライブラリと自前実装のメリット・デメリット

 IdentityCache 自前実装
メリット ・できることが多い
・LRUキャッシュで実装されている
・好きなように拡張しやすい
・キャッシュキーを自由に決定できる
・シンプルなのでバグが追いやすい
デメリット ・キャッシュの削除がたまに失敗する
・コードが複雑
・やりたいことが増えるとそこそこの工数がかかる

既存ライブラリとの速度比較

IdentityCacheと自前実装の速度を比較してみました。

# 下記を1000回実行した平均を計測

ids = [1..100]

ids.each do |i|
  Test.fetch(i)
end

Test.fetch_multi(ids)

キャッシュバックエンドをRails.cacheに揃えた場合

ほんのすこしですが機能を削った分速くなっています。

内容 user(ms) system(ms) total(ms) real(ms)
自前実装(single) 20 10 30 (26.562)
IdentityCache(single) 20 10 30 (30.014)
自前実装(multi) 20 0 10 (21.368)
IdentityCache(multi) 20 0 20 (24.547)

自前実装のキャッシュバックエンドにarthurnn/memcachedを使った場合

キャッシュバックエンドをRails.cacheからarthurnn/memcachedにするとmultiで引く際の速度は2倍になります。singleの方は1.2倍程度速くなります。 自前実装なら簡単にキャッシュバックエンドを変えることができるのもメリットの一つです。

内容 user(ms) system(ms) total(ms) real(ms)
自前実装(single) 20 0 20 (23.451)
IdentityCache(single) 20 10 30 (29.303)
自前実装(multi) 10 0 10 (10.967)
IdentityCache(multi) 20 10 30 (23.824)

まとめ

Modelキャッシュは機能をそぎ落とせばかなり簡単な実装で実現できます。 IdentityCacheを使うのは敷居が高い、そこまでの機能はいらないと感じている方は軽量な自前実装で試してみてはいかかでしょうか。

VASILYではバックエンドチームで一緒に開発してくれるエンジニアを募集しています。 興味がある方は以下のリンクをご覧ください。

www.wantedly.com