🧡

【Rails】eager_load, preload, includesの違いと使い分け

2023/03/26に公開

Active Recordにおいて、親子関係にあるテーブルのデータを取得する際に、N+1問題を解消するためにpreload, includes, eager_loadを使いますが、それぞれのメソッドの違いや使い方についてあまり理解できていなかったのでまとめました。

N+1問題とは

N+1問題 とは、ループ処理の中で都度クエリを発行してしまい、大量のクエリが発行されてパフォーマンスが低下してしまう問題のことです。余計なクエリが発行されるということは、データの取得や参照に時間がかかり、アプリ自体のパフォーマンスが低下することになります。

N+1問題の例

今回は会員テーブル(users)と投稿テーブル(posts)があったとして、これらは、1対多で関連づけています。

モデル

Users.rb
 class User < ApplicationRecord
  has_many :posts
 end
Posts.rb
 class Post < ApplicationRecord
  belongs_to :user
 end

投稿一覧画面の作成

投稿一覧画面において、投稿に紐づくユーザーを取得する場合があるとします。

# posts_controller.rb
def index
  @posts = Post.all # DBに保存されている全ての投稿を取得する
end

# views/posts/index.html.erb
# 投稿のタイトルと投稿に紐づくユーザー名を表示する
@posts.each do |post|
  post.title
  post.user.name
end

このようなコードを書くと以下のようなクエリが発行されます。
このように投稿に紐づくクエリが1回、投稿ごとに投稿に対応するユーザーを取得するクエリがN回生成されてしまうこととなります。
データモデル間(users-posts)にhas_manyやbelongs_toといったアソシエーションが組まれているためです。

Post Load (0.2ms)  SELECT "posts".* FROM "posts"

User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", x], ["LIMIT", 1]]

上記の例のように、大量のクエリを発行することでパフォーマンスの低下を招くN+1問題を回避するために、preload, includes, eager_loadメソッドを使います。

preload

preloadは指定したassociation[1]を複数のクエリに分けて引いてキャッシュ[2]します。
上の例でいうと、usersテーブルから全データを取得してきた後に、取得してきた user_id を持つデータを posts テーブルから取得してくるというものです。

User.preload(:posts)
# SELECT `users`.* FROM `users`
# SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, ...)

このようにすることで、userに紐づいたpostsはまとめて全て取得できるため、繰り返し処理を実行する度に、テーブルに問い合わせる必要がなくなります。つまり、大量のクエリを発行することを防ぎ、N+1問題が解消されます。

eager_load

eager_loadは、左外部結合(LEFT OUTER JOIN)でキャッシュします。
また、特徴としてassociationの値で絞り込みをすることができます。

左外部結合(LEFT OUTER JOIN)とは

二つのテーブルの特定のフィールド同士を対応付け、両者の値が一致するレコードを一つに連結して取得するもの です。このとき、一致する相手がいなかったレコードもデータとして取得するのが特徴です。
上の例で左外部結合した場合、下のような図のイメージになります。

  1. posts テーブルを左、usersテーブルを右に置いて左外部結合
    postsテーブルのuser_id とusersテーブルのidを対応づけて、データを紐づけています。

  2. users テーブルを左、postsテーブルを右に置いて左外部結合
    usersテーブルのidと、それに合致するpostsテーブルのuser_idを参照して、データを紐づけています。

    この場合、id=3のDavidさんは紐づくpostsテーブルのデータが存在しませんが、一致する相手がいなかったレコードもデータとして取得しています。

Post.all.eager_load(:user)
# SELECT "posts"."id" AS t0_r0, "posts"."name" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0, "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, "users"."updated_at" AS t1_r3 FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"

関連するテーブルを左外部結合してしまえば、繰り返し処理を行う際に、その都度テーブルに問い合わせる必要がなくなり、多数のクエリを発行する必要がなくなります。これによってN+1問題を解消しています。

includes

includesメソッドは条件に応じて、preloadとeager_loadのいずれかの処理を行います。

  1. 基本的にはpreloadを実行
  2. 下記いずれかの条件を満たす場合はeager_loadを実行
  • includesしたテーブルでwhere句などを使用して条件を絞った場合
  • includesしたassociationに対して、joinsかreferencesを呼んでいる場合
  • 任意のassociationに対してeager_loadメソッドを呼んでいる場合
User.includes(:posts)
# SELECT `users`.* FROM `users`
# SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, ...)

(参考)joins

joinsはデフォルトでINNER JOINを行います。
他の3つとの違いは、associationをキャッシュしないことです。associationをキャッシュしないのでeager loadingには使えませんが、ActiveRecordのオブジェクトをキャッシュしない分メモリの消費を抑えられます。
関連付けたテーブルのレコードにアクセスせず、単に結果をフィルタリングしたいだけであれば、joinsが適しています。

preload, eager_load, includesの使い分け

includesは非推奨

includesメソッドは下記の点から非推奨としているケースが多いです。

  • associationが複数あるとき、個別に最適化できない点
Post.includes(:user, :comments)

上記のように、associationが複数あるとき、userはeager_load, commentsはpreload」のように個別で振り分けることができません。1つでもeager_loadメソッド(LEFT OUTER JOIN)を使うべきassociationがあれば、すべてLEFT OUTER JOINでeager_loadingされます。そのため、本来preloadメソッドを使った方が(クエリを分けた方が)良いassociationに対して、LEFT OUTER JOINでeager_loadingされてしまうことで、パフォーマンスが下がってしまうことがあります。

  • preloadとeager_loadを適宜振り分ける点
    includesクエリが状況によって変わるため、preloadとeager_loadのどちらを採用するのかの基準を正しく理解していないと、データ量が増えてきたときにコントロールしずらくなります。

preloadを推奨するケース

  • has_manyの関連を持つデータの事前読み込みを行う場合
  • 複数の関連先の事前読み込みを行う場合

前述したように、eager_loadでは常に結合処理を行うため、データ量が大きいと考えられる条件下では重いクエリを実行することになってしまいます。クエリを分割して事前読み込みを行った方がレスポンスが早くなると考えられます。

eager_loadを推奨するケース

  • 関連先の要素で絞り込みを行いたい場合
  • has_one、belongs_toなど1クエリでデータを取得した方が効率が良いと考えられる場合

関連先が上記のように1:1, N:1で関連付けられている場合、外部結合しても取得するレコード数に変わりは無く、1クエリでデータが取得出来るためpreloadより効率が良いと考えられます。

最後に

この記事では、preload、eager_load、includesの挙動や使いどころについてまとめてみました。恥ずかしながらプログラミングスクールで学習していた場合はデータ量が少なかったため、「とりあえずinclude使っとこ」と思っていたところがありました。しかし、今回の学習でパフォーマンスの問題はレコード数が増加するにつれて顕在化してくること、それを未然に防止する重要性を学びました。今後も日頃からよりパフォーマンスを意識したコードを書くことが出来るように意識していきたいと思います。
最後までお読みいただき、ありがとうございました!

参考文献

https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58
https://qiita.com/ryosuketter/items/097556841ec8e1b2940f#includes
https://moneyforward-dev.jp/entry/2019/04/02/activerecord-includes-preload-eagerload/
https://tech.stmn.co.jp/entry/2020/11/30/145159

脚注
  1. 関連テーブルのこと。User.preload(:posts)でいうところのpostsにあたる。 ↩︎

  2. よく利用するデータを蓄積しておくことにより、コンピュータのデータ処理を速くすること。 ↩︎

  3. max_allowed_packet:SQLのサイズが設定値を超えた場合のSQLエラー など ↩︎

Discussion