目次
- はじめに
- 使用する関連付け
- 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
各メソッドの挙動
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の挙動や使いどころについてまとめてみました。 パフォーマンスの問題はレコード数が増加するにつれて顕在化してくるため、未然に防止する為にも、日頃からよりパフォーマンスを意識したコードを書くことが出来るように意識していきたいと思います。
スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。