🌀

【Rails】N+1を回避するメソッド(includes, eagar_load, preload)の使い分けについて

2022/10/24に公開

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を使う

実装のたびにpreloadeager_loadのパフォーマンスを比較しなきゃいけなくて大変だけど、一番パフォーマンスが出るのはこれ。

(2022/11/11追記)
実装時点ではeager_loadの方が速くても、レコード数が増えてくるとpreloadの方が速くなる場合も多いので、あんまり良い戦略じゃないかも。また速度が問題になるのは異常に遅くなるケースで、preload優先で考えてめっちゃ遅くなるケースは遭遇したことがないので、その意味でもメリットが薄そう。

オマケ: associationが複数ある場合の書き方のサンプル

子が複数
Post.includes(:user, :tags)
子1つ、孫1つ
Post.includes(user: :country)
子1つ、孫1つ、ひ孫1つ
Post.includes(user: {country: :prefectures})
子1つ、孫複数
Post.includes(user: [:country, :comments])
子複数、孫1つ
Post.includes(:tags, user: :country)

※ 単数系or複数形は、紐づくレコードが1つか複数かで変わる。(1つのPostにuserは1人、tagは複数紐づく)
※ 例はincludesだけど、eager_loadpreloadでも基本同じ書き方ができる。

参考

https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58

https://moneyforward.com/engineers_blog/2019/04/02/activerecord-includes-preload-eagerload/

https://techracho.bpsinc.jp/hachi8833/2021_09_22/45650

https://qiita.com/mball/items/5d4228cd3523f7a1ad04

https://qiita.com/hirotakasasaki/items/e0be0b3fd7b0eb350327

Discussion