N+1を解消する方法としてincludes
を使うことが多いと思います。includes
を使うrailsがよしなにpreload
とeager_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]
該当箇所を見てみると下記のような実装になっていることがわかりました👀
これだけだとまだpreload
とeager_load
がどう使い分けてるかわかりませんが、とりあえずincludes
の中を読み進めていきます。
def includes(*args) check_if_method_has_arguments!(:includes, args) spawn.includes!(*args) end
includes
の中ではcheck_if_method_has_arguments
とincludes!
が実行されています。
check_if_method_has_arguments
は引数が設定されているかを確認しているだけのようなので、実際の処理はincludes!
が実行されているようですね。
includes!
に中では、args.compact_blank!
で空白の引数を削除して、args.flatten!
で引数のネストをなくしています。
def includes!(*args) # :nodoc: args.compact_blank! args.flatten! self.includes_values |= args self end
self.includes_values |= args
でincludes_values
がfalse
の場合に整形した引数をincludes_values
代入しています。
で最後にself(ActiveRecord_Relation
のオブジェクト)を返却しています。
ちなみにpreload
やeager_load
では、それぞれpreload_values
、eager_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
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
実際にやってみると、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?
がpreload
かeager_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
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
preload
はpreload_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
まとめ
結論として今回のケースは、
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_load
とpreload
を使い分けているかと見ていきました。
内部実装を見てみるとActiveRecordがいかによくできているかがわかりますね✨
N+1を解消するためにはpreload(別SQLで事前にとっとく)するかeager_load(JOINしてとる)するかの2パターンありますが、 それをいい感じに判断して、使いやすいインターフェースで提供するというのは、今回実装を見てみて大変だということがわかりました💦
しかし、includesのロジックは結構複雑かつ変わる可能性があるので、明示的にpreload、eager_loadしたいときは、ちゃんと指定したほうがよさそうですね。