preload、eager_load、includesの挙動を理解して使い分ける

f:id:takuyawww1101:20201130144730j:plain

目次

  • はじめに
  • 使用する関連付け
  • preload、eager_load、includesの挙動
  • includesはどのような場合にpreloadとeager_loadの挙動となるのか
  • preload、eager_loadの使い分け
  • さいごに

はじめに

こんにちは、株式会社スタメンでエンジニアをしているワカゾノです! 4月からサーバーサイドエンジニアとして、弊社プロダクトTUNAGの開発を行っております。

先日、弊社CTOの松谷とペアプロを行いました。 パフォーマンス改善のタスクを行いましたが、タスクを通してN + 1問題に複数回直面しました。

Active Recordにおいて、N + 1問題を解消する方法として、関連テーブルのデータを事前に読み込んでおき、キャシュしておくという方法を取ると思いますが、Railsではその方法としてpreload、eager_load、includesメソッドが用意されています。

それぞれのメソッドの挙動の違いについて、都度調べ理解するようにしていましたが、ペアプロを通して理解が浅いと感じた為、今回はpreload、eager_load、includesの挙動の違いや使い所についてまとめていきます。

検証環境
  • Ruby version 2.5.1
  • Rails version 6.0.3.4

使用する関連付け

下記のモデル間の関連付けを前提として各メソッドの挙動の違いを説明していきます。

class Company < ApplicationRecord
  has_many :users
  has_many :departments
end

class User < ApplicationRecord
  belongs_to :companies
  has_many :departments, through: :deparment_user_maps
end
 
class Department < ApplicationRecord
  belongs_to :company
  has_many :users, through: deparment_user_maps
end
 
# ユーザーが部署に所属しているかを扱う中間テーブル
class DepartmenUserMap < ApplicationRecord
  has_many :users
  has_many :deparments
end

f:id:takuyawww1101:20201130140020p:plain

各メソッドの挙動

preload

指定した関連テーブル毎に別クエリを作成、関連テーブルのデータ配列を取得し、キャッシュします。 引数に多対多の関連先を渡した場合は、中間テーブルを介して取得しキャッシュします。 preloadした関連先で絞り込みを行った場合は、例外を投げます。

Company.preload(:users, :departments)
→  SELECT `companies`.* FROM `companies`
   SELECT `users`.* FROM `users` WHERE `users`.`company_id` IN ※※※
   SELECT `departments`.* FROM `departments` WHERE `departments`.`company_id` IN ※※※
    
Department.preload(:users)
→  SELECT `departments`.* FROM `departments`
   SELECT `department_user_maps`.* FROM `department_user_maps` WHERE `department_user_maps`.`department_id` IN ※※※
   SELECT `users`.* FROM `users` WHERE `users`.`id` IN ※※※
    
# 例外を投げる
Company.preload(:users).where(users: {id: 1})
→  Mysql2::Error: Unknown column 'users.id' in 'where clause': SELECT `companies`.* FROM `companies` WHERE `users`.`id` = 1

eager_load

LEFT_OUTER_JOINで指定したデータを結合して関連テーブルのデータ配列を取得し、キャッシュします。 引数として渡した関連先の要素で絞り込みを行うことが出来ます。

Company.eager_load(:users).where(users: {id: 1})
→  SELECT `companies`.`id` AS t0_r0, `companies`.`name` AS t0_r1, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, 
    FROM `companies` LEFT OUTER JOIN `users` ON `users`.`company_id` = `companies`.`id` WHERE `users`.`id` = 1

includes

後述しますが、デフォルトではpreloadと同じ挙動、関連先のテーブルの要素で絞り込みを行った場合などはeager_loadと同じ挙動をします。

Company.includes(:users)
→  SELECT `companies`.* FROM `companies`
   SELECT `users`.* FROM `users` WHERE `users`.`company_id` IN ※※※

Company.includes(:users).where(users: {id: 1})
→  SELECT `companies`.`id` AS t0_r0, `companies`.`name` AS t0_r1, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, 
    FROM `companies` LEFT OUTER JOIN `users` ON `users`.`company_id` = `companies`.`id` WHERE `users`.`id` = 1

includesはどのような場合にpreloadとeager_loadの挙動となるのか

ActiveRecordには下記のように各メソッドが定義されています

