当ブログではPRや広告を掲載しています

【Rails】ActiveRecordでN+1問題を解決する1つの手法

プログラミング
この記事はこんな人向け
  • Rails に少し慣れてきた
  • 画面表示がやたらと遅い
  • 特に一覧表示の処理に時間がかかっている
スポンサーリンク

画面表示が遅いのは N+1問題が原因かも

N+1問題とは

DBからデータを取得する際に、都度SQLを発行してしまい、大量のSQLが発行されてパフォーマンスが悪くなってしまうことです。

例えるなら、ECサイトで10個の商品を注文した際に、1回で1つの段ボールに入って全ての商品が届くのが理想ですよね。
これが、1つずつ別の業者が届けにきて、計10個の段ボールが別々に届くようなイメージです。

見分け方

ではN+1問題をどのように見分けるか。
それはログを見ればわかります。

例としてターミナルの画像で紹介します。

赤枠で囲ってあるように、同じ内容のリクエスト(SQL)がいくつも発行されていることがわかります。
画面表示するレコードの件数が多いと、このリクエストの数もそれにつれて増えていき、10秒待たないと画面表示されないといった事象が発生します。

これが N+1問題です。

本来であれば、同じ内容のリクエストは1つにまとめることができる(ダンボールで商品を1つにできるのと同じです)ので、その方法を解説していきます!

解決手順

今回は、BooksController のindex、つまり一覧ページで N+1問題の解決をしていきます。
※ページングがあり、取得件数を5件で統一しています。

Book モデルと User モデルのリレーションとしては以下の通りです。

app/models/user.rb
class User < ApplicationRecord
  has_many :book, dependent: :destroy
  has_one_attached :profile_image
end

N+1問題が起きている状態

まずは N+1問題が起きている状態です。
さきほど紹介したように、内容が同じリクエストが複数あります。(赤枠部分)

この時のコントローラーの記述は以下の通りです。

app/controllers/books_controller.rb
def index
  @books = Book.all.page(params[:page]).per(5)
end

Bookモデルから5件取得する記述になっています。
一見すると問題なさそうで、確かにコントローラー単体で見れば問題はありません。

しかし、N+1問題になる直接的な原因はView側にあるんです。

Viewファイルの該当部分を抜粋したのが以下のコードです。

app/views/books/index.html.erb
<% @books.each do |a_book| %>
  <div class="row border-bottom py-1">
    <div class="col-sm">
      <%= link_to user_path(a_book.user.id) do %>  #ここでuserモデルを初めて呼び出している
        <% if a_book.user.profile_image.attached? %>
          <%= image_tag a_book.user.profile_image, size: '60x60' %>
        <% else %>
          <%= image_tag 'no_image.jpg' %>
        <% end %>
      <% end %>
    </div>
    <div class="col-sm">
      <%= link_to a_book.title, book_path(a_book.id) %>
    </div>
  </div>
<% end %>

“@books” をループで回しており、”a_book.user.id” の箇所で初めて User モデルを呼び出しています。

コントローラー側では Book モデルのレコードのみ使う想定でいるため、View側で必要になったものはその都度リクエストを発行して取得しなくてはなりません。

このように、Viewとコントローラーで統率が取れてないと N+1問題が発生する要員となります。

※逆に言えば、View側で Book モデルの内容のみ出力する(Userの内容を消す)ようにすると、N+1問題も無くなります。

解決後

結論から言うと、View側のコードは一切変更せずに、コントローラーの記述を少しだけ変えて、N+1問題を解決することができます

対処後は以下の通り、同じリクエストが複数回されていた箇所が、1回になっています。

コントローラーで変更したのは、”includes(:user)”を追加したのみで、最終的に以下のようなコードになりました。

app/controllers/books_controller.rb
def index
  @books = Book.includes(:user).page(params[:page]).per(5)
end

この “includes” というのは、モデルを結合してくれるもので、SQLで言うところのJOINです。
“join(:user)” という書き方でも大丈夫です。

“includes” によって結合されているため、View側でループする対象となる “@books” の中身は Book と User の両方が入っています。
このため、その都度 User の内容をDBから取得する必要が無くなり、リクエストの数が減るという仕組みです。

効果測定

N+1問題が解消すると、画面表示に劇的な改善をもたらします。
今回のケースで効果を見てみましょう。

5件のデータ表示
  • N+1問題発生時:54ms
  • N+1問題対処後:48ms

5件で約6msの差があるため、単純に考えると、10件の表示では約12msの差となります。(恐らくもう少し差は広がります)

一般的にWebページの表示時間が0.1秒でも遅くなると、人間でも少し違和感を覚えるほどのレベルなので、100件ほどのデータ表示をする場合は死活問題となってきそうなのが分かります。

まとめ

  • 画面表示がやたら遅い場合はN+1問題を疑え
  • N+1問題が発生しているか否かはログやターミナルで確認可能
  • 解消法はコントローラーの記述を修正する

コメント