もこたんぺ Learning Note

自分が勉強する上でわからなかったことを理解を深めていくためにまとめていく。

N+1問題について

N+1問題って?

ループ処理の中で、都度SQLを発行して、大量のSQLが発行されてパフォーマンスが低下してしまう問題のこと。
初めの一回のSQLでModelを取得して、そのModelに対するデータ数分(N回)のSQLが実行されてしまう。
データ量(N)+1回文のSQLが実行されて、パフォーマンスの低下につながる問題。
N+1問題の対策として、preloadeager_loadを使用する

例:スーパーで1点ずつお会計するような感じで、圧倒的に非効率。


ショップ(shops)とそこに所属している人(users)を例にしてみる。

shopsテーブル

id name
1  Aショップ
2  Bショップ
3  Cショップ


usersテーブル

id shop_id name
1  1    伊藤さん
2  1    八田さん
3  1    木村さん
4  2    松田さん
5  2    斉藤さん
6  3    高橋さん
7  3    橘さん


一つショップーに複数人の従業員が所属しているという関係なので、
ショップ 1: 従業員 多 となる。

それぞれのモデルは以下のようになる。

app/models/shop.rb

class Shop < ApplicationRecord
  has_many :users
end

app/models/user.r

class User < ApplicationRecord
end

各ショップに所属している人の名前を出力するプログラムを作成。

Shop.all.each do |s|
  # usersテーブルから名前を取得し、カンマ区切りで結合する
  user_names = s.users.pluck(:name).join(",")
  p "#{s.name}に所属する人は#{user_names}です"
end

上記のコードはまずショップを全て取得してくる。
その後、それぞれのショップに対してusersテーブルから名前の情報を取得してくる。
実行すると下記のような感じで出力される。

 Shop Load (0.3ms)  SELECT `shops`.* FROM `shops`
   (0.3ms)  SELECT `users`.`name` FROM `users` WHERE `users`.`shop_id` = 1
"Aショップに所属する人は伊藤さん,八田さん,木村さんです"
   (0.3ms)  SELECT `users`.`name` FROM `users` WHERE `users`.`shop_id` = 2
"Bショップに所属する人は松田さん,斉藤さんです"
   (0.3ms)  SELECT `users`.`name` FROM `users` WHERE `users`.`shop_id` = 3
"Cショップに所属する人は高橋さん,橘さんです"

上記のログから、まず最初のSQLで全てのショップの情報を取得している。
その後、ショップの数分の人の情報を取得するSQLが実行されていることがわかる。

今回の場合だと、数が少ないからそこまで問題なさそうだが、
データ数と実行する回数が同じになるので、データの数が増えたときに処理に時間がかかってしまう。


N+1問題の対策について

each よりも前にuserの情報を取得すればN+1問題ができるかもしれないと推測。
上記のように関連しているテーブルの情報を先に読み込んでおく方法として、
ActiveRecordpreloadメソッドeager_loadメソッドを使う。


preloadを使用

# すべてのショップに対し、それぞれ所属する人の名前を出力する
Shop.preload(:users).all.each do |s|
  # usersテーブルから名前を取得し、カンマ区切りで結合する
  user_names = s.users.pluck(:name).join(",")
  p "#{s.name}に所属する人は#{user_names}です"
end

shopの後にpreload(:users)を追加してみる。 これで何が起こるか。。。

Shop Load (0.3ms)  SELECT `shops`.* FROM `shops`
  User Load (0.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`shop_id` IN (1, 2, 3)
"Aショップに所属する人は伊藤さん,八田さん,木村さんです"
"Bショップに所属する人は松田さん,斉藤さんです"
"Cショップに所属する人は高橋さん,橘さんです"

N+1問題が解決されました。
先ほど4回実行されていたSQLが2回に減っていることがわかる。
ショップのデータを全て取得してから、ユーザーの情報をまとめて取得している。


eager_loadを使用

# すべてのショップに対し、それぞれ所属する人の名前を出力する
Shop.eager_load(:users).all.each do |s|
  # usersテーブルから名前を取得し、カンマ区切りで結合する
  user_names = s.users.pluck(:name).join(",")
  p "#{s.name}に所属する人は#{user_names}です"
end

実行結果を確認

SQL (0.4ms)  SELECT `shops`.`id` AS t0_r0, `shops`.`name` AS t0_r1, `shops`.`created_at` AS t0_r2, `shops`.`updated_at` AS t0_r3, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`shop_id` AS t1_r2, `users`.`created_at` AS t1_r3, `users`.`updated_at` AS t1_r4 FROM `shops` LEFT OUTER JOIN `users` ON `users`.`shop_id` = `shops`.`id`

eager_loadの場合はLEFT OUTER JOINというのが使われて、
1回のSQLで全てのデータを取得している。