ActiveRecordのpreloadが親子関係をキャッシュする仕組みを調べてみた
はじめに
Railsでアプリケーションのパフォーマンスチューニングをする際、N+1問題を解決するためにincludesメソッドをよく使います。このincludesメソッドは、クエリの条件によって内部で自動的にeager_loadとpreloadのどちらかに振り分けられます。
具体的には、結合先テーブルのカラムに対する絞り込み条件(where句など)がある場合や、referencesで明示的に参照を指定した場合はeager_loadが使われ、それ以外の場合はpreloadが使われます。
それぞれのデータ取得方法には違いがあります。
eager_loadはLEFT OUTER JOINを使って1回のSQLで全データをまとめて取得するのに対し、preloadは親テーブルと子テーブルでそれぞれ別々にSQLを発行し、子テーブルのデータはIN句を使って取得します。
例えば下記のようなコードがある場合...
Large.preload(media: :smalls).hogehoge
preloadが使われる際に発行されるSQL
Large Load (1.9ms) SELECT `larges`.* FROM `larges` /* loading for pp */ LIMIT 11
Medium Load (0.7ms) SELECT `media`.* FROM `media` WHERE `media`.`large_id` IN (1, 2, 3)
Small Load (0.7ms) SELECT `smalls`.* FROM `smalls` WHERE `smalls`.`medium_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9)
eager_loadが使われる際に発行されるSQL
SQL (1.4ms) SELECT `larges`.`id` AS t0_r0, `larges`.`name` AS t0_r1, `larges`.`created_at` AS t0_r2, `larges`.`updated_at` AS t0_r3, `media`.`id` AS t1_r0, `media`.`name` AS t1_r1, `media`.`large_id` AS t1_r2, `media`.`created_at` AS t1_r3, `media`.`updated_at` AS t1_r4, `smalls`.`id` AS t2_r0, `smalls`.`name` AS t2_r1, `smalls`.`medium_id` AS t2_r2, `smalls`.`created_at` AS t2_r3, `smalls`.`updated_at` AS t2_r4 FROM `larges` LEFT OUTER JOIN `media` ON `media`.`large_id` = `larges`.`id` LEFT OUTER JOIN `smalls` ON `smalls`.`medium_id` = `media`.`id`
どちらの方法もループ処理の中でデータ参照のたびにクエリが発行されるN+1問題を防ぐ効果があります。
しかし、preloadは親テーブルと子テーブルのデータを別々に取得するため、SQLレベルでは親子関係が保証されていません。つまり、アプリケーション側で親子の関連付けを行う必要があるはずです。
普段何気なく使っているincludes(preload)が、どのような実装で親子関係の関連付けを行っているのか気になったので調べてみました。
※ 手元のrubyとrailsのバージョンは下記になります
ruby 3.2.4
Rails 7.2.3
まず結論
preloadは、プライマリーキー(またはアソシエーションで指定されたキー)でグループ化したハッシュを作成し、外部キーで参照することでアプリケーションレベルで親子の関連性を構築していました。構築された関連はAssociationクラスのインスタンス変数にキャッシュされ、以降のアクセスではSQLを発行せずにメモリから取得されます。
具体的な処理の流れは以下の通りです。
-
親を主キーでグループ化してハッシュに保持
→ owners_by_key = { 1 => [Large(1)], 2 => [Large(2)] } -
子を一括取得(WHERE large_id IN (1,2,3))
→ [Medium(large_id:1), Medium(large_id:1), Medium(large_id:2)] -
子の外部キーで親を逆引きして紐付け
→ @records_by_owner = { Large(1) => [Medium, Medium], Large(2) => [Medium] } -
親の Association クラスの @target にこの配列をセット
→ Large(1).association(:media).target = [Medium, Medium]
メソッドの呼び出しを追ってみる
preloadを使ったクエリが実際に実行される流れを追っていきます。
(いくつか中間メソッドは省略しています)
1. load - クエリ実行のエントリーポイント
to_aやeachなどでRelationが評価されると、最終的にこのloadメソッドが呼ばれます。
# Causes the records to be loaded from the database if they have not
# been loaded already. You can use this if for some reason you need
# to explicitly load some records before actually using them. The
# return value is the relation itself, not the records.
#
# Post.where(published: true).load # => #<ActiveRecord::Relation>
def load(&block)
if !loaded? || scheduled?
@records = exec_queries(&block)
@loaded = true
end
self
end
2. exec_queries - メインクエリ実行とpreload呼び出し
最初に親テーブル(Large)のレコードを取得します。次にpreload_associationsが呼ばれ、取得した親のIDを使って子テーブル(Media, Small)のレコードをIN句でまとめて取得します。
def exec_queries(&block)
skip_query_cache_if_necessary do
rows = if scheduled?
future = @future_result
@future_result = nil
future.result
else
exec_main_query
end
records = instantiate_records(rows, &block)
preload_associations(records) unless skip_preloading_value
records.each(&:readonly!) if readonly_value
records.each { |record| record.strict_loading!(strict_loading_value) } unless strict_loading_value.nil?
records
end
end
3. preload_associations - preload対象の決定
eager_loading?がfalseの場合、includesで指定された関連はpreloadとして処理されます。
def preload_associations(records) # :nodoc:
preload = preload_values
preload += includes_values unless eager_loading?
scope = strict_loading_value ? StrictLoadingScope : nil
preload.each do |associations|
ActiveRecord::Associations::Preloader.new(records: records, associations: associations, scope: scope).call
end
end
eager_loading?の判定ロジックは以下の通りです。
def eager_loading?
@should_eager_load ||=
eager_load_values.any? ||
includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
end
| 条件 | 結果 |
|---|---|
| eager_load(:media) を使った | → true (eager_load) |
| includes(:media).joins(:media) | → true (eager_load) |
| includes(:media).where(media: {...}) | → true (eager_load) |
| includes(:media).references(:media) | → true (eager_load) |
| includes(:media) のみ | → false (preload) |
4. Preloader#call - Batchへの委譲
Preloaderは実際の処理をBatchクラスに委譲します。
def call
Batch.new([self], available_records: @available_records).call
loaders
end
5. Batch#call - 関連ツリーを辿りながらクエリ発行
branchesは関連のツリー構造を表現するBranchクラスのオブジェクトの配列です。Large → Media → Smallのようなネストした関連がある場合、このループで順番にクエリを発行して親子を紐付けていきます。
def call
branches = @preloaders.flat_map(&:branches)
until branches.empty?
loaders = branches.flat_map(&:runnable_loaders)
loaders.each { |loader| loader.associate_records_from_unscoped(@available_records[loader.klass.base_class]) }
if loaders.any?
future_tables = branches.flat_map do |branch|
branch.future_classes - branch.runnable_loaders.map(&:klass)
end.map(&:table_name).uniq
target_loaders = loaders.reject { |l| future_tables.include?(l.table_name) }
target_loaders = loaders if target_loaders.empty?
group_and_load_similar(target_loaders)
target_loaders.each(&:run)
end
finished, in_progress = branches.partition(&:done?)
branches = in_progress + finished.flat_map(&:children)
end
end
6. Association#load_records - 親子の紐付け処理
ここが今回の本題です。取得した子レコードを親レコードに紐付けます。
def load_records(raw_records = nil)
# owners can be duplicated when a relation has a collection association join
# #compare_by_identity makes such owners different hash keys
@records_by_owner = {}.compare_by_identity
raw_records ||= loader_query.records_for([self])
@preloaded_records = raw_records.select do |record|
assignments = false
owners_by_key[derive_key(record, association_key_name)]&.each do |owner|
entries = (@records_by_owner[owner] ||= [])
if reflection.collection? || entries.empty?
entries << record
assignments = true
end
end
assignments
end
end
このメソッドの処理を分解して見ていきます。
1. 子レコードの取得
load_recordsが呼ばれる前に、Batch#group_and_load_similarからload_records_in_batchが呼ばれ、ここで子レコードが一括取得されます。
# LoaderQuery#load_records_in_batch
def load_records_in_batch(loaders)
raw_records = records_for(loaders) # ← ここでSQLが発行される
loaders.each do |loader|
loader.load_records(raw_records) # ← 取得済みのレコードを渡す
loader.run
end
end
SELECT * FROM media WHERE large_id IN (1, 2, 3)のようなSQLが発行され、子レコードが一括取得されます。
取得されたレコードはload_recordsメソッドに引数として渡され、以降の紐付け処理が行われます。
2. owners_by_key - 親を主キーで引けるハッシュ
owners_by_keyは事前に構築された、親レコードを主キーでグループ化したハッシュです。
owners_by_keyの中身
{ 1 => [#<Large id:1>], 2 => [#<Large id:2>], 3 => [#<Large id:3>] }
3. 子の外部キーで親を逆引き
owners_by_key[derive_key(record, association_key_name)]
子レコードの外部キー(large_id)を使って、対応する親レコードを特定します。例えばMedium(large_id: 1)ならowners_by_key[1]でLarge(id:1)が取得できます。
4. @records_by_owner に紐付け結果を格納
entries = (@records_by_owner[owner] ||= [])
entries << record
親をキー、子の配列を値としたハッシュが構築されます。
最終的な@records_by_ownerの中身
{
#<Large id:1> => [#<Medium id:1>, #<Medium id:2>],
#<Large id:2> => [#<Medium id:3>],
#<Large id:3> => [#<Medium id:4>]
}
こうして、SQLレベルでは分離していた親子関係が、Rubyのハッシュを介してアプリケーション側で再構築されます。
この@records_by_ownerが後続の処理で各親レコードのassociationにセットされ、large.mediaでN+1なしにアクセスできるようになります。
Discussion
普段何気なく使っているものの深掘りされているのは本当に素晴らしいですね!