Railsアプリケーションを66%高速化する - Railsキャッシュ完全ガイド

SpeedShop(詳細),Railsパフォーマンス・コンサルタンシー

ネイト・ベルコペック Nate Berkopec (@nateberkopec)

要約:Railsアプリケーションでのキャッシュは、夕食の時に、時々あなたの周りにいる、まあまあ仲の良い友人のようですが、本当はもっと頻繁にいてもらわなければなりません。

 Railsアプリケーションでのキャッシュは、夕食の時に、時々あなたの周りにいる、まあまあ仲の良い友人のようですが、本当はもっと頻繁にいてもらわなければなりません。

 パフォーマンスに深刻な問題があるRailsアプリケーションは、ほぼ全て、キャッシュを使うことで改善します。しかし大部分のRailsアプリケーションはキャッシュを全く使っていません。それでも、通常Railsで高速のサーバー応答時間を実現するには、キャッシュを賢く使うのが唯一の手段です。応答時間は約250~500ミリ秒から100ミリ秒に簡単に高速化できます。

 定義について簡単に書いておきます。 - この記事は、アプリケーション・レイヤでのキャッシュのみを扱います。HTTPのキャッシュ(これは全く別の難物で、あなたのアプリケーションに実装する必要すらありません)については、別の機会に扱います。

なぜできる限りキャッシュしないのか?

 我々開発者の気質は、エンド・ユーザとはだいぶ違います。ソフトウェアやウェブ・アプリケーションの各場面の背後で、何が起こっているかをよく知っています。典型的なウェブ・ページが読み込まれるとき、大量のコードの実行され、データ・ベース・クエリが実行され、HTTPを介してサービスが呼び出されることがある、ということを理解しています。処理は時間がかかります。開発者はコンピュータとやり取りするときに、コンピュータが応答するまで少し時間がかかるという考えに慣れています。

 エンド・ユーザの気質はまったく異なります。 あなたの作ったウェブ・アプリケーションは魔法の箱です。 エンド・ユーザは、箱の内で何が起こっているのかわかりません。

開発者から見たエンド・ユーザのイメージ

undefined

 特に最近では、魔法の箱が、ほぼ瞬時に反応することを期待しています。昨日もあなたのウェブ・アプリケーションから、望むことがなんでも得られると期待したに違いありません。

 これは全くの真実です。しかしそれでも開発者は決して、ユーザ・ストーリーと、製品仕様に対して、パフォーマンス要件を厳しく設定しません。サーバの応答時間は測定や目標設定が簡単で、ユーザが高速なWebページを望んでいることがわかっているにもかかわらず、特定のサイトや機能について、「このページは100ms以内に必ず応答を返します。」などと言わないできました。その結果ユーザ・ストーリーや大きい機能などが優先され、パフォーマンスは後回しにされることが良くあります。パフォーマンスに対して手抜きをすると、技術を手抜きするのと同じように、急激に問題が蓄積していきます。誰かがリクエストをするたびに、アプリケーションがいつも炎に包まれるようになるまで、実際にはパフォーマンスは決して優先されることはありません。

 加えて、キャッシュが簡単とは限りません。キャッシュの期限切れは、特に混乱しやすいトピックです。キャッシュの動作についてのバグは、アプリケーションであまりテストされない、インテグレーション・レイヤで起きやすいため、キャッシュのバグは潜みやすく、発見したり、再現することが難しいです。

 さらに悪いことに、Railsの世界では、キャッシュのベスト・プラクティスは頻繁に変化しているようです。キーベースって何?ロシアン・モール・キャッシュ?ドールじゃなかったっけ?

キャッシュのメリット

 実行速度を上げるには、リクエストごとに実行されるRubyのコード量を少なくしなければなりません。最も簡単な方法は、キャッシュを使うことです。一度だけ実行し、結果をキャッシュします。その後キャッシュされた結果を使用します。

