2019-10-18に投稿

【Rails】N+1問題に遭遇

まえがき

Railsアプリ制作中に「N+1問題」というものがあるというのを知りました。
リレーションのあるモデルをループ処理する際に起こりうるようで、
SQL文が大量に発行されてしまいパフォーマンスが低下してしまう問題のようです。
理解が難しかったためまとめてみたいと思います。

参照したページ

問題(修正前のコード)

実際に制作中のアプリでは、
モデルはDiaryとWorkの二つがあり、一対多の関係になっています。(Workが子モデル)

models/concerns/diary.rb

class Diary < ApplicationRecord
  validates :date, uniqueness: true
  has_many :works, dependent: :destroy
end

models/concerns/work.rb

class Work < ApplicationRecord
  belongs_to :diary
 <br>
  validates :category, presence: true
  validates :title, presence: true
end

そして、こちらがDiariesコントローラーの、ループ処理が含まれているindexアクションの中身。
降順に表示させるようにしているので、order(date: :desc)をつけています。
ここが今回の問題となっているようです。  

controllers/concerns/diaries_controller.rb

  def index
    @diaries = Diary.all.order(date: :desc)
  end

そしてこちらがindexのビューで、diaryとそれに紐づいた複数のworksを表で表示するようにしています

vies/diaries/index.html.erb

<div class="contents">
  <table>
    <tr>
      <td>日付</td>
      <td>内容</td>
    </tr>
    <% @diaries.each do |diary| %>
      <tr>
        <td><%= link_to diary.date, diary_path(diary) %></td>
        <td><%= diary.body %></td>
        <td><%= link_to "ワークを追加", new_diary_work_path(diary) %></td>        
      </tr>
      <% diary.works.each do |work| %>
        <tr>
          <td><%= work.category %></td>
          <td><%= work.title %></td>
          <td><%= work.body %></td>
          <td><%= work.image %></td>
          <td><%= link_to "edit", edit_diary_work_path(diary, work) %></td>
          <td><%= link_to "delete", diary_work_path(diary, work), method: :delete, data: {confirm: "このワークを消して大丈夫?"} %></td>              
        </tr>
      <% end %>
    <% end %>
  </table>
</div>

さて、今回の問題はこのindexのビューにおいて、workを表示する際に必要以上のSQL文がWorkの数だけ発行されてしまい処理が増えてしまうこと。

親の表示と子の表示がそれぞれループになっていて(入れ後構造=ネスト構造、と呼ぶのかな)
プログラム上は、

  1. コントローラー上で、インスタンス変数に@diariesにDiaryモデル(親モデル)を順番に取得
  2. Diary(親モデル)の1番目を表示
  3. それに紐づいているWork(子モデル)の中身をすべて取得して表示
  4. Diary(親モデル)の2番目を表示
  5. それに紐づいているWork(子モデル)の中身ををすべて取得して表示

    …(以下、Diaryの数だけ繰り返し)

という処理が行われます。

ここで子モデルであるWorkを取得する際、つまり上の3, 5の処理の際に、Workの数だけSQL文が発行されてしまう。
例えば1番目のDiaryに対してWorkが5個あったらSQL文も5個、
2番目のDiaryに対してWorkが10個あったらSQL文も10個
となっていく。(ということで合っているのかな……)

登録しているデータ数が少ないうちはいいですが、
データ数が多くなってくるとここのパフォーマンスが問題になってくるようです。

解決策(修正後のコード)

コントローラーでDiary.allを取得する際に、includesする。

controllers/concerns/diaries_controller.rb

  def index
    @diaries = Diary.all.includes(:works).order(date: :desc)
  end

こうすることで、子モデルであるWorkを取得する際に、一つのSQL文だけが発行されるようになり、パフォーマンスを上げることができる。
1番目のDiaryに対してWorkが5個あっても、SQL文は1個で済ますことができる。
2番目のDiaryに対してWorkが10個あっても、SQL文は1個で済ますことができる。
(ということで合っているのかな……)

どういうことか

修正前のDiariesコントローラーの
@diaries = Diary.all.order(date: :desc)
ここが問題。allメソッドは、指定のテーブルのデータをすべて取得するためのメソッドですが、
ビューでWorkの各行を表示するときに、Diaryから名前を取得するためのSQL文が作られていました。

そこで、コントローラーの段階で関連テーブルから事前に1文のSQLで読み込んでおく(キャッシュ)ことで、
ビューからeachで呼び出す際に都度SQL文を発行する必要がなくなります。
それがincludesメソッドの役割です。

ビューでWorkの各行を表示するときではなく、その前のコントローラーでDiaryを取得する際に、読み込む処理を行っておくのですね。

それが修正後のDiariesコントローラーの
@diaries = Diary.all.includes(:works).order(date: :desc)
これになります。

まとめ

今後、どのようなケースでN+1問題が発生するのか
また、includes以外にもpreloadやeager_loadという対策があるようなので、
この辺りの違いを調べてみたいと思います。
N+1を検出する「bullet」というgemもあるようですね。

Originally published at massasquash.qrunch.io

Massa

北海道の畑作専業農家・WEBエンジニア修行中の30代。「自然の中で感じたことをカタチにする」をモットーに、農業とITを掛け合わせた仕事に携わりたいなあと思ってます。農業の効率化と発展を目指して。のんびり畑を耕したいw ■Ruby/Rails ■Capoeiraがライフワーク ◇noteでブログ書いてます

Crieitは個人で開発中です。 興味がある方は是非記事の投稿をお願いします! どんな軽い内容でも嬉しいです。
なぜCrieitを作ろうと思ったか

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください!

ボードとは?

コメント