Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Railsのパフォーマンス改善:キャッシュに頼る前に検討すべきこと

プログラミングにおける「キャッシュ」とは、値を一時的に保存しておき、後で素早く取り出せるようにすることを指します。通常は、外部APIへのリクエストが必要だったり、大量の数値処理を要したりといった理由で計算に時間のかかる値が対象になります。

キャッシュされた値は、memcachedやRedisのような専用サーバーに保存されることが多く、ディスク上にもRAM上にも格納できます。またコードの中では、コストの高い関数を何度も呼び出さずに済むよう、変数にデータを「キャッシュ」しておくこともよくあります。

data = some_calculation()
a(data)
b(data)

速度向上と引き換えに、古いデータを使うことになるのがトレードオフです。キャッシュされたデータが「陳腐化(ステール)」して正確さを失ったらどうなるでしょうか。その場合は、キャッシュをクリアして「無効化」する必要があります。

キャッシュ反対論

古くからの名言にあるように、コンピュータサイエンスには難しい問題が2つしかありません。
1. 命名
2. キャッシュ無効化
3. オフバイワンエラー

なぜキャッシュ無効化はこれほど難しいのでしょうか。キャッシュされた値は、その性質上、「本物の」値を隠してしまうからです。「本物の」値が変わるたびに、プログラマであるあなたがキャッシュを「無効化」して更新させることを覚えておかなければなりません。

例として、テキストエディタに「単語数カウント」ウィジェットを追加する場面を考えてみましょう。ユーザーが入力するたびに単語数を更新する必要があります。最も単純な方法はキーストロークごとに単語数を再カウントすることですが、これは遅すぎます。そこで別のアプローチを取ります。

  1. ファイル読み込み時に単語数をカウントする。
  2. その単語数を変数に保存する(=キャッシュする)。
  3. 変数の内容を画面に表示する。

この実装ははるかに高速ですが、キャッシュされた「単語数」は入力しても変化しません。変化させたいなら、単語数が変わると予想される場面すべてでキャッシュを「無効化」する必要があります。

そこで、キーストロークを検知したら単語(つまり空白)を見つけてカウンターを増やし、削除時には減らすようにします。簡単です。完成です。次のチケットへ——と言いたいところですが。

……ちょっと待ってください。ユーザーがテキストをクリップボードに切り取ったときの更新は忘れていませんか?貼り付けた場合は? スペルチェッカーが誤字を2つの単語に分割した場合は?

ここでの問題は値の更新自体ではありません。それはごく平凡な作業です。問題は、あらゆる箇所で更新を忘れないようにしなければならない点にあります。たった1か所でも更新漏れがあるとキャッシュ無効化のバグが発生し、ユーザーに古い値を表示し続けることになってしまうのです。

このことを踏まえると、キャッシュの導入には技術的な複雑さと潜在的なバグの温床が伴うことが分かります。もちろん、こうした問題は解決可能ですが、安易にキャッシュへ飛びつく前に考慮すべき点であることは間違いありません。

キャッシュなしで速くするには

キャッシュを選択肢から外すと、アプリケーションの高速化は「パフォーマンスのボトルネック——本来より遅くなっている部分——を特定して修正する」ことに集約されます。ボトルネックは大きく次の3つに分類できます。

  1. データベースクエリ(多すぎる、または遅すぎる)
  2. ビューのレンダリング
  3. アプリケーションコード(重い計算処理など)

パフォーマンス改善に取り組むうえで、押さえておくべき2つの技法が「プロファイリング」と「ベンチマーク」です。

プロファイリング

プロファイリングとは、アプリのどこに問題があるのかを把握するための手法です。ページが遅いのはテンプレートの描画が遅いからでしょうか? それとも、データベースに何百万回もアクセスしているからでしょうか?