Benchmarks GameでのJavascriptと比較したRubyのパフォーマンス

 しかし、実際にはどれくらいの速さが必要なのでしょうか?

 人間とコンピュータのインタラクションに関するガイドラインは、コンピュータが開発された1960年代からすでに知られています。ユーザがロード時間なしに、サイトを自由に行き来していると感じる時間の基準値は1秒以下です。これは1秒の応答時間ということではなく、画面表示まで1秒という意味です。ユーザがクリック、もしくは操作した瞬間から、インタラクションの完了まで(DOMの描画完了まで)です。

 表示まで1秒というのはあまり長い時間ではありません。第一に、ネットワーク・レイテンシが50ミリ秒程度あります。(これはデスクトップの場合です。モバイルのレイテンシは全く別の話です)そして、JSとCSSのリソースロード、レンダリング・ツリーの構築と描画に、別の150ミリ秒がかかることを考慮にいれておきましょう。最後に、少なくとも250ミリ秒がダウンロードされた全てのJavaスクリプトの実行に費やされます。もしJavaスクリプトに、Domの準備を待つ関数が多くあれば、それ以上多く時間がかかる可能性があります。このように、サーバがどのくらいの時間でレスポンスを返さなければならないかと考える前に、既に500ミリ秒がかかっています。常に1秒以下の表示時間を達成するには、サーバの応答は300ミリ秒以下でなくてはなりません。100ミリ秒で結果を表示する必要があるウェブ・サイトでは、私の別の記事で触れていますが、サーバ・レスポンスは25-30ミリ秒に保つべきです。リクエストごとに300ミリ秒というのは、Railsアプリケーションでキャッシュを使わなくても、特にあなたが、SQLクエリとActiveRecordに精通していれば、達成するのは不可能ではありません。しかしキャッシュを使えば、はるかに簡単になります。私が見た大半のRailsアプリケーションでは、アプリケーション中の少なくとも半分のページが、常に300ミリ秒以上の応答時間を費やしていました。さらに、Eコマースで良く知られたSpreeのように重いフレーム・ワークを、Railsに追加して使う場合、リクエストのたびに追加されるRubyの実行によって、著しく遅くなります。よく使われるDeviseやActiveAdminなどのGemでさえ、それぞれのリクエストの間に数千行のRubyのコードを追加するため重いです。

 もちろん例えばPOSTエンド・ポイントのように、アプリケーション中で、キャッシュが助けにならない場所が常に存在するでしょう。アプリケーションが行う、POSTやPUTに対する応答はどのようなものでも非常に複雑です。キャッシュは助けにならないでしょう。もし問題なら、キャッシュを使う代わりに、処理をバック・グラウンド・ワーカに移すことができます。(別の機会にブログに書きます)

はじめに

 最初に、Railsのキャッシュに関する公式ガイドは、様々なRail APIの技術的な詳細について書かれた、素晴らしいものです。もしまだ読んでいないのであれば、隅々まで読んでください。

 この記事の後半で、Rails開発者のために用意された、様々なキャッシュ・バックエンドについて論じます。遅くても、ホストとサーバ間で共有機能を提供するもの、高速でも他のプロセスにでさえキャッシュの共有をしないもの、といったように、それぞれにメリットとデメリットがあります。開発者のニーズは異なります。要約すると、キャッシュへのストアは、デフォルトでは、ActiveSupport::Cache::FileStoreでOKです。しかし、このガイドにあるような(特にキーベースの期限切れ)テクニックを使いたい場合、結局違った方法でキャッシュをストアしなければなりません。

 キャッシュを扱うのが初めての開発者へのヒントとして、アクション(action)・キャッシュとページ(page)・キャッシュは無視することをお勧めします。これらのテクニックが使える場所は非常に限られる状況なので、Raisl4.0以降削除されています。その代わりにフラグメント(fragment)・キャッシュが快適に使用できるのでお勧めです。詳細を以下に説明します。

パフォーマンスをプロファイルする

ログを読む

 さあ、キャッシュへストアする仕組みを理解し、実際に使う準備ができました。しかし、何をキャッシュすればよいのしょうか?

 ここでプロファイラの出番です。アプリケーションのどの部分がパフォーマンスのホット・スポットになっているか、当てずっぽうに山を張るよりも、プロファイラを起動して、ページのどの部分が遅いのか調べましょう。

 この作業のために、私がお勧めするツールは、rack-mini-profilerです。素晴らしいツールです。rack-mini-profilerは特定のサーバー応答、正確にどの場所に時間が費やされたか、1行単位の素晴らしい分析を提供してくれます。

 しかし、もし面倒くさくて、ツールを使いたくない場合、実はrack-mini-profilerやどんな他のプロファイラでさえ、全く使う必要はありません。 - Railsはページの作成に費やされた時間の合計を、ログとして外部に出力します。

例えばこんな風に見えます:



