こんにちは、kyamaです。
RailsにおけるEager Loadingって難しいですよね。自分の中では未だに理解がふわっとしています。
私が開発に携わっているアプリケーションも肥大化してきて、雑な「とりあえずincludes」では通用しなくなってきたので、良い機会と思い備忘録も兼ねて改めて復習しました。
記載内容はできる限り検証用コードを動かして確認しましたが、誤りや誤解を招く表現があったら申し訳ありません。
ベストプラクティスについても調べてみたのですが、アプリの規模ややりたいこと、チームの方針などによって最適解が違うなと感じたので、今回はあえて触れていません。
本記事はあくまで参考情報としてご覧ください。
前提構成
バージョン情報
- Rails 8.0.2
- Ruby 3.4.4
- PostgreSQL 15
ER図
Association
- Author has_many Book
- Book belongs_to Author, has_many Review
- Review belongs_to Book
検証データ
サンプルデータは10 * 3 * 3で準備。
- Author: 10件
- Book: 30件
- Review: 90件
1. 各メソッドの概要比較
preload
-
概要
複数のSQLクエリを発行して、メインとなるテーブルと関連テーブルを別々に取得する。
主キーのIN句を使って関連レコードを効率的に取得し、Ruby側でそれらを関連付ける。
仕様として、関連テーブルのカラムに対してwhere/orderを使うことはできない。
IN句に悍ましいほどのID達が突っ込まれがち。発行されるクエリに関しては
includes
の項に記載。
eager_load
-
概要
常に1つのSQLクエリでLEFT OUTER JOINを使って関連テーブルを含めて一度に取得する。
こちらは関連テーブルのカラムに対するwhere/orderが使用できるが、取得時に大量レコードの取得、読み込みが発生する可能性がある。発行されるクエリに関しては
includes
の項に記載。
includes
-
概要
includesはN+1問題を解決するための最も汎用的なメソッド。Railsが関連テーブルの利用状況に応じて preload か eager_load を自動で選択し、関連テーブルのカラムを where/order で使う場合は自動的に JOIN に切り替え、それ以外は複数クエリに分割する。
昔はどの読み込みメソッドを使うべきか迷ったときは「とりあえず includes」と言われがちだったが、実際にはちいかわ然に「なんとかなれーッ!」と投げやりに使っても、本当に何とかしたい場面ではたいてい何ともならない。
最近だとどちらかというとバッドプラクティスよりっぽい旨の記載が散見されている。
-
発行クエリ
実装によりRailsが自動判定し、「includesしたModel数に応じてSQLを実行」 or 「LEFT OUTER JOINのSQLが1本実行」のどちらかとなる。
-
1. includesしたModel数に応じてSQLを実行(=
preload
)# authorのみincludesした場合 Book.includes(:author).each { |book| book.author.name }
-- SQLは2回実行される #=> SELECT "books".* FROM "books" #=> SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
# author, reviewsをincludesした場合 Book.includes(:author, :reviews).each { |book| [book.author.name, book.reviews.size] }
-- SQLは3回実行される #=> SELECT "books".* FROM "books" #=> SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) #=> SELECT "reviews".* FROM "reviews" WHERE "reviews"."book_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
また厳密に言うと、has_many ~ throughで中間テーブルを介して紐づくテーブルの情報を取得しようとした際は、中間テーブル分もクエリが発行されたりはするが、一旦ここでは取り扱わない。
-
2. LEFT OUTER JOINのSQLが1本実行(=
eager_load
)# 子テーブルのカラムに対してwhere条件を設定 Book.includes(:author).where(authors: { name: 'Author 1' }).each { |book| book.title }
-- LEFT OUTER JOIN を利用したSQLが実行される SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, "books"."author_id" AS t0_r2, "books"."created_at" AS t0_r3, "books"."updated_at" AS t0_r4, "authors"."id" AS t1_r0, "authors"."name" AS t1_r1, "authors"."created_at" AS t1_r2, "authors"."updated_at" AS t1_r3 FROM "books" LEFT OUTER JOIN "authors" ON "authors"."id" = "books"."author_id" WHERE "authors"."name" = 'Author 1'
-
-
preload, eager_loadどちらを使うかの判定
下記で判定している。
# activerecord/lib/active_record/relation.rb # Returns true if relation needs eager loading. def eager_loading? @should_eager_load ||= eager_load_values.any? || includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?) end
-
@should_eager_load
eager_loadを使用するかどうかをbooleanで保持。最初の実行時に判定結果が代入されて使い回される。以降の不要な判定を省略するためのキャッシュ的なやつ。
-
eager_load_values
明示的にeager_loadを指定したモデル名がsymbolの配列形式で格納される。
[:book, :author] みたいな感じ。
-
includes_values
明示的にincludesを指定したモデル名がsymbolの配列形式で格納される。
同じく [:book, :author] みたいな感じ。
-
joined_includes_values
実装は下記。
# activerecord/lib/active_record/relation.rb def joined_includes_values includes_values & joins_values end
つまり、includesで指定したモデルとjoinsで指定したモデルの積集合。
両方で指定されているモデル名のsymbolを取得する。
-
references_eager_loaded_tables?
実装は下記。
def references_eager_loaded_tables? joined_tables = build_joins([]).flat_map do |join| if join.is_a?(Arel::Nodes::StringJoin) tables_in_string(join.left) else join.left.name end end joined_tables << table.name # always convert table names to downcase as in Oracle quoted table names are in uppercase joined_tables.map!(&:downcase) !(references_values.map(&:to_s) - joined_tables).empty? end
「明示的にreferencesで指定されたテーブル名」-「JOIN済みのテーブル名」を行い、未JOINかつreferences指定のテーブルがないかチェックしている。
Book.includes(:author).references(:authors).where("authors.name = ?", "太郎") #=> true Book.joins(:author).includes(:author).references(:authors).where("authors.name = ?", "太郎") #=> false # ただ、こんな書き方をするユースケースが思いつかない
また下記のようなコードも内部的にreferencesが付与されるようで、references_valuesにauthorsが入ってくる。
これが関連テーブルにwhereやorderを指定した際にeager_loadを行うように判定している部分に相当している。# referencesが内部的に付与される Book.includes(:author).where(authors: {name: "太郎"}).each { |book| book.title } Book.includes(:author).order("authors.name DESC").each { |book| book.title } # where句に生SQLを渡す場合は明示的にreferencesを指定しないとエラーになる Book.includes(:author).where("authors.name LIKE ?", "%Author%").each { |book| book.title } #=> PG::UndefinedTable: ERROR: missing FROM-clause entry for table "authors" (ActiveRecord::StatementInvalid)
長々と記載したが、つまるところeager_loadになる条件は下記。
これをいずれかを満たす時eager_loadとしてふるまい、一つも満たさないときはpreloadとしてふるまう。- 関連テーブルのカラムをwhere条件に使用
- 関連テーブルのカラムをorderに使用
- referencesで関連テーブルを明示的に指定
- 同じモデルに対してjoinsとincludesを併用
-
簡易まとめ
- 早見表
メソッド | 発行クエリ | JOIN の有無 |
where /order で関連テーブル利用 |
---|---|---|---|
includes |
関連テーブル数分 or LEFT OUTER JOIN 1本preload かeager_load どちらかが使用される |
Rails が判定 | 可能 |
preload |
関連テーブル数分 | しない | 不可 |
eager_load |
LEFT OUTER JOIN 1本 | する | 可能 |
-
preload, eager_loadの判定基準
下記いずれかを満たす時eager_load。そうではないときpreload。
- 関連テーブルのカラムをwhere条件に使用
- 関連テーブルのカラムをorderに使用
- referencesで関連テーブルを明示的に指定
- 同じモデルに対してjoinsとincludesを併用
2. 実例で見るSQLの違い
2-1. N+1 (指定なし)
Book.all.each { |book| book.author.name }
-- クエリ①
SELECT "books".* FROM "books";
-- クエリ②以降 (Book 件数分繰り返し)
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1 LIMIT 1;
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2 LIMIT 1;
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3 LIMIT 1;
…
2-2. includes(:author)
Book.includes(:author).each { |book| book.author.name }
-- クエリ①
SELECT "books".* FROM "books"
-- クエリ② (IN 句)
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (...);
2-3. preload(:author)
Book.preload(:author).each { |book| book.author.name }
-- 2-2. `includes(:author)` と同様
-- クエリ①
SELECT "books".* FROM "books"
-- クエリ② (IN 句)
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (...);
2-4. eager_load(:author)
Book.eager_load(:author).each { |book| book.author.name }
SELECT
"books"."id" AS t0_r0,
"books"."title" AS t0_r1,
"books"."author_id" AS t0_r2,
"books"."created_at" AS t0_r3,
"books"."updated_at" AS t0_r4,
"authors"."id" AS t1_r0,
"authors"."name" AS t1_r1,
"authors"."created_at" AS t1_r2,
"authors"."updated_at" AS t1_r3
FROM
"books"
LEFT OUTER JOIN
"authors"
ON
"authors"."id" = "books"."author_id"
2-5. includes(:author, :reviews)
Book.includes(:author, :reviews).each { |book| [book.author.name, book.reviews.size] }
-- クエリ①
SELECT "books".* FROM "books";
-- クエリ②
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (...);
-- クエリ③
SELECT "reviews".* FROM "reviews" WHERE "reviews"."book_id" IN (...);
2-6. includes + where(文字列) + references
Book.includes(:author).where('authors.name LIKE ?', 'Author%').references(:author).each { |book| book.title }
SELECT
"books"."id" AS t0_r0,
"books"."title" AS t0_r1,
"books"."author_id" AS t0_r2,
"books"."created_at" AS t0_r3,
"books"."updated_at" AS t0_r4,
"authors"."id" AS t1_r0,
"authors"."name" AS t1_r1,
"authors"."created_at" AS t1_r2,
"authors"."updated_at" AS t1_r3
FROM
"books"
LEFT OUTER JOIN
"authors"
ON
"authors"."id" = "books"."author_id"
WHERE
(authors.name LIKE 'Author%')
2-7. includes + order(文字列) + references
Book.includes(:author).order('authors.created_at DESC').references(:author).each { |book| book.title }
SELECT
"books"."id" AS t0_r0,
"books"."title" AS t0_r1,
"books"."author_id" AS t0_r2,
"books"."created_at" AS t0_r3,
"books"."updated_at" AS t0_r4,
"authors"."id" AS t1_r0,
"authors"."name" AS t1_r1,
"authors"."created_at" AS t1_r2,
"authors"."updated_at" AS t1_r3
FROM
"books"
LEFT OUTER JOIN
"authors"
ON
"authors"."id" = "books"."author_id"
ORDER BY
authors.created_at DESC
2-8. joins(:author)
Book.joins(:author).each { |book| book.author.name }
-- クエリ①
SELECT "books".* FROM "books" INNER JOIN "authors" ON "authors"."id" = "books"."author_id"
-- クエリ②以降 (Book 件数分繰り返し)
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1 LIMIT 1
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2 LIMIT 1
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3 LIMIT 1
…
2-9. left_joins(:author)
Book.left_joins(:author).each { |book| book.author.name }
-- クエリ①
SELECT "books".* FROM "books" LEFT OUTER JOIN "authors" ON "authors"."id" = "books"."author_id"
-- クエリ②以降 (Book 件数分繰り返し)
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1 LIMIT 1
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2 LIMIT 1
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3 LIMIT 1
…
2-10. includes(:author).joins(:author)
(includes + joins 同一モデル指定 )
Book.includes(:author).joins(:author).each { |book| [book.author.name, book.title] }
SELECT
"books"."id" AS t0_r0,
"books"."title" AS t0_r1,
"books"."author_id" AS t0_r2,
"books"."created_at" AS t0_r3,
"books"."updated_at" AS t0_r4,
"authors"."id" AS t1_r0,
"authors"."name" AS t1_r1,
"authors"."created_at" AS t1_r2,
"authors"."updated_at" AS t1_r3
FROM
"books"
INNER JOIN
"authors"
ON
"authors"."id" = "books"."author_id"
3. おまけ:load_async
3-1. load_async
Rails 7で追加された 非同期クエリ実行 API。
前述したincludes
, preload
, eager_load
とは少し毛色が違うが、組み合わせて使用できる。
Relation に対して load_async
を呼ぶと バックグラウンドスレッド で SQL を先行発行するというもの。
3-2. コード例
books = Book.includes(:author, :reviews).load_async # load_asyncあり
total = 0
1_000_000.times do |i|
total += i
puts "[CPU] processed #{i} iterations" if (i % 100_000).zero?
end
books.each { |book| "#{book.title} - #{book.author.name} (#{book.reviews.size})" }
# SQL実行が先(非同期実行が行われている)
DEBUG -- : Book Load (13.6ms) SELECT "books".* FROM "books" /*application='App'*/
DEBUG -- : Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) /*application='App'*/
DEBUG -- : Review Load (0.2ms) SELECT "reviews".* FROM "reviews" WHERE "reviews"."book_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30) /*application='App'*/
[CPU] processed 0 iterations
[CPU] processed 100000 iterations
[CPU] processed 200000 iterations
[CPU] processed 300000 iterations
[CPU] processed 400000 iterations
[CPU] processed 500000 iterations
[CPU] processed 600000 iterations
[CPU] processed 700000 iterations
[CPU] processed 800000 iterations
[CPU] processed 900000 iterations
books = Book.includes(:author, :reviews) # load_asyncなし
total = 0
1_000_000.times do |i|
total += i
puts "[CPU] processed #{i} iterations" if (i % 100_000).zero?
end
books.each { |book| "#{book.title} - #{book.author.name} (#{book.reviews.size})" }
[CPU] processed 0 iterations
[CPU] processed 100000 iterations
[CPU] processed 200000 iterations
[CPU] processed 300000 iterations
[CPU] processed 400000 iterations
[CPU] processed 500000 iterations
[CPU] processed 600000 iterations
[CPU] processed 700000 iterations
[CPU] processed 800000 iterations
[CPU] processed 900000 iterations
# booksが参照されたタイミングでクエリの実行が始まっている(非同期実行されていない)
DEBUG -- : Book Load (12.7ms) SELECT "books".* FROM "books" /*application='App'*/
DEBUG -- : Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) /*application='App'*/
DEBUG -- : Review Load (0.2ms) SELECT "reviews".* FROM "reviews" WHERE "reviews"."book_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30) /*application='App'*/
ちなみにRails 7.1ではasync_count
やasync_sum
なんかも入ってるとのこと。参考
3-3. 注意点
大量に load_async
を連発すると ConnectionPoolの枯渇を招く可能性あり。