ActiveRecordのincludes, preload, eager_load の個人的な使い分け

マネーフォワード福岡拠点の責任者をしております 黒田 です。
普段はRailsエンジニアとして マネーフォワードクラウド経費 の開発を担当しています。

普段Railsを使って開発されている方であれば、N+1問題に悩まされた経験は大抵の方がおありではないでしょうか。

N+1なクエリの発見には bullet を使うと良いですね。
bulletを使うとN+1なクエリを発見してくれ、さらに、具体的にここにincludesを追加しなさいと指摘までしてくれるので大変助かります。

しかし、先日bulletに言われるがままにincludesを付けてみたところ、N+1は解消したものの、スロークエリに見舞われることとなったので、includes,preload, eager_loadについて改めて調べてまとめてみることにしました。
(ソース調査したRailsのバージョンは 6.0.0.beta3 です。)

 

includesの挙動については正しく知っておきたい

includesの挙動について、「preloadeager_loadをよろしく使い分けしてくれるもの」と認識しているRailsエンジニアは結構いるのではないでしょうか?

そういったエンジニアの方の中には、先日の私のようにN+1問題の対策で深く考えずに「includesつけとけばOKでしょ。ほらbulletのN+1のアラート止まったし。」という方もいらっしゃるのではと思っています。「とりあえずincludes」で、データが少ないうちは問題にならなくてもデータが増えてきた時に問題が顕在化する事がよくあります。

まず、eager_loadpreloadについて簡単に説明すると、どちらもアソシエーションをまとめて取得しキャッシュしてくれるもので(= eager loading)、N+1問題の解消策となります。両者の違いを簡単に説明すると、preloadは指定したアソシエーションを別クエリで取得してキャッシュし、eager_loadは指定したアソシエーションをleft joinで取得しキャッシュします。

さて、includesはどうやって、eager_loadpreloadを使い分けしているのか、見ていきます。

Railsのソースを見ると、 ActiveRecord::Relation#eager_loading? メソッドによって判定されていることが分かります。

ActiveRecord::Relation#eager_loading?は、まず eager_load_values が存在すればtrue。
または、includes_values が存在し、かつ、joined_includes_values が存在するか、 references_eager_loaded_tables? がtrueのときにtrueとなります。joined_includes_valuesincludes されかつ joins されているアソシエーションの配列で、references_eager_loaded_tables?includes(:association).references(:association) としているアソシエーションが存在すればtrueとなります。

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

includes_valuespreload 処理されている箇所は、 ActiveRecord::Relation#preload_associations で、ActiveRecord::Relation#eager_loading? が falseの時にpreload_valuesincludes_valuesが足しこまれてpreload処理がされていきます。

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

includes_valueseager_load 処理されている箇所ですが、 ActiveRecord::Relation#exec_queries メソッド内で ActiveRecord::Relation#eager_loading? が trueの時に、apply_join_dependency メソッドが呼ばれることとなり、

def exec_queries(&block)
  skip_query_cache_if_necessary do
    @records =
      if eager_loading?
        apply_join_dependency do |relation, join_dependency|
          if ActiveRecord::NullRelation === 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

この ActiveRecord::FinderMethods#apply_join_dependency 内で eager_load_valuesincludes_values が足しこまれ、以後、 eager_load 処理されています。

def apply_join_dependency(eager_loading: group_values.empty?)
  join_dependency = construct_join_dependency(eager_load_values + includes_values)
  relation = except(:includes, :eager_load, :preload).joins!(join_dependency)

  if eager_loading && !using_limitable_reflections?(join_dependency.reflections)
    if has_limit_or_offset?
      limited_ids = limited_ids_for(relation)
      limited_ids.empty? ? relation.none! : relation.where!(primary_key => limited_ids)
    end
    relation.limit_value = relation.offset_value = nil
  end

  if block_given?
    yield relation, join_dependency
  else
    relation
  end
end

includesに複数アソシエーションを指定していたときの挙動

ここまで見てもらえれば、分かるかと思いますが、 User.hoge_scope.includes(:association_a, :association_b) のようにincludesに複数アソシエーションを渡していたとき、association_apreloadで、association_beager_loadされるといったことにはなりません。必ずassociation_a, association_bともにpreloadされるか、eager_loadされるかのいずれかとなります。よくある勘違いのひとつかなと思います。

 

includes, preload, eager_loadの使い分けについて

ケース・バイ・ケースです。

といった回答がやはり正答になってくると思うのですが、それではちょっと身も蓋もないので、個人的アプローチを紹介すると、belongs_to, has_one アソシエーションについては eager_loadして、has_many なアソシエーションについてはpreloadすることを基本線としています。

includesはクエリが状況によって変わってコントロールしずらいので基本使わないようにしています。

belongs_to, has_one アソシエーションについては、1対1あるいはN対1関連なのでSQLを分割して取得するより、left joinでまとめて取得した方が効率的な事が多いと思っています。 一方、has_many アソシエーションについてはeager_loadしておくと、以下に書くようにslow queryを踏みやすいためpreloadを基本にするのが良いと思っています。

has_manyアソシエーションをeager_loadするとスロークエリが発生しやすい

レコードのリスト取得の際にはページング等でレコード件数の絞り込みをする事が多いと思いますが、このレコード件数の絞り込みが、has_manyアソシエーションがeager_loadされていると大変になってきます。