Completed 200 OK in 110ms (Views: 65.6ms | ActiveRecord: 19.7ms)

 合計時間(このケースの場合110ミリ秒)は重要な項目です。”Views”で示された合計時間は、テンプレート・ファイル(例えばindex.html.erb)に費やされた時間の合計です。しかし、ctiveRecord::Relationsが遅延ロードを行う方法のために、少し誤解しやすいかもしれません。コントローラの中でActiveRecord::Relationと共に(例えば@users = User.all)、インスタンス変数を定義したとしても、ビューの中で結果が使われるまで(例えば@users.each do ...)、まったく何もおこりません。その後、変数が使われたときに、クエリ(とオブジェクトへのレイフィケーション)がViewsで示された数字に集計されるでしょう。ActiveRecord::Relationsは遅延ロードを採用しているため、データ・ベース・クエリは、(通常はビュー内で)結果がアクセスされるまで実行されません。

 ActiveRecordの値も誤解しやすいです。- 私がRailsのソース・コードを読んで言えることは、これはActiveRecord中のRubyの実行時間の合計(クエリの作成と実行、結果のActiveRecrodObjectへの変換)ではありません。特にイーガー・ロードを大量に使用する非常に複雑なクエリでは、結果を ActiveRecordオブジェクトに変換するのに非常に時間がかかることがあります。しかし、このActiveRecordの値には反映されないでしょう。

 他の時間はどこにいったのでしょうか。大部分はミドルウェアとそのコントロール・コードです。リクエスト中、どの場所に時間が費やされたか正確なミリ秒単位の分析を得るためには、rack-mini-profilerflamegraphエクステンションが必要でしょう。このツールを使うと、リクエスト中にミリ秒単位で、時間がどのように進んだか、一行ごとに把握することができます。rack-mini-profilerの使い方のガイドを書いているところなので、もしガイドがいつ出るか知りたい人は、私のニュース・レター(画面の右下)にサイン・アップしてください。

rack-mini-profilerでのflamegraph表示例

製品モード

 Railsアプリケーションのパフォーマンスをプロファイルするときはいつでも、私は製品モードで行います。 もちろん実際の製品上でという意味ではなく、RAILS_ENV=productionという意味です。 製品モードで実行することで、ローカル環境はエンドユーザの体験に近くなり、コードのリロードとアセットのコンパイルも無効になります。この2つは開発モードで、Railsのリクエストを大幅に遅くします。

 もし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行程度返すだけです。しかし、製品では10万行もの膨大な結果となり、サイトが壮絶にクラッシュするかもしれません。製品データを使用するか、なるべく現実に近いテスト・データを作成してください。大量のinlcudesやRailsのイーガー・ロード機能を使う場合には特に重要です。

ゴールの設定

 最後に、最大許容平均応答時間(maximum acceptable average response time、略称MAART)をサイトに目標として設定することをお勧めします。パフォーマンスという指標のよいところは、通常計測が簡単なことです。そして計測したものは管理しましょう!おそらく2つのMAART値が必要でしょう。一つは開発用のハードウェアと開発モードで達成可能な値、もう一つは製品用ハードウェアと製品モードに使用する値です。

 仮想化を使ってCPUとメモリ・アクセスをコントロールして、製品と開発環境を極めてスケーラブルに設定しているような場合を除いて、開発環境でのパフォーマンスの計測結果に単純に倍率をかけて、製品環境でのパフォーマンスを算出するといったことはできません。(近い値は出せるかもしれませんが)それでいいのです。あまり細かいところに惑わされないようにしましょう。それぞれの環境で、あなたの作成したページが、適正なパフォーマンスであることが確認できればよいだけです。

 例として、私が以前書いた記事のように、表示まで100msのウェブ・アプリケーションを作成したいとしましょう。 この場合、25-50ミリ秒のサーバー応答時間が要件になります。 そこで、私はMAARTを開発モードでは25msに設定し、製品モードでは約50msに緩めて設定します。 私の開発マシンはHEROKUのdyne(私の典型的なデプロイ環境です)より少し速いので、製品モードでは少し時間に余裕を持たせます。

 最大許容平均応答時間を自動でテストするツールはないようです。(今のところ)ベンチマーク・ツールを使用して手動でテストを行う必要があります。

Apache Bench

 さて、開発中に、サイトの平均応答時間を知るには実際どうしたらよいのでしょうか。私はまだログから応答時間を知る方法しか書いていません。- では、ブラウザの"リフレッシュ"を何回かクリックして、平均値をできるだけ正しく推測するのが、一番良い方法なのでしょうか。違います。

 ここはwrkApache Benchのようなベンチマーク・ツールの出番です。ApacheBench、略称abが私のお気に入りなので、使い方を手短に説明します。Homebrew上で、brew install abとすればインストールできます。(*1)

