Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: includesがどうやってpreloadとeager_loadを使い分けてるか調べてみた。

N+1を解消する方法としてincludesを使うことが多いと思います。includesを使うrailsがよしなにpreloadeager_loadを使い分けてくれますが、その使い分けの条件を知らずに、とりあえずincludesを使ってしまうと思いも寄らないSQLが実行されてしまう恐れがあります。

https://railsguides.jp/active_record_querying.html#関連付けの一括読み込みで条件を指定する

自分もいままで、結構なんとなくで使ってしまうことも多い気がしたので、実際のrailsのコードを読んで、どういう条件でrailsが判断しているか見てみました👀

ちなみにrails 6のstable版のコードで見ています。

includesの実装を見る

まずincludesのコードを読むためにどこに定義されているかを、source_locationを使って調べてみます。

Entry.all.method(:includes).source_location
=> ["PATH/vendor/bundle/ruby/2.6.0/gems/activerecord-5.2.3/lib/active_record/relation/query_methods.rb", 114]

該当箇所を見てみると下記のような実装になっていることがわかりました👀 これだけだとまだpreloadeager_loadがどう使い分けてるかわかりませんが、とりあえずincludesの中を読み進めていきます。

def includes(*args)
  check_if_method_has_arguments!(:includes, args)
  spawn.includes!(*args)
end

https://github.com/rails/rails/blob/783cafee4f3e1b1c1f8edf4ad915f66cf84e0552/activerecord/lib/active_record/relation/query_methods.rb#L135-L138

includesの中ではcheck_if_method_has_argumentsincludes!が実行されています。

check_if_method_has_argumentsは引数が設定されているかを確認しているだけのようなので、実際の処理はincludes!が実行されているようですね。

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation/query_methods.rb#L1328-L1332

includes!に中では、args.compact_blank!で空白の引数を削除して、args.flatten!で引数のネストをなくしています。

def includes!(*args) # :nodoc:
  args.compact_blank!
  args.flatten!

  self.includes_values |= args
  self
end

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation/query_methods.rb#L140-L146

self.includes_values |= argsincludes_valuesfalseの場合に整形した引数をincludes_values代入しています。 で最後にself(ActiveRecord_Relationのオブジェクト)を返却しています。

ちなみにpreloadeager_loadでは、それぞれpreload_valueseager_load_valuesに引数を入れて、selfを返却しているようです。

これだけみるとincludesではqueryを発行せずに単純にincludes_valuesに値を入れているだけなんですね🤔

includes_valuesの使われ方を見る

とりあえず、このようなActiveRecordのqueryを実行した際にincludesはpreloadを使うかeager_loadを使うか判定して使い分けられています。

Entry.includes(:feed).all
=> Entry.includes(:feed).all
  Entry Load (2.5ms)  SELECT "entries".* FROM "entries"
  Feed Load (0.4ms)  SELECT "feeds".* FROM "feeds" WHERE "feeds"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)  [["id", 77], ["id", 78], ["id", 70], ["id", 71], ["id", 72], ["id", 73], ["id", 74], ["id", 75], ["id", 76], ["id", 50], ["id", 79], ["id", 66]]

Entry.includes(:feed).where(feeds: { id: 1 }).all
=> SQL (1.0ms)  SELECT "entries"."id" AS t0_r0, "entries"."feed_id" AS t0_r1, "entries"."title" AS t0_r2, "entries"."link" AS t0_r3, "entries"."eye_catching_image" AS t0_r4, "entries"."description" AS t0_r5, "entries"."published_at" AS t0_r6, "entries"."created_at" AS t0_r7, "entries"."updated_at" AS t0_r8, "feeds"."id" AS t1_r0, "feeds"."title" AS t1_r1, "feeds"."endpoint" AS t1_r2, "feeds"."created_at" AS t1_r3, "feeds"."updated_at" AS t1_r4, "feeds"."last_published_at" AS t1_r5 FROM "entries" LEFT OUTER JOIN "feeds" ON "feeds"."id" = "entries"."feed_id" WHERE "feeds"."id" = $1  [["id", 1]]

とりあえず最終的にSQLを実行しているところを見ていくと、eager_loading?でeager_loadingを行うかcheckして、skip_preloading_valueでpreloadするかどうかどうかチェックしているようでした👀

module ActiveRecord
  class Relation
     def exec_queries(&block)
        skip_query_cache_if_necessary do
          @records =
            if eager_loading?
              apply_join_dependency do |relation, join_dependency|
                if relation.null_relation?
                  []
                else
                  relation = join_dependency.apply_column_aliases(relation)
                  rows = connection.select_all(relation.arel, "SQL")
                  join_dependency.instantiate(rows, &block)
                end.freeze
              end
            else
              klass.find_by_sql(arel, &block).freeze
            end

          # ここは`skip_preloading_value!`が実行されない限りはfalse
          preload_associations(@records) unless skip_preloading_value

          @records.each(&:readonly!) if readonly_value

          @loaded = true
          @records
        end
      end

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation.rb#L808-L832

eager_loading?の中身をみていくと下記のような条件で検証しています。

module ActiveRecord
  class Relation
    def eager_loading?
      @should_eager_load ||=
        eager_load_values.any? ||
        includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
    end

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation.rb#L679-L683

実際にやってみると、eager_loading?の結果が変わり、eager_loadが実行される方がtrueになることがわかります👀

