【Rails】 N + 1問題の解決法、preload, eager_load, includes, joinsの使い分け案
N+1問題とは
ループ処理の中でその都度クエリを発行してしまいパフォーマンスが低下してしまうこと。
解決策の1つは、Eager Loading(積極的な先読み)を使用すること。
Eager Loadingでは、関連するデータを事前にまとめてロードすることで、N回のクエリを削減する。
# 使用するモデルのアソシエーション class Area < ApplicationRecord has_many :sub_areas, dependent: :destroy end class SubArea < ApplicationRecord belongs_to :area end
# ループの回数分クエリが発行されてしまっている。 SubArea.all.each { |sub_area| sub_area.area } # SubArea Load (3.8ms) SELECT `sub_areas`.* FROM `sub_areas` # Area Load (0.6ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 1 LIMIT 1 # Area Load (0.3ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.5ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1
preload
アソシエーションを複数のクエリに分けてeager loadingする。
JOINしていないので、アソシエーションの値で絞り込むことはできない。
# 複数のクエリに分けてeager loadingする SubArea.preload(:area).each { |sub_area| sub_area.area } # SubArea Load (0.4ms) SELECT `sub_areas`.* FROM `sub_areas` # Area Load (0.4ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) # アソシエーションの値で絞り込むことはできないのでエラーになる。 SubArea.preload(:area).where(areas: { id: [1, 2, 3] }).each { |sub_area| sub_area.area } # SubArea Load (0.6ms) SELECT `sub_areas`.* FROM `sub_areas` WHERE `areas`.`id` IN (1, 2, 3) # ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'areas.id' in 'where clause' # Caused by Mysql2::Error: Unknown column 'areas.id' in 'where clause'
eager_load
アソシエーションを、1つのクエリ(LEFT OUTER JOIN)でまとめて取得して、eager loadingする。
JOINしているので、preloadと違って、アソシエーションの値で絞り込みができる。
# LEFT OUTER JOINしている SubArea.eager_load(:area).each { |sub_area| sub_area.area } # SQL (0.6ms) SELECT `sub_areas`.`id` AS t0_r0, `sub_areas`.`area_id` AS t0_r1, `sub_areas`.`en_name` AS t0_r2, `sub_areas`.`ja_name` AS t0_r3, `sub_areas`.`created_at` AS t0_r4, `sub_areas`.`updated_at` AS t0_r5, `areas`.`id` AS t1_r0, `areas`.`en_name` AS t1_r1, `areas`.`ja_name` AS t1_r2, `areas`.`created_at` AS t1_r3, `areas`.`updated_at` AS t1_r4 FROM `sub_areas` LEFT OUTER JOIN `areas` ON `areas`.`id` = `sub_areas`.`area_id` # LEFT OUTER JOINしているので絞り込みができる。 SubArea.eager_load(:area).where(areas: { id: [1, 2, 3] }).each { |sub_area| sub_area.area } # SQL (1.3ms) SELECT `sub_areas`.`id` AS t0_r0, `sub_areas`.`area_id` AS t0_r1, `sub_areas`.`en_name` AS t0_r2, `sub_areas`.`ja_name` AS t0_r3, `sub_areas`.`created_at` AS t0_r4, `sub_areas`.`updated_at` AS t0_r5, `areas`.`id` AS t1_r0, `areas`.`en_name` AS t1_r1, `areas`.`ja_name` AS t1_r2, `areas`.`created_at` AS t1_r3, `areas`.`updated_at` AS t1_r4 FROM `sub_areas` LEFT OUTER JOIN `areas` ON `areas`.`id` = `sub_areas`.`area_id` WHERE `areas`.`id` IN (1, 2, 3)
includes
preloadで事足りる場合はpreloadと同じ挙動(クエリを分けて実行)
無理な場合はeager_loadと同じ挙動(LEFT OUTER JOIN)をしてくれる。
アソシエーションが複数あるとき、個別に最適化できないという問題がある。
# preloadと同じ挙動 SubArea.includes(:area).each { |sub_area| sub_area.area } # SubArea Load (0.7ms) SELECT `sub_areas`.* FROM `sub_areas` # Area Load (0.5ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) # 絞り込む必要があるのでeager_loadと同じ挙動 SubArea.includes(:area).where(areas: { id: [1, 2, 3] }).each { |sub_area| sub_area.area } # SQL (1.2ms) SELECT `sub_areas`.`id` AS t0_r0, `sub_areas`.`area_id` AS t0_r1, `sub_areas`.`en_name` AS t0_r2, `sub_areas`.`ja_name` AS t0_r3, `sub_areas`.`created_at` AS t0_r4, `sub_areas`.`updated_at` AS t0_r5, `areas`.`id` AS t1_r0, `areas`.`en_name` AS t1_r1, `areas`.`ja_name` AS t1_r2, `areas`.`created_at` AS t1_r3, `areas`.`updated_at` AS t1_r4 FROM `sub_areas` LEFT OUTER JOIN `areas` ON `areas`.`id` = `sub_areas`.`area_id` WHERE `areas`.`id` IN (1, 2, 3)
joins
デフォルトでINNER JOINを行う。LEFT OUTER JOINを行いたい時はleft_joinsを使う。
アソシエーションを事前にまとめてロードしないのでN+1問題は起きてしまう。
アソシエーションの絞り込みはできる。
# N+1問題は起きる。 SubArea.joins(:area).where(areas: { id: [1, 2, 3] }).each { |sub_area| sub_area.area } # SubArea Load (0.5ms) SELECT `sub_areas`.* FROM `sub_areas` INNER JOIN `areas` ON `areas`.`id` = `sub_areas`.`area_id` WHERE `areas`.`id` IN (1, 2, 3) # Area Load (0.3ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 1 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1 # Area Load (0.2ms) SELECT `areas`.* FROM `areas` WHERE `areas`.`id` = 2 LIMIT 1
それぞれのメソッドの特徴
メソッド | eager loading有無 | 絞り込み |
---|---|---|
preload | ○ | × |
eager_load | ○ | ○ |
includes | ○ | ○ |
joins | × | ○ |
使い分けの案
- includesメソッドは一切使わない。
- preloadメソッドで事足りる場合は、preloadメソッドを使う。
- 無理な場合はeager_loadメソッドを使う。
- 絞り込みだけ必要な場合はjoinsを使う。
includesを使わないことで意図が明確になるので読み手に優しいコードになる。
preloadよりeager_loadの方が速いケースもあるがレコード数が増えることによって変わることがあるので注意。