*1 これを動作させるには、最初に‘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できることの一覧はmanページを見てください。重要な他のオプション、SSLやKeepAlive,POST/PUTのサポートがあります。

 このツールの良いところは、もちろん、製品サーバーでも使えることです。ただし、高負荷のベンチマークをしたいときには、おそらくステージング環境で実行するのがベストでしょう。あなたの顧客が影響を受けないように!

 ここからのワーク・フローは簡単です。MAARTを超えない限りキャッシュすることはしません。ページが設定したMAARTより遅いなら、どの部分が遅いかを正確に知るためにrack-mini-profilerを使って調べます。

 具体的には、特にリクエストごとに大量に不必要なSQLがに実行されている部分や、大量のコードが繰り返し実行されている部分を探します。

rack-mini-profilerでの分析結果



キャッシュのテクニック

キーベースの期限切れ(Key-Based expiration)

 キャッシュに書き込んだり読み込んだりするのはとても簡単です。もし基本を知らない場合は、このトピックスに関するRailsガイドを確認してみてください。キャッシュのわかりずらい部分は、いつキャッシュが期限切れになるかを把握することです。

 昔、Railsの開発者は、ObserverとSweeperを使って、手作業でキャッシュの期限を管理していました。最近はこういったことを全くしないですむように、代わりにキーベースの期限切れと呼ばれる仕組みを使っています。

 キャッシュは単純で、ハッシュのようなキーと値の組み合わせです。実は、Rubyはキャッシュとしていつもハッシュを使っています。キーベースの期限切れは、キャッシュ期限切れストラテジの一種です。キャッシュされる値の情報を含んだ、キャッシュ・キーを作成することによって、キャッシュ・エントリを期限切れにします。オブジェクトの値が変わった時(これは我々が行うことです)、オブジェクトのためのキャッシュ・キーも変わります。このキャッシュ・キーを取っておき、キャッシュ・ストアに使います。そうすると前の(使われなくなった)キャッシュ・キーは期限切れします。手動でキャッシュ・エントリーを期限切れにすることはできません。

 ActiveRecordオブジェクトの場合、属性を変更してオブジェクトをデータ・ベースに保存するたびに、そのオブジェクトのupdated_at属性が変わることがわかります。 そのため、ActiveRecordオブジェクトをキャッシュするときにキャッシュ・キーにupdated_atを使用できます。ActiveRecordオブジェクトが変更されるたびに、updated_atも変更され、キャッシュが破壊されます。 Railsがこれを知っているおかげで、この操作をとても簡単にしてくれます。

 例えば、Todoアイテムがあるとき、このようにキャッシュすることができます。

<% todo = Todo.first %>

<% cache(todo) do %>

 ... a whole lot of work here …

( ここにたくさんの処理が入ります。)

<% end %>

 ActiveRecordオブジェクトをキャッシュに渡すと、Railsが認識し、次のようなキャッシュ・キーを生成します。

views/todos/123-20120806214154/7a1156131a6928cb0026877f8b749ac9

 viewsの部分は一目でわかります。 Todoの一部は、ActiveRecordオブジェクトのClassに基づいています。 次の部分は、オブジェクトのID(この場合は123)とupdated_at値(2012年のある時期)の組み合わせです。 最後の部分はテンプレート・ツリー・ダイジェストと呼ばれるもので、単に、キャッシュ・キーが呼び出されたテンプレートのmd5ハッシュです。テンプレートが変更されると(たとえば、テンプレートの行を変更してからその変更を製品環境にプッシュすると)、キャッシュは破棄され新しいキャッシュ値が再生成されます。非常に便利です。さもなくば、テンプレートの内容を変更したときに、すべてのキャッシュを手動で期限切れにする必要があるでしょう! 

 ここで、キャッシュ・キー内の何かを変更すると、キャッシュが期限切れになることに注意してください。 そのため、あるTodo項目について、次のいずれかの項目が変更されると、キャッシュは期限切れになり、新しい内容が生成されます。

■オブジェクトのクラス(あまり起こらないでしょう)

■オブジェクトのID(これもオブジェクトのプライマリ・キーなので、あまり起こらないでしょう)

■オブジェクトのupdated_at属性(オブジェクトが保存されるたびに変わるので、良く起きます)

■テンプレートの変更(デプロイ環境間で起こりえます)

 これらの項目は、実際にはキャッシュ・キーを期限切れにしないことに注意してください -未使用のまま保持されるだけです。

 キャッシュのエントリを手動で期限切れにする代わりに、キャッシュがスペース不足になり始めたときに、未使用の値が自動的に廃棄される仕組みにまかせましょう。もしくは、キャッシュは、一定時間たつと、古いキャッシュ・エントリが期限切れになるような、タイム・ベースの期限切れストラテジを使うかもしません。

 配列をキャッシュに渡すこともできます。このとき、キャッシュ・キーは、配列を全て連結したものに基くようになります。これは、同じActiveRecordオブジェクトを使用する、異なるキャッシュに役立ちます。current_userに依存するTodoアイテム・ビューがあるかもしれません。

