Dalli で Memcached の Multi-Set を使って高速化する
モンスターストライク(以下、モンスト)のバックエンドでは、Memcached に強く依存しています。主に、DB(MariaDB)から取得したデータを保存して、読み込み側のパフォーマンス改善に利用しています。
Memcached には Quiet モードという仕組みがあり、これを使うことでキャッシュの取得や保存のリクエストをまとめて送ることができます。
モンストの場合、非常に多くのデータをやり取りするため、この機能がかなり効いてきます。今までは、キャッシュの取得にだけ利用していましたが、保存にも使うことでログイン処理の高速化を実現した、という話を書きます。
fetch_by_idx
今回主題になる fetch_by_idx
というのは、Second Level Cache という OSS を利用して独自で拡張したキャッシュの仕組みです。
SLC は User.find(1)
のような主キーに対するクエリをキャッシュしてくれる仕組みです。
インデックスへのキャッシュ(例えば user_id
とか)も SLC のキャッシュを使ってキャッシュの効率をあげたいと考えました。そこで、インデックスから主キーだけを引いてそれをキャッシュし、主キーからは SLC のキャッシュを引くような機能が fetch_by_idx
です。
例えば、UserItem
というユーザーのアイテム所持数を保存したモデル(テーブル)があったとします。user_id
というフィールドでどのユーザーのアイテムかを保持し、user_id
に対してインデックスを貼ります。このインデックスを使った SELECT の結果を、user_id
をキャッシュキーに含ませてキャッシュに保存する方法もあります:
# ざっくりとした疑似コードです
class UserItem < ActiveRecord::Base
second_level_cache expires_in: 2.days
after_commit :expire_cache
def self.get_all_by_user_id(user_id)
cached = cache_get("UserItem.get_all_by_user_id#{user_id}")
return cached unless cached.nil?
records = self.where(user_id: user_id).to_a
cache_set("UserItem.get_all_by_user_id#{user_id}", records)
records
end
def expire_cache
cache_delete("UserItem.get_all_by_user_id#{user_id}")
end
end
そうすると、特定のアイテム数が増減するたびにこのキャッシュを削除する、つまり SELECT しなおす必要があります。レコード数が大したことない場合はそれでもよく、そうしているユーザーデータもありますが、モンストでのアイテムはかなりの種類がある上にアイテムの増減も激しいので、キャッシュが効きづらくなってしまいます。
fetch_by_idx は、レコードに対するキャッシュ自体は SLC で主キー毎に保存し、主キーのリストをインデックス(今回の場合は user_id )を含むキャッシュキーでキャッシュします。その後取得できた主キーのリストをもとに SLC のキャッシュキーを作り、それを Memcached へ問い合わせて、キャッシュヒットしなかったものだけを DB へ SELECT します。この「Memcached へ問い合わせる」時に Quiet モードを利用して、複数キーを一気に投げて一気に取得することが可能です。これにより Memcached との往復を減らしていました:
def fetch_by_idx(**argv)
# インデックスから主キーのリストを取得
ids = fetch_ids_by_idx(**argv)
return [] if ids.blank?
# 主キーから SLC のキーへ変換
slc_key_to_id_hash = ids.each_with_object({}) { |id, hash| hash[second_level_cache_key(id)] = id }
# 一回の Memcached との往復で一気に取得する
raw_datas_exist_in_slc = SecondLevelCache.cache_store.read_multi(*slc_key_to_id_hash.keys)
# 自分でデコードしないといけない
records_exist_in_slc = raw_datas_exist_in_slc.values.map { |raw_data| RecordMarshal.load(self, raw_data) }
# キャッシュヒットしたものと、そうでないものを分ける
ids_exist_in_slc = slc_key_to_id_hash.values_at(*raw_datas_exist_in_slc.keys)
ids_not_exist_in_slc = ids - ids_exist_in_slc
if ids_not_exist_in_slc.present?
# キャッシュヒットしなかったものは DB から取得(割愛)
records_not_exist_in_slc = ...
# DB から取得したものを SLC に書き込む
records_not_exist_in_slc.each(&:write_second_level_cache)
end
# 主キーでソートして返す
(records_exist_in_slc + records_not_exist_in_slc).sort_by(&:id)
end
弱点
実は、、、上のコードには弱点があります。全くキャッシュヒットしなかった場合に、records_not_exist_in_slc.each(&:write_second_level_cache)
でキャッシュセットをするために愚直に何回も Memcached との往復をしちゃう点です。前述した通り、所持アイテム数のようなデータだと、この Memcached との往復が無視できないものになります。
キャッシュセットも一気にやってしまう
キャッシュを読む時みたいに、キャッシュをセットするのも Quiet モードを使って一気にやってしまえばいいのでは、と考えました。
モンストでは Memcached クライアントに dalli というライブラリを使っています。dalli には、一気にキャッシュを読むためのメソッド(get_multi)はありましたが、それのセット版はなさそうだったので、get_multi をもとにして自作しました。
Quiet モード
バイナリプロトコルの場合は、GET ・SET と言うオペコードの代わりに GETKQ・SETQ と言うのを使います。Q が Quiet です。GETKQ は GET Key Quiet で、レスポンスにキーを含んでくれます(ので、キーと値の対応付けが取得できます)。最後だけ GET(GETK)や SET を送ることで一気に送ったリクエストの終了だと判断してくれます(dalli の実装見る感じ、Qじゃなければ何でも良いようです)。
メタテキストプロトコルの場合は、Quiet モードを表すフラグがあるので、それを立てて送るだけであとはバイナリプロトコルと同じです。
実はあった
dalli のコードをよく読み直してたら、任意の処理に対して Quiet モードを利用するメソッドがありました・・・それを使うと、こんなふうに get_multi のセット版を実装できます:
# こんな感じに実装できる
module Dalli
class Client
...
# @param kvs [Hash<String, String>] キャッシュにセットしたいキーと値のペア
# @param expires_in [Integer] キャッシュの生存期間(秒)
def set_multi(kvs, expires_in: nil)
return {} if kvs.blank?
self.quiet do
kvs.each { |key, value| self.set(key, value, expires_in) }
end
end
...
end
end
とはいえ、、、これは少しだけ効率が良くありません。dalli は、Memcached を複数台設定してクラスタ化することができます。あるキャッシュがどの Memcached に保存されるかは、キャッシュキーから決定されます。quiet
を使わずに、プリミティブに実装されている get_multi では、引数で与えられたキャッシュキーを保存先の Memcached でグルーピングしてから、保存先の Memcached ごとにリクエストを投げます。対して quiet
メソッドは都度向き先を切り替えているので効率が悪いのです。
実際、完全に自作した set_multi と、quiet
を使った set_multi でベンチマークを開発環境で簡単にとった結果、倍ぐらいの差がありました:
user system total real
each SLC: 0.375508 0.070099 0.445607 ( 15.845477)
set_multi: 0.109335 0.004101 0.113436 ( 0.138252)
quiet method: 0.268792 0.031187 0.299979 ( 0.346302)
これは開発環境で、1ユーザーにモンストの全アイテムを持たせて set_multi を何回かした結果です。一番上は、ただ each(&:write_second_level_cache)
した場合で、真ん中が完全自作、下が quiet
を使った場合です。開発環境は Memcached を2台しか用意してないので、何十台もある本番だともう少し差が小さくなるような気もしますが、完全自作の方が速いはずです。
と言うことで、(作ってしまったし)完全自作を使うことにしました。
リリースした結果・・・
ログイン API が 100ms ほど速くなっていました 🎉
キャッシュが全くない状態でのキャッシュセットはログインぐらいでしかされないので、ログイン以外は変化がないのですが。
Discussion