# activerecord/lib/active_record/relation/query_methods.rb:150
def includes(*args)
  check_if_method_has_arguments!(:includes, args)
  spawn.includes!(*args)
end

def includes!(*args)
  self.includes_values |= args
  self
end

# activerecord/lib/active_record/relation/query_methods.rb: 166
def eager_load(*args)
  check_if_method_has_arguments!(:eager_load, args)
  spawn.eager_load!(*args)
end

def eager_load!(*args)
  self.eager_load_values |= args
  self
end

# activerecord/lib/active_record/relation/query_methods.rb:180
def preload(*args)
  check_if_method_has_arguments!(:preload, args)
  spawn.preload!(*args)
end

def preload!(*args)
  self.preload_values |= args
  self
end

この時点ではクエリを発行せず、それぞれincludes_values、eager_load_values、preload_valuesに値を格納しているだけです。 その後、exec_queriesメソッド内でeager_loading?メソッドにより判定を行い、preloadとeager_loadを使い分けています。

# activerecord/lib/active_record/relation.rb:667
def exec_queries(&block)
  @records =
    if eager_loading?
      find_with_associations do |relation, join_dependency|
        if ActiveRecord::NullRelation === relation
          []
        else
          rows = connection.select_all(relation.arel, "SQL", relation.bound_attributes)
          join_dependency.instantiate(rows, &block)
        end.freeze
      end
    else
      klass.find_by_sql(arel, bound_attributes, &block).freeze
    end
    
    preload = preload_values
    preload += includes_values unless eager_loading?
    preloader = nil
    preload.each do |associations|
      preloader ||= build_preloader
      preloader.preload @records, associations
    end
    
    @records.each(&:readonly!) if readonly_value
    @loaded = true
    @records
end

# activerecord/lib/active_record/relation.rb:597
def eager_loading?
  @should_eager_load ||=
    eager_load_values.any? ||
    includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
end

eager_loading?メソッドは下記の場合にtrueとなります。

  • eager_load_valuesが存在している(eager_loadメソッドを使用している)
  • includes_valuesが存在している(includesメソッドを使用している) かつ 別テーブルをjoinsしている か 関連先のテーブルで絞り込みを行っている

includesメソッドの場合は2つ目の条件に当てはまる場合がeager_loading?がtrueとなります。

詳細は割愛しますが、eager_loading?がtrueの場合はfind_with_associationsメソッド内で結合処理がされていきます。

まとめるとincludesは

  • デフォルトではpreloadと同様の挙動
  • 他テーブルを結合処理しているか、関連先のテーブルで絞り込みを行っている場合はeager_loadと同じ挙動になる

メソッドの実装の通り、複数のアソシエーションを引数で渡したとしても、どちらか片方のみpreload、もう片方はeager_loadを行うという挙動にはなりません。必ずどちらのアソシエーションもpreloadされるか、eager_loadされるという挙動になります。

preload、eager_loadの使い分け

上述の内容を踏まえると、includesはクエリを制御しにくいため、基本的に優先してpreload、eager_loadを使用する方が良いと考えられます。 その上でpreload、eager_loadの使い所を考えてみます。

preload

  • has_manyの関連を持つデータの事前読み込みを行う場合
  • 複数の関連先の事前読み込みを行う場合
    • eager_loadでは常に結合処理を行うため、データ量が大きいと考えられる条件下では、重いクエリを実行することになってしまう。クエリを分割して事前読み込みを行った方がレスポンスが早くなると考えられます。
    • ※主テーブルのレコード数が多い場合は、IN句が大きくなってしまうため、DB側での設定値を確認する必要があるようです。

eager_load

  • 関連先の要素で絞り込みを行いたい場合
  • has_one、belongs_to関連など1クエリでデータを取得した方が効率が良いと考えられる場合
    • 関連先が上記のように○対1で関連付けられている場合、外部結合しても取得するレコード数に変わりは無く、1クエリでデータが取得出来るためpreloadより効率が良いと考えられます。

最後に

preload、eager_load、includesの挙動や使いどころについてまとめてみました。 パフォーマンスの問題はレコード数が増加するにつれて顕在化してくるため、未然に防止する為にも、日頃からよりパフォーマンスを意識したコードを書くことが出来るように意識していきたいと思います。

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。

Webアプリケーションエンジニア募集ページ