<% todo = Todo.first %>

<% cache([current_user, todo]) do %>

 ... a whole lot of work here ... ここにたくさんの処理が入ります。

<% end %>

current_userが更新された場合、またはTodoが変更された場合、このキャッシュキーは期限切れになり、置き換えられます。

ロシアン・ドール・キャッシュ

 奇抜な名前ですが、心配しないでください。このDHH(David Heinemeier Hanssonデビッド・ハイネマイヤー・ハンソン)が名付けたキャッシュのテクニックは、全く複雑なものではありません。

 ロシア人形(ロシアン・ドール)がどのように見えるかというと、ご存知のように - 一つの人形の中に、さらに人形が入っています。ロシアン・ドール・キャッシュもそのようなものです - それぞれの内側にあるフラグメント・キャッシュを積み重ねていきます。 例えばTodoリストの要素があるとします:

<% cache('todo_list') do %>

 <ul>

   <% @todos.each do |todo| %>

     <% cache(todo) do %>

       <li class="todo"><%= todo.description %></li>

     <% end %>

   <% end %>

 </ul>

<% end %>

 上記のコード例には問題があります。例えば、既存のTodoの説明を「犬の散歩」から「猫の餌やり」に変更したとしましょう。 ページをリロードしても、Todoリストはいまだに「犬の散歩」と表示されます。内側のキャッシュは変更されていますが、外側のキャッシュ(Todoリスト全体のキャッシュ)は変更されていないからです!内側のフラグメント・キャッシュを再利用したいのですが、同時に外側のキャッシュも無効にしたいのです。

ロシアン・ドール・キャッシュは、単にこの問題を解決するためにキー・ベースの期限切れを使用しています。「内側の」キャッシュが期限切れになると、外側のキャッシュも期限切れします。 外側のキャッシュが期限切れした場合でも、内側のキャッシュは期限切れにしたくありません。 上記のtodoリストの例で、どうなるとよいのか見てみましょう:

<% cache(["todo_list", @todos.map(&:id), @todos.maximum(:updated_at)]) do %>

 <ul>

   <% @todos.each do |todo| %>

     <% cache(todo) do %>

       <li class="todo"><%= todo.description %></li>

     <% end %>

   <% end %>

 </ul>

<% end %>

 これで、どんな@ todosの変更(@ todos.maximum(:updated_at)の変更)、Todoの削除または@ todosへの追加(@ 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, @brake.car, and @brake.car.corporationにtouchメソッドを適用、

@brake.updated_at, @brake.car.updated_atと @brake.car.corporation.updated_atは全て同じ)

@brake.touch

# changes updated_at on @brake and saves as usual.

# @brake.car and @brake.car.corporation get "touch"ed just like above.

(@brakeのupdated_atを変更して、いつもどおりセーブする。@brake.carと@brake.car.corporationは上と同じくtouchされる)

@brake.save

@brake.car.touch # @brake is not touched. @brake.car.corporation is touched.

(@brakeはtouchされない。@brake.car.corporationはtouchされる)

 上記の動作を使って、ロシアン・ドール・キャッシュをエレガントに期限切れにすることができます。

<% 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のための)内側のキャッシュはtouchされず、再利用されます。

どのキャシュ・バックエンドを使用すべきか

 Rails開発者のために、キャッシュ・バックエンドにはいくつか選択肢が用意されています。

ActiveSupport :: FileStore これがデフォルトです。 このキャッシュ・ストアでは、キャッシュ内のすべての値がファイル・システムに保存されます。

ActiveSupport :: MemoryStore このキャッシュ・ストアは基本的に、すべてのキャッシュ値を、スレッド・セーフの大きいハッシュにストアし、効率的にRAMに保存します。

■Memcacheとdalli dalliはMemcacheキャッシュ・ストアの最も一般的なクライアントです。 Memcacheは2003年にLiveJournalというサービスのために開発され、Webアプリケーションに特化して設計されています。

■Redisとredis-store redis-storeはRedisをキャッシュとして使用するための、最も一般的なクライアントです。

