0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

includes, preload, eager_load 完全に理解した(してない)

Last updated at Posted at 2025-07-10

こんにちは、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本
preloadeager_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. コード例

  • load_asyncするパターン

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
  • load_asyncしないパターン

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_countasync_sumなんかも入ってるとのこと。参考

3-3. 注意点

大量に load_async を連発すると ConnectionPoolの枯渇を招く可能性あり。

4. 参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?