コレクションにおけるフラグメントキャッシュの粒度とパフォーマンスのはなし(Jbuilder編)

MERYのAndroid/iOSアプリ向けの記事一覧取得APIのキャッシュ粒度を小さくした際に生じたパフォーマンスの劣化とその対応について紹介します。

MERYのWeb APIでは少しでもレスポンスタイムを短くするために、Ruby on Rails(以下、Rails)のフラグメントキャッシュと呼ばれるViewのキャッシュ機能を使うことで、レンダリングにかかる時間を抑えています。MERYの既存のシステムでは、JSONテンプレートエンジンであるJbuilderを使用しており、このJbuilderを介してフラグメントキャッシュを行っています。

この記事では、Jbuilderにおけるコレクションのキャッシュ粒度とパフォーマンスのトレードオフを説明し、jbuilder_cache_multiによるトレードオフの解消方法を紹介します。

MERYのアプリ向けのWeb APIについて

技術スタック

  • アプリケーションサーバ
    • Ruby
    • Ruby on Rails
  • キャッシュストア
    • Memcached

Jbuilderについて

この記事を読むにあたって必要となる最小限のJbuilderの機能を紹介します。詳細については公式ドキュメントを参照してください。

Jbuilderとは

JbuilderはRailsに標準でバンドルされているJSONテンプレートエンジンです。JbuilderはJSONをレンダリングするための便利な機能を多数提供しています。Rails標準のViewテンプレートエンジンはほかにはERB(Embedded Ruby)がありますが、JSONをレスポンスとして返すWeb APIの場合は、JSONに特化したJbuilderを利用するほうが便利です。
RailsのAPIモードでJbuilderを利用するには、Gemfileの次の1行のコメントアウトを外し、Bundlerでインストールを実行します。

# コメントアウトを外す
gem 'jbuilder'
# シェル上で実行
$ bundle install

Jbuilderの基本的な使い方

以下は一番シンプルな例です。json.に続けてフィールド名と値を記述します。

json.title 'INSIDE MERY'

Jbuilderはこれを次のJSONに変換して出力します。

{ "title": "INSIDE MERY" }

もちろん、入れ子構造を作ることもできます。

json.article do
  json.title 'INSIDE MERY'
end

入れ子になったJSONが出力されます。

{ "article": { "title": "INSIDE MERY" } }

配列を扱うには json.array! メソッドを使います。@articlesは記事を表すオブジェクトの配列とします。

json.articles do
  json.array! @articles do |article|
    json.title article.title
  end
end

出力結果は次のとおりです。

{ "articles": [{ "title": "INSIDE MERY" }, ...(省略)] }

フラグメントキャッシュ

フラグメントキャッシュはViewの部分的なキャッシュのことです。

記事と著者の情報から構成されるJSONがあるとします。このJSONの著者の情報だけをキャッシュしたい場合は、Jbuilderでは次のようにします。

json.article do
  json.title @article.title
  json.cache! @article.author, expires_in: 10.minutes do
    json.author do
      json.name @article.author.name
    end
  end
end

json.cache! で囲まれた部分のデータは、キャッシュストアから取得されます。キャッシュストアにはMemcachedやRedisがよく利用されます。

コレクションのキャッシュ粒度とパフォーマンス

Jbuilderにおけるコレクションのキャッシュ粒度とパフォーマンスにはトレードオフがあります。今回は jbuilder_cache_multi を使って Jbuilder を拡張することで、この問題を解決します。

MERYの事例

MERYではタイアップ記事*1MERY編集部が制作する広告商品としての記事です。において、より多くのユーザーに記事を読んで貰えるようにABテストを実施することになりました。しかしすぐに、既存の記事一覧取得APIのキャッシュのしくみではABテストに対応できないことが判明します。当時、MERYの記事一覧取得APIのキャッシュ粒度はページ単位でした。1ページあたり20記事で構成されており、20記事分のリストをまとめてキャッシュしていました。

一方、ABテストの仕様は、同一記事に対して複数の異なるタイトルとサムネイルを用意し、それをランダムにユーザーに出し分けるものです。これは、リストに含まれる記事のタイトルとサムネイルがユーザーによって異なる可能性があることを意味します(図1)。

図1. 記事のABテスト

ABテスト以前は、全ユーザーに対して同一のタイトルとサムネイルを表示していたのでページ単位のキャッシュ粒度でも問題ありませんでしたが、これでは記事のABテストに対応できません。つまり、ABテストを行うには既存のキャッシュは粒度が大きすぎるわけです。検討を重ねた結果、ABテストの仕様はそのままにして、キャッシュの粒度をページ単位から記事単位に変更することに決めました。

