【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つ。
使い分け戦略1. 「基本includes、ときどきそれ以外」
- 基本的に
includesメソッドを使う - associationが複数あって、それぞれ個別に最適化する必要がある場合のみ、
eager_loadメソッドとpreloadメソッドを使う
短期的にみると、考えることが少なく済むので、ちょっとしたアプリをパパッと作る時とかには良いかもしれない。
使い分け戦略2. 「includesは使わない、preloadで済むときはpreload」
-
includesメソッドは一切使わない -
preloadメソッドで事足りる場合は、preloadメソッドを使う - 無理な場合は
eager_loadメソッドを使う
意図が明確になりブラックボックスが減るので、「使い分け戦略1」よりも読み手に優しいコードになる。
preloadよりeager_loadの方が速いケースは、誤差として切り捨て、実装の楽さを優先する。
個人的にはこれが好き。
使い分け戦略3. 「includesは使わない、eager_loadの方が速ければeager_load」
-
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