Ruby on Railsであれば、rack-mini-profilerをおすすめします。アプリの端に小さなウィジェットを表示してくれるgemで、表示中のページの描画にかかった処理——発行されたデータベースクエリの回数や所要時間、レンダリングされたパーシャルの数など——を一目で確認できます。

本番環境向け(プロのコツ:rack-mini-profilerは本番環境でも問題なく動作します。ただし管理者や開発者など特定のユーザーにのみ表示されるよう設定しましょう)には、Skylight、New Relic、Scoutといったページパフォーマンス監視のオンラインサービスもあります。

よく引用される目標値の<= 100msはページレンダリングにとって優れた基準です。実際のインターネット環境では、それ未満の差はユーザーにはほとんど感知できないためです。ただし目標はさまざまな要因によって変わります。筆者もかつて、パフォーマンスがひどいレガシーアプリケーションに携わった際には、<= 1秒を目標にしました。決して理想的ではありませんが、着手前よりはるかに良い状態でした。

ベンチマーク

問題の場所を特定できたら、次はベンチマークを使って、最適化がパフォーマンスにどのような影響を与えたか(あるいは何も変わらなかったか)を確認します。個人的には、この種の作業にはbenchmark-ips gemがおすすめです。人間が読みやすい形でコード変更の効果を比較できます。

ごく単純な例として、文字列連結と文字列補間の比較を見てみましょう。

require 'benchmark/ips'

@a = "abc"
@b = "def"
Benchmark.ips do |x|
  x.report("Concatenation") { @a + @b }
  x.report("Interpolation") { "#{@a}#{@b}" }
  x.compare!
end

結果は以下の通りです。

Warming up --------------------------------------
       Concatenation   316.022k i/100ms
       Interpolation   282.422k i/100ms
Calculating -------------------------------------
       Concatenation     10.353M (± 7.4%) i/s -     51.512M in   5.016567s
       Interpolation      6.615M (± 6.8%) i/s -     33.043M in   5.023636s

Comparison:
       Concatenation: 10112435.3 i/s
       Interpolation:  6721867.3 i/s - 1.50x  slower

このように読みやすい結果が得られ、(少なくとも短い文字列においては)補間は連結より1.5倍遅いことが分かります。このような理由から、改善したいメソッドをコピーして別名を付けておき、作業しながら手軽に比較できるようにしておくのもおすすめです。

パフォーマンス問題の修正

ここまでで、アプリのどの部分が遅いかが分かり、改善効果を測定するベンチマークも整いました。あとは実際に最適化作業を行うだけです。どの手法を選ぶかは、問題がデータベース・ビュー・アプリケーションのどこにあるかによって異なります。

データベースのパフォーマンス

データベース関連のパフォーマンス問題でまず確認したいのは、忌々しい「N+1クエリ」の回避です。これはビューでコレクションを描画する際によく発生します。たとえば、10件のブログ記事を持つユーザーについて、ユーザー情報と全記事を表示したいケースを考えてみましょう。素朴な初版のコードは次のようになるかもしれません。

# コントローラ
def show
  @user = User.find(params[:id])
end
# ビュー
Name: <%= @user.name %>
Posts:
  <% @user.posts.each do |post| %>
    <div>Title: <%= post.title %></div>
  <% end %>

上記のアプローチでは、まずユーザーを取得するクエリ(1件)が走り、その後各記事ごとに個別のクエリ(N=10件)が発行され、合計11回(N+1)となります。幸い、Railsにはシンプルな解決策が用意されており、ActiveRecordのクエリに.includes(:posts)を追加するだけです。先ほどの例であれば、コントローラを次のように変更します。

def show
  @user = User.includes(:posts).find(params[:id])
end

これで、ユーザーその全記事を1回のデータベースクエリで取得できるようになります。

もう1つ注目すべきは、計算処理をデータベース側に任せられる箇所がないかという点です。多くの場合、アプリケーション側で同じ処理を行うよりも高速です。よくあるのが、次のような集計処理です。