# eager_loadのケース
[48] pry(main)> Entry.includes(:feed).where(feeds: { id: 1 }).eager_load_values.any?
=> false
[49] pry(main)> Entry.includes(:feed).where(feeds: { id: 1 }).includes_values.any?
=> true
[50] pry(main)> Entry.includes(:feed).where(feeds: { id: 1 }).joined_includes_values.any?
=> false
[53] pry(main)> Entry.includes(:feed).where(feeds: { id: 1 }).send(:references_eager_loaded_tables?)
=> true

# preloadのケース
[54] pry(main)> Entry.includes(:feed).eager_load_values.any?
=> false
[55] pry(main)> Entry.includes(:feed).includes_values.any?
=> true
[56] pry(main)> Entry.includes(:feed).joined_includes_values.any?
=> false
[57] pry(main)> Entry.includes(:feed).send(:references_eager_loaded_tables?)
=> false

※joined_includes_valuesはjoinsで引数に渡した値が取得されますので、joinsした場合にもeager_load?はtrueになります。

$ Entry.joins(:feeds).joins_values
=> [:feeds]

今回のケースだとreferences_eager_loaded_tables?preloadeager_loadを判断する条件になっているようです。

module ActiveRecord
  class Relation
    private
      def references_eager_loaded_tables?
        joined_tables = arel.join_sources.map do |join|
          if join.is_a?(Arel::Nodes::StringJoin)
            tables_in_string(join.left)
          else
            [join.left.table_name, join.left.table_alias]
          end
        end

        joined_tables += [table.name, table.table_alias]

        # always convert table names to downcase as in Oracle quoted table names are in uppercase
        joined_tables = joined_tables.flatten.compact.map(&:downcase).uniq

        (references_values - joined_tables).any?
      end

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation.rb#L848-L855

references_eager_loaded_tables?の実装を見てみると(references_values - joined_tables).any?が返却されています。

[80] pry(main)> Entry.includes(:feed).where( feeds: {id: 1} ).references_values
=> ["feeds"]
[81] pry(main)> Entry.includes(:feed).references_values
=> []

実際のケースで実行してみるとeager_loadのケースではreferences_valuesに値が入るので、references_eager_loaded_tables?の返り値は、eager_loadのケースではtrue、preloadのケースではfalseになる。※references_valuesは、feed: {id: 1}のを解析して関連するassosiationを取得するやつです。(https://github.com/rails/rails/blob/5ae48c43a224bf87fb30f3a322cc4fb93b5a25a8/activerecord/lib/active_record/relation/predicate_builder.rb#L24-L33)

というわけで、以上のような流れでeager_loadが実行されます。 ではpreloadはどうなのでしょうか?もう一度exec_queriesを見てみます👀

module ActiveRecord
  class Relation
     def exec_queries(&block)
        skip_query_cache_if_necessary do
          @records =
            if eager_loading?
              apply_join_dependency do |relation, join_dependency|
                if relation.null_relation?
                  []
                else
                  relation = join_dependency.apply_column_aliases(relation)
                  rows = connection.select_all(relation.arel, "SQL")
                  join_dependency.instantiate(rows, &block)
                end.freeze
              end
            else
              klass.find_by_sql(arel, &block).freeze
            end

          # ここは`skip_preloading_value!`が実行されない限りはfalse
          preload_associations(@records) unless skip_preloading_value

          @records.each(&:readonly!) if readonly_value

          @loaded = true
          @records
        end
      end

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation.rb#L808-L832

preloadpreload_associationsで実行されてそうですね、中をみていくとeager_loading?falseだったらincludes_valuesをpreload対象に追加してます。

なので、includes_valuesに指定した値が2重で読み込まれてしまうようなことは無さそうですね🙌

module ActiveRecord
  class Relation
    def preload_associations(records) # :nodoc:
      preload = preload_values
      preload += includes_values unless eager_loading?
      preloader = nil
      preload.each do |associations|
        preloader ||= build_preloader
        preloader.preload records, associations
      end
    end

https://github.com/rails/rails/blob/6bf2e59becc0d9cf884f28eac61d7f0aeae75c23/activerecord/lib/active_record/relation.rb#L740-L748

まとめ

結論として今回のケースは、

  • Entry.includes(:feed).where(feeds: { id: 1 })
    • whereで別TBLを参照している(refarences_valuesがある)ので、eager_loading?trueになり、eager_loadが実行される。
    • joinsで別TBLをJOINしてても、eager_loading?はtrueとなるので、eager_loadが実行される。
  • Entry.includes(:feed)
    • whereで別TBLを参照していない(refarences_valuesがない)ので、eager_loading?falseになり、preloadが実行される。

ということのようですね😀

おわりに

今回はincludesがどうやってeager_loadpreloadを使い分けているかと見ていきました。 内部実装を見てみるとActiveRecordがいかによくできているかがわかりますね✨

N+1を解消するためにはpreload(別SQLで事前にとっとく)するかeager_load(JOINしてとる)するかの2パターンありますが、 それをいい感じに判断して、使いやすいインターフェースで提供するというのは、今回実装を見てみて大変だということがわかりました💦

しかし、includesのロジックは結構複雑かつ変わる可能性があるので、明示的にpreload、eager_loadしたいときは、ちゃんと指定したほうがよさそうですね。