【Rails】N+1を回避するメソッド(includes, eagar_load, preload)の使い分けについて
N+1問題とは?
「クエリが必要以上に発行されて、処理が重くなっちゃう」問題。
例えば以下のようにpostsテーブルのデータを取得して
posts = Post.all
各postに紐づくuserテーブルの値を参照しようとすると
posts.each do |post|
puts post.user.name
end
postの数だけ、クエリが発行されちゃう
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
これを回避するためには、あらかじめassociation(例の場合はuserテーブルのデータ)をeager_loadingしてキャッシュしておく必要がある。
N+1を回避するための3つのメソッド
eager_load
メソッド
associationを、1つのクエリ(LEFT OUTER JOIN)でまとめて取得して、eager loadingする。
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"
JOINしているので、preloadと違って、associationの値で絞り込みができる。
Post.eager_load(:user).where(user: { id: 1 })
# 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, "user"."id" AS t1_r0, "user"."name" AS t1_r1, "user"."created_at" AS t1_r2, "user"."updated_at" AS t1_r3 FROM "posts" LEFT OUTER JOIN "users" "user" ON "user"."id" = "posts"."user_id" WHERE "user"."id" = ? [["id", 1]]
preload
メソッド
associationを複数のクエリに分けてeager loadingする。
Post.all.preload(:user)
# SELECT "posts".* FROM "posts"
# SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5], ["id", 6], ["id", 7], ["id", 8], ["id", 9], ["id", 10]]
JOINしていないので、assosiationの値で絞り込むことはできない。
User.preload(:posts).where(posts: { id: 1 })
# ERROR
includes
メソッド
- preloadで事足りる場合はpreloadと同じ挙動(クエリを分けて実行)
- 無理な場合はeager_loadと同じ挙動(LEFT OUTER JOIN)
みたいな感じで、よしなに処理を分けてくれる。
補足
- includesしたテーブルで、whereによる絞り込みを行っている
- includesしたassociationに対して、joinsかreferencesを呼んでいる
- 任意のassociationに対してeager_loadメソッドを呼んでいる
のどれかを満たすときeager_load
メソッドと同じ挙動、そうでなければpreload
メソッドと同じ挙動になる。
includes
メソッドの注意点
associationが複数あるとき、個別に最適化できない
以下のように、eager_loadingしたいassociationが複数あるとき
Post.includes(:user, :tags)
「userはeager_load」「tagはpreload」みたいな個別の振り分けは行われない。
1つでもeager_load
メソッド(LEFT OUTER JOIN
)を使うべきassociationがあれば、すべてLEFT OUTER JOIN
でeager_loadingされる。
本来preload
メソッドを使った方が(クエリを分けた方が)良いassociationに対して、LEFT OUTER JOINでeager_loadingされてしまうことで、パフォーマンスが下がってしまうことがある。
eager_load
メソッドとpreload
メソッドの速度比較
関連テーブルのデータ量がすごく小さいときには、preload
メソッドで複数クエリで取得するより、eager_load
メソッドでLEFT OUTER JOINで取得した方がパフォーマンスが良いケースもある。
けど、そういう時はpreload
でも十分速いので、ぶっちゃけ誤差でしかないことも多い。
一方ある程度重いクエリを発行する時は、クエリを2つに分けている分、preload
メソッドの方がパフォーマンスが良くなることが多い。(クソでかクエリが生まれちゃうリスクが低くなる)
どう使い分けるか?
結局どういう使い分けをすればいいのか。考えられる使い分け戦略は3つ。
includes
、ときどきそれ以外」
使い分け戦略1. 「基本- 基本的に
includes
メソッドを使う - associationが複数あって、それぞれ個別に最適化する必要がある場合のみ、
eager_load
メソッドとpreload
メソッドを使う
短期的にみると、考えることが少なく済むので、ちょっとしたアプリをパパッと作る時とかには良いかもしれない。
includes
は使わない、preload
で済むときはpreload
」
使い分け戦略2. 「-
includes
メソッドは一切使わない -
preload
メソッドで事足りる場合は、preload
メソッドを使う - 無理な場合は
eager_load
メソッドを使う
意図が明確になりブラックボックスが減るので、「使い分け戦略1」よりも読み手に優しいコードになる。
preload
よりeager_load
の方が速いケースは、誤差として切り捨て、実装の楽さを優先する。
個人的にはこれが好き。
includes
は使わない、eager_load
の方が速ければeager_load
」
使い分け戦略3. 「-
includes
メソッドは一切使わない -
preload
で実装でき、かつeager_load
よりパフォーマンスが出る場合のみ、preload
を使う - それ以外は
eager_load
を使う
実装のたびにpreload
とeager_load
のパフォーマンスを比較しなきゃいけなくて大変だけど、一番パフォーマンスが出るのはこれ。
(2022/11/11追記)
実装時点ではeager_load
の方が速くても、レコード数が増えてくるとpreload
の方が速くなる場合も多いので、あんまり良い戦略じゃないかも。また速度が問題になるのは異常に遅くなるケースで、preload
優先で考えてめっちゃ遅くなるケースは遭遇したことがないので、その意味でもメリットが薄そう。
オマケ: associationが複数ある場合の書き方のサンプル
Post.includes(:user, :tags)
Post.includes(user: :country)
Post.includes(user: {country: :prefectures})
Post.includes(user: [:country, :comments])
Post.includes(:tags, user: :country)
※ 単数系or複数形は、紐づくレコードが1つか複数かで変わる。(1つのPostにuserは1人、tagは複数紐づく)
※ 例はincludes
だけど、eager_load
やpreload
でも基本同じ書き方ができる。
参考
Discussion