💎

ActiveRecordのpreloadが親子関係をキャッシュする仕組みを調べてみた

に公開1

はじめに

Railsでアプリケーションのパフォーマンスチューニングをする際、N+1問題を解決するためにincludesメソッドをよく使います。このincludesメソッドは、クエリの条件によって内部で自動的にeager_loadpreloadのどちらかに振り分けられます。

具体的には、結合先テーブルのカラムに対する絞り込み条件(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レベルでは親子関係が保証されていません。つまり、アプリケーション側で親子の関連付けを行う必要があるはずです。

普段何気なく使っているincludespreload)が、どのような実装で親子関係の関連付けを行っているのか気になったので調べてみました。

※ 手元のrubyとrailsのバージョンは下記になります
ruby 3.2.4
Rails 7.2.3

まず結論

preloadは、プライマリーキー(またはアソシエーションで指定されたキー)でグループ化したハッシュを作成し、外部キーで参照することでアプリケーションレベルで親子の関連性を構築していました。構築された関連はAssociationクラスのインスタンス変数にキャッシュされ、以降のアクセスではSQLを発行せずにメモリから取得されます。

具体的な処理の流れは以下の通りです。

  1. 親を主キーでグループ化してハッシュに保持
    → owners_by_key = { 1 => [Large(1)], 2 => [Large(2)] }

  2. 子を一括取得(WHERE large_id IN (1,2,3))
    → [Medium(large_id:1), Medium(large_id:1), Medium(large_id:2)]

  3. 子の外部キーで親を逆引きして紐付け
    → @records_by_owner = { Large(1) => [Medium, Medium], Large(2) => [Medium] }

  4. 親の Association クラスの @target にこの配列をセット
    → Large(1).association(:media).target = [Medium, Medium]

メソッドの呼び出しを追ってみる

preloadを使ったクエリが実際に実行される流れを追っていきます。
(いくつか中間メソッドは省略しています)

1. load - クエリ実行のエントリーポイント

to_aeachなどでRelationが評価されると、最終的にこのloadメソッドが呼ばれます。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/relation.rb#L1175

# 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句でまとめて取得します。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/relation.rb#L1395

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として処理されます。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/relation.rb#L1313

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?の判定ロジックは以下の通りです。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/relation.rb#L1234

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クラスに委譲します。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/associations/preloader.rb#L120

def call
  Batch.new([self], available_records: @available_records).call

  loaders
end

5. Batch#call - 関連ツリーを辿りながらクエリ発行

branchesは関連のツリー構造を表現するBranchクラスのオブジェクトの配列です。Large → Media → Smallのようなネストした関連がある場合、このループで順番にクエリを発行して親子を紐付けていきます。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/associations/preloader/batch.rb#L12

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 - 親子の紐付け処理

ここが今回の本題です。取得した子レコードを親レコードに紐付けます。

https://github.com/rails/rails/blob/v7.2.3/activerecord/lib/active_record/associations/preloader/association.rb#L197

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なしにアクセスできるようになります。

GMOメディアテックブログ

Discussion