マネーフォワード福岡拠点の責任者をしております 黒田 です。 普段は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
の挙動について、「preload
とeager_load
をよろしく使い分けしてくれるもの」と認識しているRailsエンジニアは結構いるのではないでしょうか?
そういったエンジニアの方の中には、先日の私のようにN+1問題の対策で深く考えずに「includes
つけとけばOKでしょ。ほらbulletのN+1のアラート止まったし。」という方もいらっしゃるのではと思っています。「とりあえずincludes
」で、データが少ないうちは問題にならなくてもデータが増えてきた時に問題が顕在化する事がよくあります。
まず、eager_load
とpreload
について簡単に説明すると、どちらもアソシエーションをまとめて取得しキャッシュしてくれるもので(= eager loading)、N+1問題の解消策となります。両者の違いを簡単に説明すると、preload
は指定したアソシエーションを別クエリで取得してキャッシュし、eager_load
は指定したアソシエーションをleft join
で取得しキャッシュします。
さて、includes
はどうやって、eager_load
とpreload
を使い分けしているのか、見ていきます。
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_values
は includes
されかつ 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_values
が preload
処理されている箇所は、 ActiveRecord::Relation#preload_associations で、ActiveRecord::Relation#eager_loading?
が falseの時にpreload_values
にincludes_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_values
が eager_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_values
に includes_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_a
はpreload
で、association_b
はeager_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』
■ビジネス向けクラウドサービス『マネーフォワードクラウドシリーズ』 ・バックオフィス業務を効率化『マネーフォワードクラウド』 ・会計ソフト『マネーフォワードクラウド会計』 ・確定申告ソフト『マネーフォワードクラウド確定申告』 ・請求書管理ソフト『マネーフォワードクラウド請求書』 ・給与計算ソフト『マネーフォワードクラウド給与』 ・経費精算ソフト『マネーフォワードクラウド経費』 ・マイナンバー管理ソフト『マネーフォワードクラウドマイナンバー』 ・資金調達サービス『マネーフォワードクラウド資金調達』