例えば、User.eager_load(:has_many_association).limit(10) としたとき、10件の絞り込みはUserのレコード数についての絞り込みとなりますが、 1対N関連のテーブルをleft joinしたSQLが返すレコードはUserについて重複を含んだものになってくるため絞り込みが難しい形となります。ActiveRecordでは、この点についてどのように対処しているかというと、先程の ActiveRecord::FinderMethods#apply_join_dependency メソッドの以下の部分で、対処しています。

if eager_loading && !using_limitable_reflections?(join_dependency.reflections)
  if has_limit_or_offset?
    limited_ids = limited_ids_for(relation)
    limited_ids.empty? ? relation.none! : relation.where!(primary_key => limited_ids)
  end
  relation.limit_value = relation.offset_value = nil
end

上記の using_limitable_reflections?(join_dependency.reflections) の部分は has_many なアソシエーションをeager_loadしていると false になり、has_limit_or_offset? の部分はSQLにlimit句またはoffset句が入っている場合に true となります。

そして、重要なのが limited_ids = limited_ids_for(relation) の部分です。この結果で受け取るidリストを使って、レコードの絞り込み(relation.where!(primary_key => limited_ids))が行われています。

ActiveRecord::FinderMethods#limited_ids_for の実装を見てみましょう。

def limited_ids_for(relation)
  values = @klass.connection.columns_for_distinct(
    connection.visitor.compile(arel_attribute(primary_key)),
    relation.order_values
  )

  relation = relation.except(:select).select(values).distinct!

  id_rows = skip_query_cache_if_necessary { @klass.connection.select_all(relation.arel, "SQL") }
  id_rows.map { |row| row[primary_key] }
end

上記のrelation.except(:select).select(values).distinct! を見れば分かるかと思いますが、distinctをつけたクエリを発行することで、絞り込み対象のidリストを取得しているのですが、このdistinctのSQLがスロークエリになりやすいということになります。

has_oneアソシエーションをeager_loadする際に気にしておくこと

上に述べてきたように、has_one アソシエーションを eager_load しただけでは、distinctで対象レコードを絞るということは行われません。

そのため、eager_loadでleft_joinされた時に、has_one アソシエーションだけど、DB上は1対Nな関連であった場合にはレコード数の絞り込みで問題が発生します。

例えば、 User.eager_load(:has_one_association).limit(10) とした時にレコード数が10に満たなくなるという事が発生しえます。

よく下記のように has_one アソシエーションにscopeでorder を設定している実装を見たりしますが、下記のような実装をしている場合は、たいていDB上は1対Nな関連であると思うので、eager_loadする際には気をつけましょう。
preload へ切り替えを検討するのが良いと思います。

class User < ApplicationRecord
  has_one :last_blog, -> { order(id: :desc) }, class_name: 'Blog' 
  has_many :blogs
end

preloadの際に気にしておくこと

一方、preloadの際に注意することはIN句が大きくなりすぎないようにすることです。

例えば current_user.blogs.preload(:comments) としたとすると、

# SELECT `blogs`.* FROM `blogs` where `blogs`.`user_id` = 1
# SELECT `comments`.* FROM `comments` WHERE `comments`.`blogs_id` IN (1, 2, 3, ...)

といったようなSQLが発行されますが、最初のSQLで得られるblogsのレコード件数が非常に大きいとIN句が膨大に膨らんできます。

マネーフォワードではDBに主にMySQLを利用していますが、MySQLでいうと以下の設定値を気にする必要が出てくるかもしれません。

  • max_allowed_packet
    • SQLのサイズが設定値を超えた場合SQLエラーとなる
  • range_optimizer_max_mem_size
    • in句が巨大になるに比例してサイズが必要となるメモリで、メモリサイズの設定値を超えるとindexが使われずテーブルフルスキャンとなる

preloadを利用する際は、ページング等で件数絞り込みがされているかどうか意識しましょう。

 

最後に

「とりあえずincludes」を見直してもらう機会が増えると幸いです。

マネーフォワードでは東京だけでなく、福岡、京都、ベトナムでもエンジニアを募集しています。
ご応募お待ちしています!

【採用サイト】
マネーフォワード採用サイト
Wantedly | マネーフォワード

【マネーフォワードのプロダクト】
自動家計簿・資産管理サービス『マネーフォワード ME』 iPhone,iPad Android

「しら」ずにお金が「たま」る 人生を楽しむ貯金アプリ『しらたま』 iPhone,iPad

おトクが飛び出すクーポンアプリ『tock pop トックポップ』

金融商品の比較・申し込みサイト『Money Forward Mall』

くらしの経済メディア『MONEY PLUS』

■ビジネス向けクラウドサービス『マネーフォワードクラウドシリーズ』
バックオフィス業務を効率化『マネーフォワードクラウド』
会計ソフト『マネーフォワードクラウド会計』
確定申告ソフト『マネーフォワードクラウド確定申告』
請求書管理ソフト『マネーフォワードクラウド請求書』
給与計算ソフト『マネーフォワードクラウド給与』
経費精算ソフト『マネーフォワードクラウド経費』
マイナンバー管理ソフト『マネーフォワードクラウドマイナンバー』
資金調達サービス『マネーフォワードクラウド資金調達』

Pocket