■LRURedux ActiveSupport :: MemoryStoreのようなメモリベースのキャッシュ・ストアです。Discourseの共同設立者である、サム・サフラン(Sam Saffron)がパフォーマンスに特化し設計しました。

 それぞれの長所と短所を比較しながら、1つずつ詳細に見ていきましょう。 各キャッシュ・ストアでのパフォーマンスのトレード・オフについて理解を深めるために、最後にベンチマークをいくつか用意してあります。

ActiveSupport::FileStore

 FileStoreは、私が言える限りでは、すべてのRailsアプリケーションのキャッシュのデフォルト実装です。 production.rbで(もしくは他のどんな環境でも)config.cache_storeを明示的に設定していない場合は、FileStoreが使用されます。

 FileStoreは単純に、キャッシュのすべてを一連のファイルとフォルダに保存します - デフォルトではtmp / cacheです。

長所

■FileStoreはプロセス間で機能します 例えば1つのHEROKU dyneがRailsアプリケーションをUnicornと共に実行していて、3つのUnicornワーカがある場合、それぞれのワーカが同じキャッシュを共有できます。そのため、前に述べたTodoリストの例で、ワーカー1がキャッシュを計算して格納した場合、ワーカー2はそのキャッシュされた値を使用することができます。 ただし、ホスト間では機能しません。(もちろん、ほとんどのケースで、他のホストが同じファイルシステムにアクセスできないためです)そのため、HEROKUでは、dyneのすべてのプロセスがキャッシュを共有できますが、 dyno間で共有することはできません。

■ディスク・スペースはRAMよりも安価です ホスト化されたMemcacheサーバは安くはありません。 例えば、30MBのMemcacheサーバを使用するのに、1か月数ドルかかります。 しかし、5GBのキャッシュは?月290ドルかかります。 痛いでしょう? しかし、ディスク・スペースはRAMよりもすごく安いので、大きなディスク・スペースにアクセスできて、大量のキャッシュが必要な場合には、FileStoreでうまくいくかもしれません。

短所

■ファイルシステムは遅い(っぽい)です ディスクへのアクセスはRAMへのアクセスより常に遅くなります。 ただし、ネットワークを通じてキャッシュにアクセスするよりは速いかもしれません。(これについては、すぐ後で説明します)

■キャッシュをホスト間で共有できません 残念ながら、ファイルシステムを共有していないRailsサーバーとキャッシュを共有することはできません。(たとえば、HEROKUのdyno間) そのため、FileStoreは大規模なデプロイ環境には不適切です。

■LRUキャッシュではありません FileStoreの最大の欠点です。 FileStoreは、最近最後に使用またはアクセスされた時間ではなく、キャッシュに書き込まれた時間をもとにキャッシュを期限切れにします。このことによって、キー・ベースの期限切れを処理するとき、FileStoreにまずいことが起こります。前に書いた例を思い出してください。実際にはキー・ベースの期限切れでは、キャッシュ・キーを手動で期限切れにできません。この手法をFileStoreで使用すると、キャッシュは単純に最大サイズ(1GB!)になります。その後、キャッシュが作成された時間にもとづいてキャッシュ・エントリの期限切れを開始します。例えばもし、Todoリストが最初にキャッシュされたが、1秒間に10回アクセスされているとしても、FileStoreはそのアイテムを先に期限切れにします。 LRU(Least Recently Used)キャッシュ・アルゴリズムは、 キー・ベースの期限切れにとって、ずっと良いです。しばらくの間使用されていないエントリを期限切れにするためです。

■HEROKUのdynoがクラッシュする FileStoreのもう一つの致命的な点は、HEROKUの一時ファイルシステムに、完全に不適切な事です。そのため、HEROKUではファイルシステムへのアクセスが非常に遅くなり、実際にはdyneの”スワップ・メモリ”が増加します。FileStoreキャッシュが巨大なため、Railsアプリケーションのクロールが、何年もかかるかのように遅くなるのをよく見ます。さらに、HEROKUは24時間ごとにすべてのdyneを再起動します。 そのとき、ファイル・システムはリセットされ、キャッシュを一掃します!



どんな時にActiveSupport :: FileStoreを使うべきか?

 リクエストの負荷が少なく1台または2台のサーバ)、それでも非常に大きなキャッシュ(>100MB)が必要な場合、FileStoreに手を出すことができます。 また、HEROKUでは使用しないでください。

ActiveSupport::MemoryStore

 MemoryStoreは、Railsによって提供されている、もう一つの主要な実装です。 MemoryStoreはキャッシュ値をファイル・システム上に保存する代わりに、大きなハッシュの形で直接RAMに保存します。

 ActiveSupport :: MemoryStoreは、この記事のリストにある、他のキャッシュ・ストアと同様、スレッドセーフです。

長所

