ActiveRecord includes, eager_load, preload の挙動の話
ActiveRecord の N+1 問題を解消するため関連するテーブルのデータを事前に読み込みキャッシュしておく、その方法に includes, eager_load, preload メソッドがある。
それぞれ以下の理解でいて、とりあえず includes を使ってそこからパフォーマンスに難があればメンテしていけば良いっぽい という雑な考えで includes を採用した処理があった。
まず、joins はデフォルトで INNER JOIN を行うが association のキャッシュはしない。
- includes
- キャッシュはする
- 実際に発行されるクエリが一つで済むか(指定した association を複数のクエリに分けるので)複数になるかは場合による、以下の通り
- eager_load
- 指定した association を外部結合(LEFT OUTER JOIN)しキャッシュする、そのため実際に発行されるクエリは一つで済む
- preload
- 指定した association を別クエリで取得してキャッシュするので、複数のクエリが発行される
その後パフォーマンスに懸念が出てきたので eager_load に変えたがその時見落としていたことがあり、
has_many :hoge, -> { order(position: :asc) }, dependent: :delete_all
のように中間テーブルを has_many で定義した際の order のスコープブロックが無視され ORDER BY hoge.position ASC
が付与されなかったというものだった。
挙動として preload が 指定したassociationを別クエリで取得
に対し eager_load は LEFT_OUTER_JOIN で指定したテーブルを結合してキャッシュする
のであれば、その際引数を渡して絞り込みやソートなどを指定していなかったので当然かも。と思い order を明示的に指定することで対処したのだが、改めてincludes, eager_load, preload についてちゃんとコードを追う。
※ v6.0.4.6 で確認している github.com
それぞれ以下のように定義されている
includes
def includes(*args) check_if_method_has_arguments!(:includes, args) spawn.includes!(*args) end def includes!(*args) # :nodoc: args.reject!(&:blank?) args.flatten! self.includes_values |= args self end
eager_load
def eager_load(*args) check_if_method_has_arguments!(:eager_load, args) spawn.eager_load!(*args) end def eager_load!(*args) # :nodoc: self.eager_load_values |= args self end
preload
def preload(*args) check_if_method_has_arguments!(:preload, args) spawn.preload!(*args) end def preload!(*args) # :nodoc: self.preload_values |= args self end
それぞれの挙動
上記のメソッドではクエリを発行せず ActiveRecord::Relation#load
時の exec_queries
メソッド内 eager_loading?
で、eager_load か preload かを判定しているのがわかる
https://github.com/rails/rails/blob/v6.0.4.6/activerecord/lib/active_record/relation.rb
def load(&block) exec_queries(&block) unless loaded? self end
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 preload_associations(@records) unless skip_preloading_value @records.each(&:readonly!) if readonly_value @loaded = true @records end end
eager_loading?
では以下のいずれかで true になる
- eager_load_values がある = eager_load methodを使用
- includes_values がある(includes method使用) かつ、joins methodを使用している
- includes_values がある(includes method使用) かつ、
references_eager_loaded_tables?
が true であるincludes(:association).references(:association)
である
def eager_loading? @should_eager_load ||= eager_load_values.any? || includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?) end
再び exec_queries
に戻ると、eager_loading?
が true の場合は apply_join_dependency do ~ end
の処理で
- eager_load_values, includes_values を join 条件とした後 includes, eager_load, preload の指定テーブルを除外した上で JoinDependency を生成
- 対象の column を select 句に足し
- 最終的にJOIN句を生成
となり、eager_loading?
が false の場合
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
preload_values に includes_values が足され preload される。apply_join_dependency の挙動追うの難しい....
そして ActiveRecord::Relation#exec_queries
での
# 2. 対象の column を select 句に足し # 3. 最終的にJOIN句を生成 relation = join_dependency.apply_column_aliases(relation) rows = connection.select_all(relation.arel, "SQL") join_dependency.instantiate(rows, &block)
このあたりの eager_load 時の処理の際、冒頭で書いた association の has_many で定義した際の order のスコープブロック
を考慮するというようにはなっていないので(order に限らず where, distinct など標準のクエリメソッドは同様)
includes から eager_load に変えた際「その際引数を渡して絞り込みやソートなどを明示的に指定していなかった」から order は無視されているのは確かにそうだったという感じだった。(以前は includes 指定の際、内部的には preload でクエリ発行されていた)
def ordered_relation if order_values.empty? && (implicit_order_column || primary_key) order(arel_attribute(implicit_order_column || primary_key).asc) else self end end
曖昧な知見、誤った知見はちゃんとアプデするぞ取組!