total = Model.all.map(&:somefield).sum

これはデータベースから全レコードを取得している一方で、実際の合計計算はRuby側で行っています。次のように書き換えて、計算をデータベースに任せれば高速化できます。

total = Model.sum(:somefield)

もう少し複雑な処理、たとえば2つのカラムを掛け合わせたい場合も同様です。

total = Model.sum('columna * columnb')

主要なデータベースはこの程度の四則演算やsum・averageなどの一般的な集計関数をサポートしています。自分のコードベースにmap(...).sumのような呼び出しがないか、ぜひ探してみてください。

ビューのパフォーマンス

テンプレート関連のパフォーマンス問題は、正直なところキャッシュが解決策になりやすい領域です。とはいえ、まず潰しておきたい「低い実りの果実」もいくつかあります。

ページ全体の読み込み時間については、JavaScriptやCSSライブラリのソースをminify(圧縮)版にしているかを確認しましょう(少なくとも本番サーバーでは必須です)。

また、大量のパーシャルを読み込んでいないかにも注意してください。_widget.html.erbテンプレートの処理に1msしかかからなくても、ページ上にウィジェットが100個あれば、それだけで100msを消費します。1つの解決策はUIの見直しです。画面に100個ものウィジェットを一度に並べるのは、そもそも優れたユーザー体験とは言えません。何らかのページネーションを導入するか、場合によっては思い切ったUI/UXの刷新を検討する価値があるでしょう。

アプリケーションコードのパフォーマンス

パフォーマンス問題がビューやデータベース層ではなく、アプリケーションコード自体(つまりデータ操作)にある場合も、選択肢はいくつかあります。1つは、前述のように処理の一部をクエリとしてデータベースに押し込めること、あるいはscenic gemなどを活用してデータベースビューとして定義することです。

もう1つの選択肢は、「重い処理」をバックグラウンドジョブに移すことです。ただしこの場合、値が非同期に計算されるようになるため、それに対応するUIの変更が必要になるかもしれません。

それでもキャッシュが必要な場合は?

ここまで読んでいただいて、それでも「やはりキャッシュこそが必要な解決策だ」と結論づけた方もいるかもしれません。では、どうすればよいのでしょうか。ご安心ください。本記事は、Ruby on Railsで利用できるさまざまなキャッシュ手法を扱うシリーズの第1弾です。続編にご期待ください。

  1. rack-mini-profilerとフレームグラフでRailsアプリのボトルネックを可視化する方法

    あなたのRailsアプリは遅くなっていませんか? 本来シンプルに表示されるはずのビューの読み込みに数秒かかるなら、原因を掘り下げて調査すべきサインです。 原因としては、データベースへの呼び出しが多すぎたり、処理の遅いメソッドがあったり、あるいは誰かがコードに仕込んだまま忘れられてしまった無駄なループだったりします。 アプリの遅さの原因を突き止めるためのツールは数多く存在します。以前このブログでもrbtraceについて紹介しましたし、New Relicのrpm gemもアプリの高速化に役立ってくれました。 しかし、私がパフォーマンス問題の調査に最も愛用しているツールは、それ以上のことができるので

  2. Androidをオーバークロックしてパフォーマンスを正しく向上させる方法

    新しいAndroidスマートフォンが次々と市場に登場し、ゲームやアプリも新機能に対応するため頻繁にアップデートされています。その結果、処理負荷は増大し、古いスマートフォンは徐々に動作が重くなりがちです。アプリをたくさん開いたときにラグ(遅延)を感じたことはありませんか?頻繁に新しいスマートフォンを買い替えられる人はそう多くありません。しかし、手持ちのAndroidデバイスのパフォーマンスを向上させる方法があることをご存知でしょうか?それが「オーバークロック」です。この記事では、オーバークロックの基本から具体的な手順まで詳しく解説します。 Androidをオーバークロックしてパフォーマンスを正し