環境
Ruby 2.7.3
Rails 6.1.4
N+1問題とは
必要以上にSQLが発行されてしまい、パフォーマンスが悪くなる問題のこと。
実際にN+1問題の例を以下に示す。
シンプルにUserカラム
とGroupカラム
が中間テーブルGroupUser
を跨いで多対多の関係になっている。
N+1問題について考えず、各GroupのUserに対してある処理をしたい。
以下のようにUserデータを取得するために、随所随所でSQLが発行されておりパフォーマンスが悪い。
問題のあるコード
Groups.each do |group|
group.users.each do |user|
id = user.id
pp id
end
end
---以下 実行内容---
Group Load (26.8ms) SELECT `group`.* FROM `group`
User Load (17.2ms) SELECT `user`.* FROM `user` INNER JOIN `group_user` ON `user`.`user_id` = `group_user`.`user_id` WHERE `group_user`.`group_id` = 97
"sample37"
"sample26"
"sample41"
"sample54"
User Load (16.5ms) SELECT `user`.* FROM `user` INNER JOIN `group_user` ON `user`.`user_id` = `group_user`.`user_id` WHERE `group_user`.`group_id` = 98
# ↑の処理が随所で出てくるのはよろしくない
"sample36"
"sample12"
"sample4"
"sample49"
User Load (15.3ms) SELECT `user`.* FROM `user` INNER JOIN `group_user` ON `user`.`user_id` = `group_user`.`user_id` WHERE `group_user`.`group_id` = 99
"sample2"
"sample51"
"sample48"
"sample24"
解決方法
・includes メソッド(極力使わない)
・preloadメソッド
・eager_loadメソッド
3種類あるけど、includeメソッドは極力使わず、preload, eager_load のどちらかに
絞りたい。includesがダメなわけではないが、思考停止でincludesに頼るのは良くない。
▼以下記事を参照▼
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由
includesは利用しない方が良いでしょう。
なぜなら、includesは、preloadとeager_loadをよしなに振り分けるので。
preloadと、eager_loadの特徴を理解していれば、includesが登場する場面は、
ほとんどないと思います。データが少ないうちはincludesしていても問題にならないかもしれませんが、
データが増えてきたときにジワジワと問題が顕在化してくるので、includesの挙動も正しく知って
おきましょう。リファクタの際に役立つと思います。
使い分けとしては、関連先のテーブルデータを
・参照した絞り込みを行う場合:eager_load
・参照せず、アソシエーションを取得する場合:preload
では、実際にそれぞれのメソッドでどのようなSQLが走るかを確認します。
preload メソッドを使用した場合
実は、includesメソッド
と全く同じSQLを発行することがわかる。
つまり、今回の例ではincludesメソッド
の中でpreload
が選択され、実行されている。
Groups.preload(:users).each do |group|
group.users.each do |user|
id = user.id
pp id
end
end
--- 以下 実行結果 ---
Group Load (225.8ms) SELECT `group`.* FROM `group` WHERE `group`.`ttt_status` = 'checking'
GroupUser Load (12.3ms) SELECT `group_user`.* FROM `group_user` WHERE `group_user`.`group_user_id` IN (97, 98, 99, 100, 101)
User Load (12.6ms) SELECT `user`.* FROM `user` WHERE `user`.`user_id` IN (39, 28, 43, 56, 38, 14, 5, 51, 3, 53, 50, 26, 1, 42, 40, 18, 29, 45, 20, 23)
"sample37"
"sample26"
"sample41"
"sample54"
"sample12"
"sample4"
"sample49"
"sample2"
"sample51"
"sample48"
"sample24"
eager_load メソッドを使用した場合
name
やslack_id
などのアソシエーション先であるUser
カラム情報も取得している。
そして、特徴的なのは1回のクエリで関連先のデータを取得していること。
そのため、関連先のモデルデータで絞り込みができる。
Groups.eager_load(:users).each do |group|
group.users.each do |user|
id = user.id
pp id
end
end
--- 以下 実行結果 ---
SQL (168.2ms) SELECT `group`.`group_id` AS t0_r0,
`group`.`event_date` AS t0_r1, `group`.`group_name` AS t0_r2,
`group`.`confirm_flag` AS t0_r3, `group`.`status` AS t0_r4,
`group`.`delete_flag` AS t0_r5, `group`.`deletion_datetime` AS t0_r6,
`group`.`registration_datetime` AS t0_r7, `group`.`update_datetime` AS t0_r8,
`group`.`update_timestamp` AS t0_r9,
`user`.`user_id` AS t1_r0,
`user`.`name` AS t1_r1, `user`.`slack_id` AS t1_r2, `user`.`image_url` AS t1_r3,
`user`.`email` AS t1_r4, `user`.`recess_flag` AS t1_r5,
`user`.`delete_flag` AS t1_r6, `user`.`deletion_datetime` AS t1_r7,
`user`.`registration_datetime` AS t1_r8, `user`.`update_datetime` AS t1_r9,
`user`.`update_timestamp` AS t1_r10
FROM `group` LEFT OUTER JOIN `group_user`
ON `group_user`.`group_id` = `group`.`group_id`
LEFT OUTER JOIN `user` ON `user`.`user_id` = `group_user`.`user_id`
WHERE `group`.`status` = 'checking'
"sample37"
"sample26"
"sample41"
"sample54"
"sample12"
"sample4"
"sample49"
"sample2"
"sample51"
"sample48"
"sample24"
-----補足-----
以下のように、連結先のテーブルの条件で絞り込みをしたい時に使える。
※Group テーブルに GroupUser テーブルを連結し、その GroupUserにおいて、status='present'で絞る
Group.eager_load(:group_user).where(group: {status: 'present'})
結論
N+1問題を解決するためにincludes メソッド
、preloadメソッド
、eager_loadメソッド
を使用する。includes メソッド
は極力使わず、やりたいことに合わせてpreloadメソッド
、eager_loadメソッド
で取捨選択する。
参考
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い