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

北海道でアプリ制作に取り組んでるノンプログラマな農夫。仕事や日常生活で感じる小さな不便を解消すべく趣味と実益を兼ねて遊んでます ■Python・GAS + LINE bot

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

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

有料記事を販売できるようになりました!

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

コメント