マッスル・メモリー

筋肉エンジニアのブログ

【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の方が速いケースもあるがレコード数が増えることによって変わることがあるので注意。