🐈

【Rails】N+1問題を知ろう!解決しよう!

2023/05/07に公開

はじめに

railsを触っていると、N+1問題というワードを耳にする方は多いのではないでしょうか?
・聞いいたことある
・なんとなく知ってる
・何それ?美味しいの?
認知度はそれぞれあると思いますが、これをきに概要や対策を理解していただき、
パフォーマンスを意識したできるエンジニア にぜひステップアップしてください!

N+1ってなに?

簡単に不必要なSQLを何回も発生させてしまう現象のことをさします。
それにより何度もDBへの問い合わせ処理が走るためパフォーマンスが悪化します。

例えば、
あなたはこれから台所で料理しようとしています。
冷蔵庫から具材を取り行く必要があります。
その際に余計な往復を避けるために
必要な食材を冷蔵庫からまとめて一回で台所に持って行きますよね?

N+1問題は、この例でいくと
台所と冷蔵庫を何度も往復すること
になります。
1往復1材料しか持っていかなかったら効率悪いですよね。

実際のコードでN+1を認識しよう

先ほどの説明で
N+1が発生すると、とりあずパフォーマスが悪化する
ということはご理解いただけたかなと思います。

でも、コードにすると何がN+1に該当するのかまで紐づいてないと思うので、
実際にコードをみながら確認していきましょう。

構成

今回はUserテーブルとTaskテーブルの2テーブル構成とします。
アプリとしてはタスク管理アプリを想定しています。

user.rb
class User < ApplicationRecord
  has_many :tasks
end
task.rb
class Task < ApplicationRecord
  belongs_to :user
end

画面はこんな感じ。
各ユーザーに紐づくタスクを表示する。

コード

view側で各ユーザーに紐づくタスクを表示するという機能を実装します。

users_controller
class UsersController < ApplicationController
  def index
    @users = User.all
  end
end
index.html.erb
<% @users.each do |user| %>
  <div>ユーザー名: <%= user.name %></div>
  <% user.tasks.each do |task| %>
    <div>タスク: <%= task.title %></div>
  <% end %>
<% end %>

どこがN+1なの?

今回問題となる箇所がここです。

index.html.erb
<% user.tasks.each do |task| %>
  <div>タスク: <%= task.title %></div>
<% end %>

何が起こってる?

userを表示させるためのループ処理の中でそれに紐づくタスクを都度取得しに行ってしまっています。
実際に発行されているSQLが以下になります。

  User Load (0.9ms)  SELECT `users`.* FROM `users`
  ↳ app/views/users/index.html.erb:1
  Task Load (0.6ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`user_id` = 1
  ↳ app/views/users/index.html.erb:3
  Task Load (0.4ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`user_id` = 2
  ↳ app/views/users/index.html.erb:3
  Task Load (0.1ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`user_id` = 3
  ↳ app/views/users/index.html.erb:3

taskを取得するSQLが3回にわたって呼ばれていると思います。
こやつらこそがN+1なのです。

どうしたらいいの?

N+1問題を解決する方法は複数あります。
今回はその中でも代表的な3つの方法をご紹介します。
・preload
・eager_load
・includes

preload

使い方は以下になります。

users_controller
class UsersController < ApplicationController
  def index
    @users = User.preload(:tasks)
  end
end

そして発行されたSQLは以下になります。

  User Load (1.0ms)  SELECT `users`.* FROM `users`
  ↳ app/views/users/index.html.erb:1
  Task Load (1.1ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`user_id` IN (1, 2, 3)
  ↳ app/views/users/index.html.erb:1

なんと!
先程まで3回発行されたSQLが1回になってるではありませんか!
先程と違い、必要なテーブルに大して1回ずつ発行されているので、
パフォーマンス悪化を避けれます!

eager_load

使い方は以下になります。

users_controller
class UsersController < ApplicationController
  def index
    @users = User.eager_load(:tasks)
  end
end

そして発行されたSQLは以下になります。

  SQL (0.3ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`created_at` AS t0_r2, `users`.`updated_at` AS t0_r3, `tasks`.`id` AS t1_r0, `tasks`.`title` AS t1_r1, `tasks`.`user_id` AS t1_r2, `tasks`.`created_at` AS t1_r3, `tasks`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `tasks` ON `tasks`.`user_id` = `users`.`id`
  ↳ app/views/users/index.html.erb:1

おや?
今までと違い、SQLは1回になり、見慣れないSQL文が発行されましたね。
eager_loadはユーザーテーブルとタスクテーブルをJoinしているため、一回のクエリで済んでいます。

1回のSQLでデータを取得してくるので、一見preloadよりもパフォーマンスが高いと考える方もいるかもしれませんが、取得してくるデータ量が多ければ多いほど、発行されるSQLが長くなってしますので、パフォーマンスは低下してしまう可能性があります。

ただ、最初の複数回発行されるSQLと比べると1回のSQLで済んでるのでN+1問題は解決されてますね!

includes

使い方は以下になります。

users_controller
class UsersController < ApplicationController
  def index
    @users = User.includes(:tasks)
  end
end

そして発行されたSQLは以下になります。

  User Load (1.0ms)  SELECT `users`.* FROM `users`
  ↳ app/views/users/index.html.erb:1
  Task Load (1.1ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`user_id` IN (1, 2, 3)
  ↳ app/views/users/index.html.erb:1

このSQLさっきみたいような...
includesはpreloadとはeager_loadをよしなに使い分けてくれるんです!

『めっちゃ便利やん!これはもうincludes一択やん!』
と思ったそこの前の僕。
それはちょっと違うんだ。

これはデータ量が起因しています。
規模が小さいうちはどちらのSQLが発行されてもパフォーマンスに影響はないのですが、
データ量が多くなるとpreloadの方がパフォーマンスが良いです。
しかし、inludesを使用しているとデータ量ではなく、データの感れ付けにのともなってどちらを使用するか決められるため、予期せぬパフォーマンス悪化を生むことがあります。

なので、
preloadとeager_loadの特性を理解して、適切に使いわけることがよしとされています。

preloadとeager_loadの使い分けのポイントは?

preloadはhas_manyの関連のデータを使う時
eager_loadは関連先の条件で絞り込みたい時
で使いわけをしたらひとまずは大丈夫です!

最後に

いかがだったでしょうか?
N+1問題の概要や対策について少しでも理解が深まったなら幸いです!

Discussion