高速です 私が行ったベンチマークで最もパフォーマンスの良いキャッシュの1つです。(下記参照)

設定が簡単です config.cache_store:memory_storeに変更するだけです。 ジャジャーン!

短所

キャッシュをプロセスやホスト間で共有することはできません 残念ながら(明らかに)、キャッシュをホスト間で共有することはできません、またプロセス間で共有することもできません。(例えば、UnicornワーカやPumaクラスタ・ワーカの間で)

キャッシュによるRAM使用量の増加 メモリにデータを保存するとRAMの使用量が増えるのは明らかです。 これはメモリが非常に制限されているHEROKUのような共有環境では厳しいです。

どんな時にActiveSupport::MemoryStoreを使うべきか?

1〜2台のサーバがあり、それぞれ数個のワーカがあって、非常に少量のキャッシュ・データ(20 MB未満)をストアしている場合は、MemoryStoreが適しているでしょう。

Memcacheとdalli

 Memcacheは、おそらくRailsアプリケーションで最も頻繁に使用されていて、推奨される外部キャッシュ・ストアです。 Memcacheは2003年にLiveJournal用に開発され、Wordpress.org、Wikipedia、Youtubeなどの製品サイトで使用されています

 Memcacheは、まさにいくつもの大規模な製品の運用に使用されているため、実績がありますが、他のキャッシュ・ストアよりも開発が遅いという状況にあります。(歴史があり、よく使われているため、不具合がなければ修正されません)

長所

■分散型なので、すべてのプロセスとホストが共有できます FileStoreやMemoryStoreとは異なり、全てのプロセスとdynoやホスト間で、まったく同じキャッシュ・インスタンスを共有します。 各キャッシュ・キーはシステム全体で一度だけ書き込まれるため、キャッシュの利点を最大限に引き出すことができます。

短所

■分散キャッシュはネットワークの問題やレイテンシの影響を受けやすくなります RAMやファイルシステム上の値にアクセスするよりも、ネットワークを介して値にアクセスする方が、もちろん、はるかに遅くなります。 後ほど触れる私の行ったベンチマークで、どの程度の影響があり得るかを確認してください - 場合によっては、非常に大きなものになります。

■コストが高い 所有しているサーバー上ならFileStoreやMemoryStoreを動作させても無料ですが、 通常、独自のMemcacheインスタンスをAWS上に構築したり、Memcachierのようなサービスを使用する場合は、コストがかかるでしょう。

■キャッシュ値の総容量は1MBにまでに制限されています  加えてキャッシュ・キーは250バイトまでに制限されています。

どんな時にMemcacheを使うべきか?

 1〜2台以上のホストが動作している場合には、分散キャッシュ・ストアを使用する必要があります。 しかしRedisはもう少し良い選択肢だと思います。理由を以下に概説します。

Redisとredis-store

 Memcacheと同じく、Redisはインメモリのキー・バリュー・データ・ストアです。2009年にサルヴァトーレ・サンフィリッポ(Salvatore Sanfilippo)が Redisの開発を開始しました。彼は今でもプロジェクトを推進しており、一人でメンテナンスを行っています。

 redis-storeに加えて、Redisキャッシュの新しいgemが登場しています:readthis。鋭意開発中で、有望に見えます。

長所

■分散型なので、すべてのプロセスとホストが共有できます Memcacheのように、全てのプロセスとdynoやホスト間で、まったく同じキャッシュ・インスタンスを共有します。 各キャッシュ・キーはシステム全体で一度だけ書き込まれるため、キャッシュの利点を最大限に引き出すことができます。

■LRUより高度な、様々な削除ポリシーが用意されています 自由に削除ポリシーを選択することができます。キャッシュ・ストアがフルになったとき何をするかを、より制御できるようになります。 どのようにポリシーを選択するかについての詳細な説明は、Redisのドキュメントに良く書かれているので、チェックしてください。

■ディスクに保存し続けており、ホットリスタートが可能です Memcacheとは異なり、Redisはディスクに書き込むことができます。Redisはデータ・ベースをディスクに保存し続けており、再起動した時はデータ・ベースを再ロードして復元します。キャッシュ・ストアを再起動した後でもキャッシュが空ということは、もうありません!

短所

■分散キャッシュはネットワークの問題やレイテンシの影響を受けやすくなります RAMやファイルシステム上の値にアクセスするよりも、ネットワークを介して値にアクセスする方が、もちろん、とても、とても遅くなります。 後ほど触れる私の行ったベンチマークで、どの程度の影響があり得るかを確認してください - 場合によっては、非常に大きなものになります。

