Rails: パーシャルと`collection:`でN+1を回避してビューを高速化(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: パーシャルとcollection:でN+1を回避してビューを高速化(翻訳)

Railsでビューのレンダリング(特にパーシャル)を正しく行うことの重要性に気づいてない人をよく見かけます。本記事では、さまざまなアプローチのパフォーマンスの相対数値を比較します。このトピックは、多くのブログ記事で見落とされがちです。

N+1クエリの回避

最初の重要なトピックは「N+1クエリ」です。N+1クエリをのさばらせると速度低下が不可避になり、その他のパフォーマンス最適化も効かなくなってしまうことがあるため、ぜひとも回避しましょう!

非常にシンプルな例から見てみましょう。

<% @users.each do |*user*| %> <div class="post">
      <%= *user*.post.title %> </div>
<% end %>

usersのリストを反復して、各userpostとしています。簡単ですね。

それでは、ユーザー1000人を以下のコードでassignしてテストしてみましょう。

assign(:users, *User*.all)

結果は以下のとおりです。

Warming up --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
          with N+1 1.000 i/100ms
Calculating --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- -
          with N+1 0.291 (± 0.0%) i/s --- 2.000 in 7.158333s

1秒あたりでレンダリング可能なビュー数は0.291です。これはかなり残念な数値なので、ビューで発生しているN+1クエリを最初に解決しましょう。現在のビューでは、イテレーションのたびにDBでSELECTを実行してuserpostを取り出しています。

N+1を今後も解決するためにBullet gemを導入します。私はBulletが大好きです❤️。次の3つの理由からGoldiloaderよりもBulletが好みです。

  1. Bulletのコード自動更新は変更してよいかどうかをプロンプトで確認してくれる。
  2. Bulletはproduction環境では実行できないようになっている。
  3. BulletはN+1クエリを隠蔽せず、検出と理解を支援してくれる。

GemfileにBulletを追加してtest環境向けに設定します。これによって、N+1クエリで例外がraiseされ、解決しないとテストを完了できないようになります。

# app/config/environments/test.rb

config.after_initialize do
  Bullet.enable = true
  Bullet.bullet_logger = true
  Bullet.raise = true
end

テストを再実行すると、必要な情報をBulletが表示してテストを失敗させます。

Bullet::Notification::UnoptimizedQueryError:

USE eager loading detected
 User => [:post]
 Add to your finder: :includes => [:post]

そしてassignを以下のように変更します。

assign(:users, User.includes(:post))

修正後の結果は7倍ほど高速になりました。

Warming up --------------------------------------
      with N+1     1.000  i/100ms
   without N+1     1.000  i/100ms
Calculating -------------------------------------
      with N+1      1.539  (± 0.0%) i/s -      8.000  in   5.305367s
   without N+1     10.479  (± 9.5%) i/s -     52.000  in   5.057764s

Comparison:
   without N+1:       10.5 i/s
      with N+1:        1.5 i/s - 6.81x  slower

パーシャルのレンダリング

それでは本記事の本題であるパーシャルの利用に進みましょう。個別のpostをパーシャルに切り出してコードをリファクタリングすることにします。これはよい方法ですが、次のような駄目リファクタリングがあることも知っておきましょう。

1. パーシャルを切り出す。

<div class="post">
  <%= user.post.title %>
</div>

2. レンダリングする。

<% @users.each do |user| %>
    <%= render 'erb_partials/post', user: user %>
<% end %>

パフォーマンスは以下のようになります。

Warming up --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
 inline 1.000 i/100ms
 partial 1.000 i/100ms
Calculating --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- -
 inline 11.776 (± 8.5%) i/s --- 59.000 in 5.085002s
 partial 5.648 (±17.7%) i/s --- 28.000 in 5.043322s

Comparison:
 inline: 11.8 i/s
 partial: 5.6 i/s --- 2.09x slower

なるほど、確かにコードが2倍も遅くなってしまいました。これはパーシャルのレンダリングを反復しているのが原因です。Railsはuserごとにパーシャルを「オープン」して評価しなければならなくなります。これを解決するにはcollectionを使います。

ビューを次のように変更します。

<%= *render* partial: 'erb_partials/post', collection: @users, as: :user %>

変更後の結果は以下のとおりです。

Warming up --------------------------------------
        inline     9.000  i/100ms
       partial     1.000  i/100ms
    collection     6.000  i/100ms
Calculating -------------------------------------
        inline     96.394  (±11.4%) i/s -    477.000  in   5.016304s
       partial      8.989  (±22.2%) i/s -     43.000  in   5.108843s
    collection     57.828  (±13.8%) i/s -    288.000  in   5.092763s

Comparison:
        inline:       96.4 i/s
    collection:       57.8 i/s - 1.67x  slower
       partial:        9.0 i/s - 10.72x  slower

パフォーマンスを落とさずにコードをパーシャルに切り出せたことがわかります。修正後のコードでは、Railsによるパーシャルの評価は1回で完了し、その後userごとにレンダリングを行います。これは、先のN+1クエリで行った修正と同じに考えることができます。すなわち、この修正によってもコードをスケール可能にできるのです。

追伸

実際には(本記事のように)同一ページに1000ユーザーを表示するのはやめ、ページネーションを実装しましょう。
本記事で用いたコードはGitHubの私のリポジトリでご覧いただけます。

ボーナス: 他のレンダリングエンジンを試してみる

先ほどはerbで比較しましたが、slimhamlではどうなのでしょうか?以下は比較の結果です。

Warming up --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
 erb inline 8.000 i/100ms
 erb partial 1.000 i/100ms
 erb collection 5.000 i/100ms
 slim inline 10.000 i/100ms
 slim partial 1.000 i/100ms
 slim collection 6.000 i/100ms
 haml inline 9.000 i/100ms
 haml partial 1.000 i/100ms
 haml collection 4.000 i/100ms
Calculating --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- -
 erb inline 97.943 (±10.2%) i/s --- 488.000 in 5.046691s
 erb partial 9.438 (±21.2%) i/s --- 46.000 in 5.026193s
 erb collection 67.090 (± 6.0%) i/s --- 335.000 in 5.015540s
 slim inline 104.373 (± 8.6%) i/s --- 530.000 in 5.122621s
 slim partial 9.836 (±20.3%) i/s --- 49.000 in 5.123851s
 slim collection 69.146 (± 7.2%) i/s --- 348.000 in 5.059607s
 haml inline 85.732 (±11.7%) i/s --- 432.000 in 5.111380s
 haml partial 8.180 (±24.4%) i/s --- 40.000 in 5.165770s
 haml collection 41.069 (±21.9%) i/s --- 196.000 in 5.084682s

Comparison:
 slim inline: 104.4 i/s
 erb inline: 97.9 i/s --- same-ish: difference falls within error
 haml inline: 85.7 i/s --- same-ish: difference falls within error
 slim collection: 69.1 i/s --- 1.51x slower
 erb collection: 67.1 i/s --- 1.56x slower
 haml collection: 41.1 i/s --- 2.54x slower
 slim partial: 9.8 i/s --- 10.61x slower
 erb partial: 9.4 i/s --- 11.06x slower
 haml partial: 8.2 i/s --- 12.76x slower

いずれの場合も、インラインのレンダリングが最もよい結果を残しています。slimのパフォーマンスはerbと同程度(またはわずかに上回る)で、hamlのレンダリング時間はパーシャルでやや落ちるようです。

結論

  • パーシャルを使いましょう。ためらう必要はありません。
  • インラインレンダリングは確かに高速ですが、コードのメンテナンス性/読みやすさ/テストのしやすさも重要です。
  • すなわち、必要に応じてビューをパーシャルに分割しましょう。
  • ただし、collectionを使って正しく行いましょう。
  • もちろんN+1クエリは避けましょう。

関連記事

Railsのurl_helperの速度低下を防ぐコツ(翻訳)

Railsアプリのアセットプリコンパイルを高速化するコツ(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