N+1問題について
N+1問題って?
ループ処理の中で、都度SQLを発行して、大量のSQLが発行されてパフォーマンスが低下してしまう問題のこと。
初めの一回のSQLでModelを取得して、そのModelに対するデータ数分(N回)のSQLが実行されてしまう。
データ量(N)+1回文のSQLが実行されて、パフォーマンスの低下につながる問題。
N+1問題の対策として、preload
やeager_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問題ができるかもしれないと推測。
上記のように関連しているテーブルの情報を先に読み込んでおく方法として、
ActiveRecordのpreload
メソッド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で全てのデータを取得している。