■コストが高い 所有しているサーバー上ならFileStoreやMemoryStoreを動作させても無料ですが、 通常、独自のMemcacheインスタンスをAWS上に構築したり、Memcachierのようなサービスを使用する場合は、コストがかかるでしょう。

■Redisはデータ型をいくつかサポートしますが、redis-storeは文字列のみです Redis自体というよりはredis-store gemの短所です。Redisは、リスト、セット、ハッシュなどのデータ型をサポートしています。 対して、Memcacheは文字列しか格納できません。 Redisが提供する追加のデータ型(多くのマーシャリングやシリアリゼーションを削減できるかもしれません)を使用できるとしたら、非常に興味深いでしょう。

どんな時にRedisを使うべきか?

 2つ以上のホストかCPUが動作している場合には、Redisをキャッシュ・ストアとして使用することをお勧めします。

LRURedux

Discourseのサム・サフラン(Sam Saffron)によって開発されたLRUReduxは、本質的にはActiveSupport :: MemoryStoreを高度に最適化したバージョンです。残念ながら、まだActiveSupport互換のインターフェースが提供されていないので、アプリの低レベルで使おうとすると行き詰るでしょう。今はまだ、デフォルトのRailsキャッシュ・ストアとして使えるようなものではありません。

長所

■とんでもなく高速です LRUReduxは今のところ、私が行ったベンチマークの中では、最もパフォーマンスの高いキャッシュです。

短所

キャッシュをプロセスやホスト間で共有することはできません 残念ながら(明らかに)、キャッシュをホスト間で共有することはできません、またプロセス間で共有することもできません。(例えば、UnicornワーカやPumaクラスタ・ワーカの間で)

キャッシュによるRAM使用量の増加 メモリにデータを保存するとRAMの使用量が増えるのは明らかです。 これはメモリが非常に制限されているHEROKUのような共有環境では厳しいです。

■Railsのキャッシュ・ストアとしては使えません。未だに。

どんな時に LRUReduxを使うべきか?

 アルゴリズムが機能するために高性能なキャッシュを必要とする場合、(そしてハッシュが大きくなりすぎたとしても、まかなうのに十分大きなメモリ容量がある場合)LRUReduxを使用してください。

キャシュのベンチマーク

 ベンチマークが嫌いな人っているのでしょうか?ここのGitHub上にベンチマーク・コードを用意しました。

Fetch

 全てのRailsキャッシュ・ストア中で最もよく使われるメソッドはfetchです。 -キャッシュに存在する場合は、値を読み込む。そうでなければ、与えられたブロックを実行して値を書き込む。このメソッドのベンチマークは、読み取りと書き込みの両方のパフォーマンスをテストします。i/sは"イテレーション数/秒"を表します。

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つのことを意味します -  MemcacheかRedisホストを選ぶとき、使用しているサーバーから物理的に最も近いものを選び、パフォーマンスをベンチマークしてください。 第二に、生成するのに10〜20ミリ秒以下の時間しかかからないものは、それ自体キャッシュしないでください。

Railsアプリケーションのフル・スタック

 このテストでは、RailsアプリケーションのWebページ上のコンテンツをキャッシュしてみます。 リクエスト・サイクル全体を実行しなければならないとき、フラグメント・キャッシュの読み取り/書き込みにかかる時間を知ることができます。

 基本的にすべてのアプリケーションは@cache_keyを1から16の間の乱数に設定し、次のビューをレンダリングします。

<% cache(@cache_key) do %>

 <p><%= SecureRandom.base64(100_000) %></p>

<% end %>

平均応答時間(ミリ秒) -小さいほうが良い

 以下の結果は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サイトを実現するために重要です。

ウェブ・サイトを高速化したいですか? 

 私はネイト・ベルコペック Nate Berkopec(@nateberkopec)です。 フル・スタック・エンジニアの観点から、ウェブのパフォーマンス、主にフロント・エンドとRubyのバック・エンドについて、オンライン記事を書いています。 もし、この記事が気に入って、次の記事について知りたい場合は、下をクリックしてください。 毎週1通程度、Eメールを私から直接送ります。スパムではありません。控えめです。

(メールアドレス送信フォーム)



Railsパフォーマンス完全ガイド     

 私が書いた「Railsパフォーマンス完全ガイド」を見てください! Ruby on Railsアプリケーションをより速く、よりスケーラブルに、より簡単にメンテナンスするためのツールを提供する、フル・スタックコースです。 361ページのPDF、プライベートSlack、15時間以上のビデオ・コンテンツが含まれています。

もっと学ぶ