Jbuilderを使ったコレクションの要素単位でのキャッシュ

Jbuilderでコレクションを要素単位でキャッシュした場合、コレクションの要素数に比例してキャッシュストアへの通信回数が増加するという問題があります。Viewのレンダリングではデータ取得の際、アプリケーションとキャッシュストアとの通信のオーバーヘッドが発生します。通信回数が多くなるほど、結果としてViewのレンダリングにかかる時間は増加します。

MERYでは当初ページ単位でキャッシュをしていたのは前述のとおりです。実際のソースコードではありませんが、同等の処理をするサンプルコードを下に示します。 “articles/#{@page}” というキーで1ページ分、つまり20記事分のデータをキャッシュストアに保存しています。この場合、”articles/#{@page}” というキー1つで20記事分のデータを取得できるので、キャッシュストアとの通信回数は1回です。

json.cache! "articles/#{@page}" expires_in: 10.minutes do
  json.articles do
    json.array! @articles do |article|
      json.title article.title
      json.thumbnail article.thumbnail
    end
  end
end


次に、ABテスト対応時にMERYで行った、記事単位でのキャッシュを行うサンプルコードを下に示します。記事ごとにキャッシュキーを持っています*2実際のキャッシュキーにはデータの更新時刻やABテストのパターン情報を含める必要がありますが、ここでは説明のために簡単にしています。。これらのキャッシュキーを使って20記事分のデータを1件ずつ取得するので、キャッシュストアとの通信回数は20回です。

json.articles do
  json.array! @articles do |article|
    json.cache! "article/#{article.id}", expires_in: 10.minutes do
      json.title article.title
      json.thumbnail article.thumbnail
    end
  end
end

当初、著者はこの通信回数の影響は無視できる程度のものと考えていました。しかし計測の結果、キャッシュストアへのデータ取得時間が単純に20倍近く増加しており、エンドポイントによっては、サーバ側での処理時間の半分がキャッシュストアとの通信の時間になっていました。この影響は無視できないと判断し、さらなる改修を行いました。

jbuilder_cache_multiを使ってJbuilderを拡張する

jbuilder_cache_multiはJbuilderを拡張するgemです。jbuilder_cache_multiを使えば、複数のキャッシュキーを使った場合でもキャッシュストアからデータを一括で取得できます。これを利用することで、Jbuilder単体でのコレクションキャッシュで生ずる「コレクションの要素数に比例してキャッシュストアへのアクセス回数が増加する」問題が解決されます。アプリケーションとキャッシュストアとの通信回数を抑えることができ、結果的にレンダリング全体の処理時間が短くなります。
Railsでjbuilder_cache_multiを利用するには、Jbuilderと同様にGemfileにgem名を追記し、Bundlerを使ってインストールします。

# Gemfileに追記
gem 'jbuilder_cache_multi'
# シェル上で実行
$ bundle install


jbuidler_cache_multiでコレクションのキャッシュを行うには json.cache_collection!メソッドを使います。jbuidlerを単体で使った場合のコレクションキャッシュのソースコードをjbuilder_cache_multiを使ったソースコードに直したものを次に示します。

json.articles do
  json.cache_collection! @articles, expires_in: 10.minutes, key: proc { |article| "article/#{article.id}" } do |article|
    json.title article.title
    json.thumbnail article.thumbnail
  end
end

ここではkeyにキャッシュキーを生成するprocを渡し、これによって生成されたキーを使って記事ごとにキャッシュをしています。jbuilder_cache_multiは内部でRails.cache.fetch_multiメソッドを使って、複数のキャッシュキーに紐づくデータをキャッシュストアから一括で取得しています。それによって通信回数を抑えることができます。

まとめ

MERYのWeb APIを事例にJbuilderにおけるコレクションキャッシュの粒度とパフォーマンスのトレードオフについて説明しました。また、jbuilder_cache_multiを用いることでトレードオフを解消できることを紹介しました。まとめは次のとおりです。

  • アプリケーションとキャッシュストアとの通信のオーバーヘッドは軽視できない
  • なるべく通信回数が少なくなるようにキャッシュの粒度を保つ(必要以上に粒度を小さくしない)
  • キャッシュの粒度を小さくする必要が出た場合は、jbuilder_cache_multiやその他データ一括取得方法を検討する

   [ + ]

1.MERY編集部が制作する広告商品としての記事です。
2.実際のキャッシュキーにはデータの更新時刻やABテストのパターン情報を含める必要がありますが、ここでは説明のために簡単にしています。
nakasone

nakasone

    サーバサイドエンジニアです。

    関連記事
    MERYの記事作成ツールをNuxt.js × Atomic Designで作り直した話(機能編)
    トップへ戻る