概要
忘れん坊な自分のために、preload、eager_load、includesの違いをメモしておきます😮💨
これらのメソッドは何のためにあるのか
結論、N+1問題が発生しないようにするため。
特定のレコードを取得する際、これらのメソッドを使って関連レコードを先読みして取得しておくことでN+1が発生しません。
N+1のおさらい
N+1とは データベースへのアクセス回数が余計に多くなってしまう現象 です。
例えば全てのユーザーの名前と、関連する最初の投稿(posts)のタイトルを出力するコードがあるとします。
users = User.all
users.each do |user|
puts "#{user.name}の最初の投稿は#{user.posts.first}です。"
end
上記を実行した時のSQLは以下になります。
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 3 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 4 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 5 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 6 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 7 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 8 ORDER BY "posts"."id" ASC LIMIT 1
eachの内部でuser.posts.first
が呼び出される度に、postsを取得する余分なSELECT文が発行されており、N+1が発生しています。
少ないレコード件数であればそこまで問題にはならないですが、これが10万、100万件の場合はパフォーマンスに大きな影響が出てしまいます。
includes、preload、eager_loadでN+1は解消できる
しかし、以下のようにincludes(preload、eager_loadでも同じ)を呼び出した上でeachすれば
users = User.includes(:posts)
users.each do |user|
puts "#{user.name}の最初の投稿は#{user.posts.first}です。"
end
users取得時に関連するpostsを一括取得するSQLが発行されるので、N+1が起きません。
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."id" IN (1, 2, 3, 4...)
ではそれぞれの違いはなに?
関連レコードを一緒に読み込むという点では一緒ですが、発行されるSQLに違いがあります。
preload
関連レコードを別のクエリ(SELECT)で取得します。
users = User.preload(:posts)
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "user_id" IN (1, 2, 3, 4...)
特徴
- データ量が大きいものを扱う場合にeager_load(
left join
を)よりも早く取得できる- ただしレコード件数が非常に大きい場合、IN句に指定される数も膨大になるため、以下のようなSQLのメモリ上限に気を付ける
- SQLのサイズがmax_allowed_packetの設定値を超えた場合にエラーとなる
- range_optimizer_max_mem_sizeに設定された範囲検索のメモリ上限をオーバーしてしまい、インデックスが使われない
- ただしレコード件数が非常に大きい場合、IN句に指定される数も膨大になるため、以下のようなSQLのメモリ上限に気を付ける
- 関連レコードを複数指定した場合、その数だけ発行されるクエリが増える
- whereなどで関連レコードの絞り込みができない(絞り込もうとするとエラーになる)
eager_load
関連レコードをLEFT OUTER JOIN
を使って一つのクエリで一括取得します。
users = User.eager_load(:posts)
SELECT "users".* FROM "users"
LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"
特徴
- whereなどでレコードの絞り込みができる
- 関連レコードを複数指定した場合でも一つのクエリで済む
-
left join
でテーブル同士を結合するため、データ量が大きいものを扱う場合にスロークエリとなる可能性がある
left joinでテーブル同士を結合するため、データ量が大きいものを扱う場合にスロークエリとなる可能性がある
こちらですが、特にhas_manyアソシエーションかつデータ量が大きい場合はスロークエリが発生しやすいので注意が必要です。
User.eager_load(:has_many_association).limit(10)
たとえば、上記のようにhas_manyの関連先があるUserをlimit(10)
で絞り込む場合、eager_loadを使うと1対N関連のテーブルをleft join
で取得するため、重複を含んでいた場合は10件以上のレコードが取得されてしまいます。
なので、ActiveRecord内部では「has_manyをeager_loadしているかつ、limit
or offset
で絞り込みしている場合」にdistinct
付きのSQLが発行されるようになっている(参考)のですが、データ量が大きいと、このdistinct
が要因でスロークエリとなりやすいわけです。
includes
Railsが自動的にpreload、eager_loadを使い分けてくれます。
includesのみを使った場合は、preloadが内部的に使われます。
users = User.includes(:posts)
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "user_id" IN (1, 2, 3, 4...)
includesと絞り込みのクエリ(whereやlimit, offsetなど)を使った場合は、eager_loadが内部的に使われます。
users = User.includes(:posts).where(posts: {title: 'preload、eager_load、includesの違いをいつも忘れてしまうのでメモしておく'})
SELECT "users".* FROM "users"
LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"
WHERE "posts"."title" = 'test'
特徴
- 絞り込みが使える/使えないを意識しなくてもrailsがよしなに変換してくれるので楽
- 使い方によってクエリが変わるので、何も考えずに使うとスロークエリになる可能性がある
それぞれどう使い分けるのが良いか
使い方によってクエリが変わっても良いという場合や、パフォーマンスを気にしないのであれば、深く考えずにincludesを使うで良いと思います。
しかしクエリを制御したいという場合や、パフォーマンスを気にするのであれば、発行されるSQLを意識した上でeager_loadとpreloadを使い分けた方が良いです。
個人的な目安としては以下のように考えています。
preloadを使う場合
- 関連レコードで絞り込みの必要がない
- データ量が大きいものを扱う
- 取得する関連先が多い
-
eager_load
だとleft join
で関連先が多ければ多いほど重くなる可能性がある
-
eager_loadを使う場合
- 関連レコードで絞り込みが必要
- データ量がそこまで大きくないものを扱う
- has_manyでない(has_one)テーブルで絞り込みを行うとき
終わりに
これで違いを忘れることがなさそうです😌
またパフォーマンス向上のためには、Active Recordのメソッドを使う際にどのようなSQLが発行されるかを意識していくことが大事だと改めて感じました。
参考にさせていただいた記事