補習ほぼ確

学びや好きなことをただ自由に書く

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

https://github.com/rails/rails/blob/v6.0.4.6/activerecord/lib/active_record/relation/query_methods.rb

    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

https://github.com/rails/rails/blob/v6.0.4.6/activerecord/lib/active_record/relation/query_methods.rb

    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

https://github.com/rails/rails/blob/v6.0.4.6/activerecord/lib/active_record/relation/query_methods.rb

    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 の処理で

  1. eager_load_values, includes_values を join 条件とした後 includes, eager_load, preload の指定テーブルを除外した上で JoinDependency を生成
  2. 対象の column を select 句に足し
  3. 最終的に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 でクエリ発行されていた)

https://github.com/rails/rails/blob/v6.0.4.6/activerecord/lib/active_record/relation/finder_methods.rb

      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

曖昧な知見、誤った知見はちゃんとアプデするぞ取組!