2015年8月28日
Railsアプリを66%スピードアップ ― Railsキャッシュの完全ガイド
本記事は、原著者の許諾のもとに翻訳・掲載して おります。
(訳注:2016/3/2、頂いた翻訳フィードバックをもとに記事を修正いたしました。)
Railsアプリでのキャッシングは、「たまに夕食を一緒にするけれど、本当はもっと頻繁に一緒にいるべき友達」に少し似ています。パフォーマンスをまじめに考えるRailsアプリのほぼ全てで、もっとキャッシングを使えるはずですが、ほとんどのRailsアプリでは、完全にキャッシングを避けています。それでも普通は、Railsで高速なサーバ応答を達成するための唯一の道は、キャッシングの知的な利用なのです。約250msの応答時間を、簡単に50~100msに高速化できます。
定義についての注意 ― この記事は、アプリケーション層のキャッシングのみを対象としています。HTTPキャッシング(これは全く別の難物で、あなたのアプリケーションに実装する必要はありません)は、別の機会で扱いましょう。
するべきキャッシングをしない理由
私たち開発者は、もともと、エンドユーザとは違います。私たちは、ソフトウェアやウェブアプリケーションで見えるものの裏側で何が起こるのかをよく理解しています。典型的なウェブページがロードされるときは、大量のコードが実行され、データベースクエリが実行され、ときにはHTTP上でサービスがpingされるということを知っています。これには時間がかかります。コンピュータと対話するときは、コンピュータが答えを返すまでには、少し間があるという考えに慣れています。
でも、エンドユーザは全く違います。彼らにとって、ウェブアプリケーションは魔法の箱です。1エンドユーザは、その箱の中で何が起こるかを知らないのです。 ^(1) 特に最近、 エンドユーザは、魔法の箱が、ほとんど瞬間的に応答してくれることを期待します。 ほとんどのエンドユーザは、欲しいものを何でも、 今すぐ ウェブアプリから取り出したいのです。
1: 開発者から見たエンドユーザ
これは、分かりきったことのように見えます。それでも、開発者はユーザの都合と製品の仕様に、厳しいパフォーマンス要件を決して設定しません。たとえサーバ応答時間を簡単に測定して目標にすることができても、また、ユーザが高速なウェブページを求めていることを知っていても、「このページは100ms以内に応答しなければならない」というような、特別なサイトや機能を扱いたくないのです。その結果、次のユーザの都合、次の素晴らしい機能を優先して、パフォーマンスはしばしば棚上げにされます。パフォーマンスの負債は、技術的な負債と同様に、あっというまに積み上がります。誰かが新しいリクエストを行うたびに アプリケーションが根本的に炎上するようになるまでは、パフォーマンスは、決して優先事項になりません。
さらに、キャッシングは必ずしも簡単ではありません。 キャッシュ の 期限切れ は、 特に混乱を招く問題になります。 キャッシング動作のバグは、インテグレーション層で起こる傾向にあり、この層は、アプリケーションで最もテストが疎かになりがちな層です。このことから、キャッシングのバグは目立たず、発見と再現が難しくなるのです。
さらに悪いことに、Railsの世界では、 キャッシングのベストプラクティスは頻繁に変わるように思われます 。キーベースの何とか。ロシアンモールキャッシングとか、ドールとか。
キャッシングの恩恵
では、なぜキャッシングを使うのでしょうか。答えは簡単です。スピードです。Rubyでは、簡単にスピードが得られません。その理由は、まず、この言語はあまり速くないからです。 ^(2) 各リクエストでのRubyの実行を少なくする ことによってスピードを得る必要があります。その最も簡単な方法は、キャッシングを使うことです。処理を行ったら、結果をキャッシュし、キャッシュした結果を将来のために保存します。
2: ベンチマークゲーム でのRubyとJavaScriptのパフォーマンス比較
でも、実際にどれぐらい速くなければならないのでしょうか。
コンピュータが最初に開発された1960年代から、人間とコンピュータのインタラクションのためのガイドラインが知られています 。ユーザが、サイトがロードされるのを待たずに、サイトで「 自由にページ間を移動している 」ように感じるための応答時間の閾値は、1秒以下です。ユーザがサイトをクリックするかインタラクションした瞬間から、その対話が完了する(DOMが描画を終える)までは、1秒の 応答時間 ではなく、1秒の” 表示のための “秒数です。
1秒の”表示のための”秒数は、それほど長い時間ではありません。まず、ネットワーク遅延に約50msを想定します(これは机上の想定です。モバイルでの遅延は別の議論になります)。次に、JSおよびCSSリソースをロードして、レンダーツリーを構築し、描画するために、さらに150msを計上します。最後に、ダウンロードしたJavaScriptを全て実行するために、 少なくとも 250msを想定し、また、JavaScriptに、DOMに結びつく機能が多く含まれる場合は、より多くの時間を想定します。このように、サーバがどのぐらいの時間で応答すべきかを考える前に、すでに約500msの時間がカウントされています。 ウェブページを表示するのに常に1秒を達成するには、サーバ応答は300ms未満を維持しなければなりません。 私が別の記事で扱ったように 、100msのウェブページ表示時間を得るためには、サーバ応答は、約25~30msを維持する必要があります。
各リクエストに300msという値は、特に、SQLクエリとActiveRecordの使用に熱心な開発者なら、Railsアプリでキャッシングを使わずに達成することは不可能ではありません。しかし、キャッシングを使えば、はるかに簡単です。私が目にしてきた大多数のRailsアプリは、常に応答に300ms以上かかるページをアプリ内に少なくとも半ダースかかえているので、キャッシングの恩恵を受けられるはずです。さらに、Railsに加えて、人気のある電子商取引フレームワークのSpreeのような重いフレームワークを使うと、それぞれのリクエストに追加される余分のRuby実行が原因となって、応答が著しく遅くなる可能性があります。DeviseやActiveAdminのような人気のある重いgemでさえ、それぞれのリクエストサイクルに数千行ものRubyのラインを追加します。
もちろん、キャッシングが助けにならない部分も、アプリの中に必ずあります。例えば、POSTエンドポイントなどです。POSTやPUTへの応答でアプリが行っていることが非常に複雑な場合は、おそらくキャッシングは助けにならないでしょう。しかし、その場合は、処理をバックグラウンド処理に移動させることを考えてみてください(後日、ブログ記事にします)。
始めに
まず、 キャッシングに関するRailsの公式ガイド は、Railsの様々なキャッシングAPIの技術的詳細を参照するには最適です。まだ読んだことがないならば、ぜひじっくり全体に目を通すことをおすすめします。
後で、Rails開発者として利用可能な、様々なキャッシングのバックエンドについて議論したいと思います。それぞれに長所と短所があります。スピードは遅いけれどホストとサーバ間での共有が提供されるものもあれば、速いけれど全く、他のプロセスとさえ、キャッシュを共有できないというものもあります。皆のニーズは同じではありません。つまり、デフォルトのキャッシュストア、 ActiveSupport::Cache::FileStore
もOKなのですが、このガイドで使われている方法(特に、キーベースのキャッシュ期限切れ)に倣うなら、やはり別のキャッシュストアに変更する必要があります。
キャッシングを初めて行う人たちへのアドバイスですが、 アクションキャッシングとページキャッシングは無視しましょう。 これら2つのテクニックを使う状況というのは非常に特殊なので、Rails 4.0からは除かれています。それよりも、これから説明するフラグメントキャッシングを使いこなせるようにするのがおすすめです。
パフォーマンスのプロファイリング
ログを読む
さて、キャッシュストアのセットアップも済み、準備ができました。けれども、何をキャッシュすればいいのでしょうか?
ここでプロファイリングの出番です。アプリケーションのどのエリアがパフォーマンスのホットスポットなのか、「闇雲に」推察しようとするよりも、プロファイリングツールを起動し、ページのどの部分が遅いのか教えてもらいましょう。
この仕事をするツールで気に入っているのは、優秀な rack-mini-profiler です。 rack-mini-profiler
は、一定のサーバ応答の間、全ての時間がまさにどこに費やされているのかを1行1行解析し、内訳を示してくれます。
しかし、もし面倒であれば、 rack-mini-profiler
やその他のプロファイリングツールさえ使わなくてもよいのです。Railsはログのボックスからページ生成にかかる合計時間を提供してくれるからです。 ^(3) それはこのように表示されます。
Completed 200 OK in 110ms (Views: 65.6ms | ActiveRecord: 19.7ms)
3:
合計時間(この場合は110ms)は重要です。Viewsで費やされた時間は、テンプレートファイル(index.html.erbなど)で費やされた時間の総計です。しかし、ActiveRecord::Relationsのデータのロードがどれだけ遅延するかによって、これは少々ミスリードになり得ます。もし、コントローラ内でActiveRecord::Relationを用いてインスタンス変数を定義して( @users = User.all
のように)いながら、ビュー内で結果を使い始める(例えば @users.each do
など)までその変数に何も操作を行わなかった場合、そのクエリ(と、ActiveRecordオブジェクトへの具象化)でかかる時間はViewsで費やされる時間に含まれます。ActiveRecord::Relationsの遅延ロードとは、実際に結果にアクセスされる(たいていビューにおいて)まで、データベースクエリは実行されないということです。
ここでのActiveRecordの秒数もミスリードです。Railsのソースを読む限りで分かるのは、この数字はActiveRecordにおいてRubyが実行される(クエリを作り、クエリを実行し、クエリ結果をActiveRecordオブジェクトに変える動き)際に費やされた時間ではなく、データベースを問い合わせるためだけに要した時間(データベース内でかかった時間)です。時々、とりわけ多くのローディングを求める非常に複雑なクエリを使う場合、クエリ結果をActiveRecordオブジェクトに変換するのに 多くの 時間がかかってしまい、さらにここのActiveRecordの数字には反映されないこともあり得ます。
それでは、残りの時間はどこに行ったのでしょうか? たいていの場合、その行き先はほとんどがRackミドルウェアとコントローラコードです。しかし、リクエストの間、時間が 正確には どこに使われたか、ミリ秒ごとの内訳を得るには、 rack-mini-profiler
と、 flamegraph
エクステンション ^(4) が必要になってきます。それらのツールを使うことで、リクエストの間、全てのミリ秒がどこに費やされたか、1行ごとに分かります。現在、私は rack-mini-profiler
の利用ガイドを作っているところです。完成したらお知らせしますので、興味のある人は、 私のニュースレターに登録してください(ページ右下から) 。
4: rack-mini-profilerにおけるフレームグラフ
本番モード
Railsアプリケーションのパフォーマンスをプロファイルする時は 常に、本番モードで行っています。 もちろん、本番で、ということではなく、 RAILS_ENV=production
を使っているという意味です。本番モードで実行すると、自身のローカル環境がエンドユーザの体験に近くなるようにしてくれるだけでなく、開発モードにおいてRailsリクエストのスピードをことごとく大幅に下げてしまう2つの要素、コードのリロードとアセットコンパイルを無効にしてくれます。本番環境設定を完璧に模倣するDockerが使えるならさらに良いでしょう。例えばHerokuを利用しているなら、Herokuが最近リリースしたDockerイメージが便利です。ただ、たいていの仮想化は本番環境に近づけるのに不必要なステップであることが多いのですが。多くの場合、Railsサーバを本番モードで実行することだけを気をつければよいのです。
簡単に復習しましょう。ローカルマシンでRailsアプリケーションを本番モードで走らせる際にしなければならないことは下記です。
export RAILS_ENV=production
rake db:reset
rake assets:precompile
SECRET_KEY_BASE=test rails s
加えて、 セキュリティ、プライバシー関連の懸念がない限り、私はいつも本番データのコピーでテストします 。開発中のデータベースクエリ(User.allのような)は、100やそこら程度のサンプル行を返してくることが非常に多いのですが、本番では、サイトをクラッシュさせ得る100,000行もの結果が吐き出されるからです。本番データを使うか、シードデータをできる限り実際に近い形にして使いましょう。このことは includes
やローディング要求の多いRails機能を広範囲にわたって使う場合は 特に 重要です。
目標を設定する
最後に、 自身のサイトの最大許容平均応答時間(MAART)を設定する ことをおすすめします。パフォーマンスの素晴らしいところは、多くの場合、測定が可能だということです。そして測れるものは、管理が可能です。2種類のMAARTが必要かもしれません。開発用ハードウェアを使い開発モードで達成できるもの、それから本番ハードウェアを使って本番モードにおいて出る数字です。
仮想化によってCPUとメモリアクセスを制御し、完璧に1対1の本番/開発の環境のセットを用意できるのであればよいですが、そうでないのならパフォーマンス結果をそれら2つの環境にわたって再現することはできません(近いものを得ることはできますが)。しかし、それで問題ありません。細かい点をつつき回さないことです。ただ、ページパフォーマンスがおおむね正確であることを確認してください。
例えば、 以前投稿したように 、100ms”表示のための”ウェブアプリケーションを作りたいとします。そのためにはサーバ応答は25~50msに抑えなければなりません。そこで開発モードでのMAARTは25msに設定し、本番では50ms程度の設定に緩めることにします。私の開発マシンはHeroku dyne(私の基本的なデプロイ環境)よりも速いため、本番には少し余分に時間を足しておきます。
最大許容平均応答時間について、自動的にテストしてくれるツールの存在は今のところ知りません。現状では、ベンチマークツールを使って手動で行う必要があります。
Apache Bench
では、開発環境において、サイトの実際の平均応答時間をどのように判断するのでしょうか。ここまで説明したのは、ログから応答時間を読み取る方法だけです。すると最善の方法は、ブラウザで再読み込みを何度か行って、結果の平均から推測することでしょうか? 違います。
ここで役に立つのが、 wrk
や Apache Bench
といったベンチマークツールです。 Apache Bench
、すなわち ab
は私のお気に入りですが、使い方を簡単に説明しましょう。Homebrewにインストールするには、 brew install ab
とします。 ^(6)
6: そのためには、最初に”brew tap homebrew/apache”を実行する必要があるかもしれません。
前述したように本番モードでサーバを開始して、Apache Benchを以下の設定で起動します。
ab -t 10 -c 10 http://localhost:3000/
もちろん、このURLは適切なものに置き換える必要があります。-tのオプションはベンチマークする時間(秒)を指定し、-cのオプションは同時に試すリクエストの数を指定します。-cは本番環境の負荷に応じて設定してください。もし(1サーバで)1秒につき平均1リクエスト以上になる場合は、-cを「(1分当たりの本番環境リクエスト数÷本番サーバまたはdyneの数)×2」を目安とした数に増やすとよいでしょう。私はたいてい、スレッドや並行処理のおかしなエラーが出ないかチェックするために、少なくとも-c 2でテストしています。
以下は、分かりやすいように簡略化したApache Benchの出力例です。
...
Requests per second: 161.04 [#/sec] (mean)
Time per request: 12.419 [ms] (mean)
Time per request: 6.210 [ms] (mean, across all concurrent requests)
...
Percentage of the requests served within a certain time (ms)
50% 12
66% 13
75% 13
80% 13
90% 14
95% 15
98% 17
99% 18
100% 21 (longest request)
この「time per request」の数値をMAARTと比べることになります。95パーセンタイル(リクエストの95%がXより速い)の目標も設定している場合は、リスト末尾にある「95%」の時間と比較できます。便利ですよね。
Apache Benchで何ができるかについては、マニュアルを参照してください。SSLサポートやKeepAlive、POST/PUTサポートなどのオプションも重要です。
もちろん、このツールの優れている点は、本番サーバに対しても使えるということです。しかし、負荷の高い環境についてベンチマークしたい場合は、顧客に影響しないように、ステージング環境で実行するのが望ましいでしょう。
ここからのワークフローはシンプルです。 設定したMAARTを満たさない限りは何もキャッシュしません。 もしページがMAARTより遅いなら、 rack-mini-profiler
を使って、ページのどの部分で時間がかかっているかを調べます。 ^(5) 私が特に注意するのは、大きなSQLがリクエストのたびに無駄に実行されていたり、大量のコードが繰り返し実行されていたりする部分がないかということです。
rack-mini-profilerの出力例
キャッシングの方法
キーベースのキャッシュ期限切れ
キャッシュから書いたり読んだりすることは、とても簡単です。繰り返しますが、基本的な事項については、 このトピックに関するRailsのガイドを参照してください 。 キャッシングにおいて複雑なのは、キャッシュを失効させるタイミングを把握することです。
以前は、Rails開発者はObserverやSweeperを使って、キャッシュの有効期限切れをよく手動で設定していました。現在では、その方法はすっかり使われなくなり、 キーベースの期限切れ が使われるようになっています。
キャッシュとは、Hashと同様に、キーと値のシンプルな集まりでしたね。実際、Rubyでは常にハッシュをキャッシュとして使っています。キーベースの期限切れとは、 キャッシュされている値 の情報を キャッシュキー に持たせて、キャッシュにおけるエントリを失効させる方法です。この場合、オブジェクトが(私たちの気にかけているような形で)変わった時に、そのオブジェクトのキャッシュキーも変わります。それをキャッシュストアに残し、(もう使われなくなった)前のキャッシュキーを失効させるのです。キャッシュにおけるエントリを手動で失効させることはありません。
ActiveRecordオブジェクトの場合は、ある属性を変更してオブジェクトをデータベースに保存するたびに、そのオブジェクトの updated_at
属性が変わることは分かっています。ですので、ActiveRecordオブジェクトをキャッシュするときには、キャッシュキーにおける updated_at
が使えます。つまり、ActiveRecordオブジェクトが変わるたびにそのupdated_atが変わることを使って、キャッシュを破棄するのです。幸い、Railsはこれに対応しており、処理も非常に簡単です。
例えば、Todoアイテムがあるとすると、以下のようにキャッシュできます。
<% todo = Todo.first %>
<% cache(todo) %>
... a whole lot of work here ...
<% end %>
ActiveRecordオブジェクトを cache
に入れると、Railsはそのことを認識して以下のようなキャッシュキーを生成します。
views/todos/123-20120806214154/7a1156131a6928cb0026877f8b749ac9
code>views の部分は説明不要ですね。 todos
の部分はActiveRecordオブジェクトのClassがベースになります。次の部分はオブジェクトの id
(この例では123)と updated_at
の値(2012年のある時)を組み合わせたものです。最後の部分はテンプレートツリーダイジェストと呼ばれるもので、このキャッシュキーが呼び出されたテンプレートのMD5ハッシュです。テンプレートが変わると(例えばテンプレートのある行を変えて、その変更を本番環境に反映させるような場合)、キャッシュは破棄されて、新しいキャッシュ値が再生成されます。これなら、テンプレートのどこかを変えた時に全てのキャッシュを手動で失効させる必要がないので、非常に便利です。
ここで注意したいのは、キャッシュキー中の何かを変えるとキャッシュが失効するということです。したがって、既定のTodoアイテムについて以下の項目のどれかが変わった場合、キャッシュは失効して新しいコンテンツが生成されます。
- オブジェクトのクラス(あまり考えられない)
- オブジェクトのid(オブジェクトの主キーなので、これもあまり考えられない)
- オブジェクトの
updated_at
属性(オブジェクトが保存されるたびに変わるので、大いに考えられる) - テンプレートの変更(デプロイ間にあり得る)
この方法では、どのキャッシュキーも 実際に 失効するわけではなく、使われない状態になるだけであることに注意してください。スペースがなくなった時に、手動でキャッシュからエントリを失効させる代わりに、使われなくなった値をキャッシュ自体が追い出すようにしているのです。または、時間ベースの期限切れとして、ある期間が過ぎたらキャッシュから古いエントリを失効させる方法もあります。
Arrayを cache
に入れた場合は、キャッシュキーはArrayの全要素を連結したものがベースになります。このことは、同じActiveRecordオブジェクトを使う別々のキャッシュにおいて役に立ちます。例えば、current_userに依存する以下のようなtodoアイテムのビューがあるとします。
<% todo = Todo.first %>
<% cache([current_user, todo]) %>
... a whole lot of work here ...
<% end %>
この場合、current_userが更新された時 または todoが変わった時に、このキャッシュキーは失効して置き換えられます。
ロシア人形キャッシュ
“ロシア人形キャッシュ”はDHHが名付けたつけたキャッシングの手法です。こじゃれた名前に身構えなくても大丈夫ですよ。難しいことは全くありません。
ロシアのマトリョーシカ人形は分かりますよね? 人形の中にまた小さな人形が入っている、というやつです。ロシア人形キャッシュの構造もまさに同じ状況で、フラグメントキャッシュが入れ子になっている状態を言います。以下のTodoの要素のリストで見てみましょう。
<% cache('todo_list') %>
<ul>
<% @todos.each do |todo| %>
<% cache(todo) do %>
<li class="todo"><%= todo.description %></li>
<% end %>
<% end %>
</ul>
<% end %>
上記のコードには問題があります。例えば、「犬の散歩をする」という現在のTodoの記述を、「猫に餌をやる」に変更したとしましょう。そして、これでページをリロードすると、Todoリストは「犬を散歩する」のままになります。これは、内側のキャッシュを変更しても外側のキャッシュ(Todoリスト全体をキャッシュしたもの)が変更されていないからです。これはいけません。内側のキャッシュは再利用したいですが、同時に外のキャッシュは失効させたいですよね。
この問題を解決するには単純に、ロシア人形キャッシュで、キーベースのキャッシュの期限切れを指定すればいいのです。内側のキャッシュが失効したら、外側のキャッシュも失効させたいけど、外側のキャッシュが失効した時は、内側のキャッシュは失効させ たくありません 。前述した例がどうなるか、以下のtodo_listで見てみましょう。
<% cache(["todo_list", @todos.map(&:id), @todos.maximum(:updated_at)]) %>
<ul>
<% @todos.each do |todo| %>
<% cache(todo) do %>
<li class="todo"><%= todo.description %></li>
<% end %>
<% end %>
</ul>
<% end %>
ここでは、どこかの@todoが修正されるか(そうすると@todos.maximum(:updated_at)が変更される)、Todoが削除されるか@todoに追加されるかすると(そうすると@todos.map(&:id)が変更される)、外側のキャッシュは失効します。しかし、変更がされていないTodoのアイテムは、内側のキャッシュ内では同じキャッシュキーを持ったままです。つまり、キャッシュされた値は再利用されます。分かりました? これだけのことです。
さらに、ActiveRecordの関連付けで touch
オプションが使用されるのを見たことがあるでしょうか。ActiveRecordオブジェクトの touch
メソッドを呼び出すと、データベースで記録された updated_at
の値がアップデートされます。以下がその様子です。
class Corporation < ActiveRecord::Base
has_many :cars
end
class Car < ActiveRecord::Base
belongs_to :corporation, touch: true
end
class Brake < ActiveRecord::Base
belongs_to :car, touch: true
end
@brake = Brake.first
# calls the touch method on @brake, @brake.car, and @brake.car.corporation.
# @brake.updated_at, @brake.car.updated_at and @brake.car.corporation.updated_at
# will all be equal.
@brake.touch
# changes updated_at on @brake and saves as usual.
# @brake.car and @brake.car.corporation get "touch"ed just like above.
@brake.save
@brake.car.touch # @brake is not touched. @brake.car.corporation is touched.
上記を使えばロシア人形キャッシュをスムーズに失効することができます。
<% cache @brake.car.corporation %>
Corporation: <%= @brake.car.corporation.name %>
<% cache @brake.car %>
Car: <%= @brake.car.name %>
<% cache @brake %>
Brake system: <%= @brake.name %>
<% end %>
<% end %>
<% end %>
上記のキャッシュの構造(上記のように設定された touch
の関係性)で、 @brake.car.save
を呼び出した場合、外側の2つのキャッシュは失効します(なぜなら updated_at
の値が変更されるからです)。しかし、内側のキャッシュ( @brake
の)はそのままの状態で再利用もされません。
どのキャッシュバックエンドを使うべきか
Railsの開発者には、キャッシュのバックエンドの選択肢はいくつかあります。
- ActiveSupport::FileStore
これはデフォルトです。このキャッシュストアではキャッシュ上のすべての値はファイルシステムに保存されます。 - ActiveSupport::MemoryStore
原則として、このキャッシュストアは全ての値を巨大なスレッドセーフなハッシュの中に置きます。これでキャッシュの値を効率的にRAMに保存します - Memcache と dalli
dalli
はMemcacheのキャッシュストアの一番のクライアントです。Memcacheは2003年にLiveJournal のために開発されました。そして、明らかにwebアプリケーション向けにデザインされています。 - Redis と redis-store
redis-store
はRedisをキャッシュとして使う際に最もよく使われるクライアントです。 - LruRedu
ActiveSupport::MemoryStore,のようなメモリーベースのキャッシュストアです。しかしこれはDiscourseの共同創設者のSam Saffronによってパフォーマンス向上のために設計されたものです。
それでは、それぞれの長所と短所を比べながら1つ1つ詳しく見ていきましょう。最後にはパフォーマンスのベンチマークを用意しています。それぞれのキャッシュストアと折り合うパフォーマンスの妥協点のアイディアをみなさんに提供できればと思っています。
ActiveSupport::FileStore
FileStoreは、私が知る限りの全てのRailsアプリでデフォルトキャッシュのインプリメンテーションです。もし、production.rb (もしくは別のどんな環境でも)に config.cache_store
を設定していなければ、FileStoreを使っていることになります。
FileStoreは単純に、デフォルトで tmp/cache
に一連のファイルやフォルダの中に全てのキャッシュを保存していきます。
長所
FileStoreはプロセス間で使える。 例えば、UnicornのRailsのアプリを起動する1つのHerokuのdyneがあって、Unicornを扱うworker が3つならば、3つ全てが同じキャッシュを共有できます。もし、worker 1が計算をして先ほどの例のtodoリストのキャッシュに保存すると、worker 2もそのキャッシュの値を使用することができます。しかし、このやり方はホスト間では通用しません。(もちろん、ほとんどのホストは同じファイルシステムにアクセスすることはありませんが。)繰り返しになりますが、Herokuでは各dyneごとの全てのプロセスでキャッシュが共有できますが、dynoでは共有できません。
RAMよりディスクスペースがより安い。 ホストのMemcacheのサーバは安くはありません。例えば、30MBのMemcacheサーバでは1カ月で数ドルの費用です。ではこれが5GBになったらどうでしょうか? 月290ドルになってしまいます。でもディスクスペースはRAMよりは、ものすっごく安くなります。ですから、もし多くのディスクスペースにアクセスしたり、膨大なキャッシュがあるのであれば、FileStoreがとても便利です。
短所
FileStoreは比較的遅い。 RAMにアクセスするよりもディスクにアクセスするほうが断然速度が遅くなってしまいます。それでも、ネットワークを介してキャッシュにアクセスするよりは速いです(ネットワークを介すると1分くらいかかってしまうでしょう)。
キャッシュがホスト間で共有できない。 残念ながら、Railsサーバとキャッシュは共有できません。Railsサーバはファイルシステムとも共有できません(例えばHeroku dyneのように)。これにより、FileStore は巨大なデプロイ環境には適していません。
LRUキャッシュではない。 これはFileStoreの一番大きな欠点です。FileStoreはエントリを失効させますが、それはキャッシュに書き換えられた時であって、最後には使われたりアクセスされたりした時点ではありません。これは、キーベースのキャッシュの期限切れを扱う時にはFileStoreでは意味をなしません。前述の例を思い出してみてください。キーベースの期限切れは、手動でキャッシュキーを失効させるは際にはありませんでした。FileStoreでこれをやろうと思うと、キャッシュは最大のサイズ(1GB!)まで膨れ上がります。それから、キャッシュが作られた時間をベースにキャッシュのエントリを失効していきます。例えば、todoリストをまず作って、1秒間に10回のアクセスがあるとします。それでもFileStoreはアイテムを先に失効していきます。Least-Recently-Used cache algorithms(LRU)はキーベースのキャッシュの期限切れでうまく動きます。それはLRUがしばらく使われていないエントリを先に失効させていくからです。
Herokuのdynoをクラッシュする。 FileStoreのもう1つの特徴は、一時的なHerokuのファイルシステムとは完全に相性が悪いことです。Herokuでファイルシステムにアクセスするととてつもなく遅く、実際にdyneの“スワップメモリ”に追加します。Railsのアプリが、アクセスに時間のかかるHerokuでの巨大なFileStoreのキャッシュのせいで遅くなるのを私は見てきました。さらにHerokuは24時間ごとに全てのdyneを再起動させます。これが起こると、ファイルシステムはリセットされ、キャッシュは削除されてしまいます!
いつActiveSupport::FileStoreを使うべきか
リクエストの負荷が低ければ(1~2個のサーバであれば)、FileStoreを使えますが、依然として大きなキャッシュが必要です。なお、Herokuでは使わないでください。
ActiveSupport::MemoryStore
MemoryStoreはRailsで提供されているメインの実装の1つです。ファイルシステムにキャッシュの値を保存する代わりに、MemoryStoreは巨大なハッシュ形式でRAMに直接保存します。
ActiveSupport::MemoryStoreはスレッドセーフです。今回リストアップした他のキャッシュストアも全てそうです。
長所
- 速い。 私が行ったベンチマーク(後述しています)で一番のパフォーマンスをするキャッシュの1つです。
- セットアップが簡単。
config.cache_store
を:memory_store.
に変えるだけで済みます。すごいでしょ?
短所
- キャッシュがプロセスやホストで共有できない。 残念ながら、キャッシュはホスト間で共有できないのは明らかですが、プロセスでも共有ができません(例えば、UnicornやPumaの workerで)。
- キャッシュはRAMの合計の使用量に追加される。 当たり前ですが、メモリにデータを保存するとRAMの使用量が追加されます。メモリが厳しく制限されるHerokuのような共有された環境では使用は難しいでしょう。
いつActiveSupport::MemoryStoreを使うべきか
もしサーバが1つか2つでworkerがそれぞれ数個なら、保存されるキャッシュデータは非常に小さいので(20MB以下)、そのような場合はMemoryStoreを使うのがいいでしょう。
Memcacheとdalli
Memcacheは、Railsのアプリケーションではおそらく一番よく使用され、外部のキャッシュストアで一番推奨されています。Memcacheは2003年にLiveJournal向けに開発されました。そして、Wordpress.orgやWikipediaやYoutubeなどでも使用されています。
Memcacheは、巨大なプロダクションのデプロイメントを持つという長所がある一方で、他のキャッシュストアよりもデプロイメントの速度がなぜか遅くなってしまうという点もあります(古くて使い込まれていても壊れていなければ、修正はしないでください)。
長所
- 分散するので、プロセスやホスト全てにおいて共有できる。 FileStoreやMemoryStore,とは違い、全てのプロセスとdynoまたはホストで全く同じキャッシュが共有できます。キャッシュキーは全体のシステムを通して一度しか書かれていないので、最大限に活用することができます。
短所
- 分散したキャッシュはネットワークの問題や待ち時間の影響を受けやすい。 もちろん、ネットワークで値にアクセスすると、RAMやファイルシステムでアクセスするよりも、とても遅くなってしまいます。後述のベンチマークをチェックしてもらえると、その影響がどれほどのものなのか分かると思います。一部のケースでは顕著に現れています。
- 費用がかかる。 FileStoreやMemoryStoreを自分のサーバで使うには無料です。通常、AWSやMemcachierのようなサービス でMemcacheをセットアップすると費用がかかります。
- キャッシュの値に1MBという制限がある。 更にキャッシュキーの制限は250バイト
です。
いつMemcacheを使うべきか
1つか2つ以上のホストを使う場合、分散したキャッシュストアを使ったほうがいいです。しかし、Redisのほうがわずかですが勝っていると私は思います。その理由は以下のとおりです。
Redisとredis-store
RedisはMemcacheと同じようなメモリ上のkey-valueデータストアです。2009年にSalvatore Sanfilippo氏が開発を始め、現在でも同氏がプロジェクトを先導し、ただ1人で保守管理を行っています。
redis-store に加え、 readthis という新しいRedisキャッシュgemが登場しました。こちらは活発に開発が進んでいて、期待できそうです。
長所
- 分散するので、プロセスやホスト全てにおいて共有できる。 Memcacheと同じように、 全てのプ ロセスとdynoまたはホストで全く同じキャッシュが共有できます。それぞれのキャッシュキーはシステム全体で一度しか書かれないため、キャッシュキーは全体のシステムを通して一度しか書かれていないので、最大限に活用することができます。
- LRU以外の除外ポリシーを使用できる。 Redisは独自の除外ポリシーを選択することが可能です。これによって、キャッシュストアがいっぱいになってしまった時の対処を制御しやすくなります。複数のポリシーからどれを選べばいいかという詳細な説明については、 素晴らしいRedisのドキュメント をご覧ください。
- ディスクに保持でき、ウォームブートが可能。 RedisはMemcacheとは異なり、ディスクに書き込むことができます。これによってRedisはディスクにDBを書き込み、再起動すると保持されたDBをリロードして復帰することができます。キャッシュストアを再起動したらキャッシュが空になっていた、なんてことはもうありません!
短所
- 分散したキャッシュはネットワーク上の問題や待ち時間の影響を受けやすい。 もちろん、ネットワークで値にアクセスすると、RAMやファイルシステムでアクセスするよりも、とても遅くなってしまいます。後述のベンチマークをチェックしてもらえると、その影響がどれほどのものなのか分かると思います。一部のケースでは顕著に現れています。
- 費用がかかる。 FileStoreやMemoryStoreを自分のサーバで使うには無料です。通常、AWSやRedisのようなサービスでRedisインスタンスをセットアップすると費用がかかります。
- Redisは様々なデータタイプをサポートしているが、redis-storeは文字列しかサポートしていない。 これはRedis自体ではなく
redis-store
gemの欠点です。Redisはリスト、セット、ハッシュといった様々なデータタイプをサポートしています。これに対してMemcacheは文字列しか保存できません。Redisでは利用できるデータタイプを増やしいているのが非常に興味深い点です(これによって多くのマーシャリングまたはシリアライゼーションといった作業を削減できます)。
いつRedisを使うべきか
2つ以上のサーバまたはプロセッサを運用しているのであれば、キャッシュストアにRedisを使うことをお勧めします。
LruRedux
Discourseの共同創設者Sam Saffron氏によって開発されたLruReduxは、ActiveSupport::MemoryStoreを高度に最適化したものです。しかし残念なことにActiveSupportの互換インターフェースはまだ提供されていません。そのため、今のところは初期状態のRailsキャッシュストアとしてではなく、アプリの低位レベルで使うことになります。
長所
- おかしいほど速い。 LruReduxは、私のベンチマークの中で段違いのパフォーマンスを見せる優れたキャッシュです。
短所
- キャッシュがプロセッサやホストで共有できない。 残念ながら、キャッシュはホスト間で共有できないのは明らかですが、プロセッサでもキャッシュを共有できません(例えば、Unicorn workerやPumaのクラスタ化されたworkerで)。
- キャッシュはRAMの合計の使用量に追加される。 当たり前ですが、メモリ内の文字列データによってRAMの使用量が追加されます。メモリが厳しく制限されるHerokuのような共有された環境では使用は難しいでしょう。
- Railsキャッシュストアとして利用できない。 今のところはできません。
いつLruReduxを使うべきか
アルゴリズムが高性能な(そしてハッシュが大きくなりすぎても問題のない大きさの)キャッシュを関数に要求する場合はLruReduxを使ってください。
キャッシュベンチマーク
優れたベンチマークは皆さん大好きですよね。 全てのベンチマークのコードが、こちらのGitHubで入手できます 。
フェッチ
全てのRailsキャッシュストアの中で、最もよく使われるメソッドが fetch
です。この値がキャッシュに存在する場合は、値を読み取ります。そうでない場合は、与えられたブロックを実行して値を書き込みます。このメソッドをベンチマークすることで、読み取りと書き込みの両方のパフォーマンスをテストできます。 i/s
は“iterations(イテレーション)/second(秒)”を省略したものです。
LruRedux::ThreadSafeCache: 337353.5 i/s
ActiveSupport::Cache::MemoryStore: 52808.1 i/s - 6.39x slower
ActiveSupport::Cache::FileStore: 12341.5 i/s - 27.33x slower
ActiveSupport::Cache::DalliStore: 6629.1 i/s - 50.89x slower
ActiveSupport::Cache::RedisStore: 6304.6 i/s - 53.51x slower
ActiveSupport::Cache::DalliStore at pub-memcache-13640.us-east-1-1.2.ec2.garantiadata.com:13640: 26.9 i/s - 12545.27x slower
ActiveSupport::Cache::RedisStore at pub-redis-11469.us-east-1-4.2.ec2.garantiadata.com: 25.8 i/s - 13062.87x slower
すごいですね。この結果から以下のことが分かります。
- LruRedux、MemoryStore、FileStoreは非常に速く、基本的に一瞬である。
- MemcacheとRedisはキャッシュが同じホスト上にあるならば非常に速い。
- MemcacheとRedisはネットワーク上で離れたホストを使う場合に多大な影響を受け、各キャッシュの読み取りに最大約50ミリ秒かかる(非常に重い負荷がかかる場合)。これは2つのことを意味します。1つ目はMemcacheまたはRedisホストを選ぶ場合、サーバに一番近いものを選ぶべきだということで、2つ目はそれ自身を生成する最大時間が10~20ミリ秒より少ないものはキャッシュしてはいけないということです。
Railsアプリのフルスタック
このテストのために、RailsアプリのWebページ上のコンテンツをいくつかキャッシュしてみることにします。これによって、全体の要求サイクルが必要な場合も、フラグメントキャッシュを読み取りまたは書き込みするのにかかる時間が分かるはずです。
本来、アプリがするのは、1から16の間のランダムな数字に @cache_key
を設定し、次のようにレンダリングするだけです。
<% cache(@cache_key) do %> <p><%= SecureRandom.base64(100_000) %></p> <% end %>
MemoryStoreの平均レスポンスタイムは速いほど良い
以下の結果がApache Benchによって得られました。プロダクションモードに設定されたローカルのRailsサーバに平均10,000リクエストがありました。
- Redisまたはredis-store (リモート) 47.763
- MemcacheまたはDalli (リモート) 43.594
- 無効なキャッシング 10.664
- MemcacheまたはDalli (ローカルホスト) 5.980
- Redisまたはredis-store (ローカルホスト) 5.004
- ActiveSupport::FileStore 4.952
- ActiveSupport::MemoryStore 4.648
これは本当に面白い結果です! 最速のキャッシュストア(MemoryStore)とキャッシュが無効なバージョンの間の差は約6ミリ秒です。つまり SecureRandom.base64(100_000)
によって行われる仕事は6ミリ秒しかかかっていないと推測できます。この場合、離れたキャッシュにアクセスするのは、仕事そのものをするよりずっと遅くなるのです!
学んだことは何でしょう。 リモートに分散されたキャッシュを使う際は、キャッシュを読み取るのにかかる実際の時間を計算しましょう。 私が行ったのと同じようにベンチマークを使っても計測できますし、Railsログから読み取ることもできます。書き込むより読み取りに時間がかかってしまうようなら、絶対にキャッシュしないでください。
結論
この投稿が皆さんの技術の向上のために必要な情報をお伝えし、Railsアプリでもっとキャッシングを活用するための助けになれば幸いです。これは非常に高性能なRailsサイトの本当のキーとなります。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa