はじめに
メリークリスマス!ZOZOの@sasamukuです。
昨年は開発未経験の SRE がバックエンドエンジニアに転籍した話を書きました。
転籍から1年経過し Ruby on Rails での開発にも慣れてきました。
しかし未だに迷うことがあります。それは joins (left_joins)
, eager_load
, preload
の使い分けです。忘れてはググる日々に終止符を打つべく YES or NO 形式のフローチャートにしました。
本記事ではフローチャートの設問に基づきながら、各メソッドを利用すべきケースとその特性について説明していきます。
フローチャート
フローチャートはこちらになります。
設問は全部で5項目です。
- 関連付けをキャッシュするか
- 関連先テーブルのカラムで絞り込むか
- 複数モデルの関連付けをキャッシュするか
-
has_many
による関連付けであるか - 関連先テーブルのレコードが存在するデータのみ必要か
想定するモデル
下記のモデルとその関連付けを用いて実装とクエリを確認していきます。
class User < ApplicationRecord
has_many :posts
has_many :photos
has_many :movies
end
class Post < ApplicationRecord
belongs_to :user
end
class Photo < ApplicationRecord
belongs_to :user
end
class Movie < ApplicationRecord
belongs_to :user
end
joins
joins
は下記を満たすときに適しています。
- 関連付けをキャッシュするか:
NO
- 関連先テーブルのレコードが存在するデータのみ必要か:
YES
下例では id = 1 である Post を持つ User を取得します。ここでは User だけが必要であり、Post は絞り込みのためだけに使われます。
User.joins(:posts).merge(Post.where(id: 1))
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1
関連付けをキャッシュしないので次のようなコードでは N+1 が発生します。
users = User.joins(:posts).merge(Post.where(id: [1, 2, 3]))
users.map(&:posts)
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" IN (1, 2, 3)
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 3
left_joins
left_joins
は下記を満たすときに適しています。
- 関連付けをキャッシュするか:
NO
- 関連先テーブルのレコードが存在するデータのみ必要か:
NO
下例では Post を1件も持たない User を取得します。
User.left_joins(:posts).merge(Post.where(id: nil))
SELECT "users".* FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" IS NULL
LEFT OUTER JOIN するので結合するレコードがなくても、左側テーブルのレコードが残ります。そのため、右側テーブルの要素が NULL であるレコードを取得できます。
joins
は INNER JOIN ですので Post を1件も持たない User を取得することはできません。
users = User.left_joins(:posts).merge(Post.where(id: nil))
users.map(&:id).uniq
#=> [4]
users = User.joins(:posts).merge(Post.where(id: nil))
users.map(&:id).uniq
#=> []
eager_load
eager_load
は下記を満たすときに適しています。
- 関連付けをキャッシュするか:
YES
- 関連先テーブルのカラムで絞り込むか:
YES
eager_load
は内部的には LEFT OUTER JOIN で結合しています。
下例では id = 1 である Post を持つ User を取得します。
User.eager_load(:posts).merge(Post.where(id: 1))
SELECT DISTINCT "users"."id" FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."name" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."created_at" AS t1_r3, "posts"."updated_at" AS t1_r4 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1 AND "users"."id" = 1
eager_load
は関連付けをキャッシュするため N+1 は発生しません。
users = User.eager_load(:posts).merge(Post.where(id: [1, 2, 3]))
users.map(&:posts)
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."name" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."created_at" AS t1_r3, "posts"."updated_at" AS t1_r4 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" IN (1, 2, 3)
関連先テーブルのカラムで絞り込みしなくても、下記を満たすときも利用できます。
- 関連付けをキャッシュするか:
YES
- 関連先テーブルのカラムで絞り込むか:
NO
- 複数モデルの関連付けをキャッシュするか:
NO
-
has_many
による関連付けであるか:NO
- =>
has_one
orbelongs_to
による関連付けである
- =>
これらの項目を満たすのは次のような実装です。
posts = Post.eager_load(:user)
posts.map { |post| post.user.name }
#=> ["User1", "User2", "User3"]
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 するテーブルは1つだけです。また関連先テーブルのレコードは必ず1つになるので、JOIN してもデータ量はそれほど増大しません。そのため preload
よりパフォーマンス面で優位になる可能性があります。
preload
preload
は下記を満たすときに適しています。
- 関連付けをキャッシュするか:
YES
- 関連先テーブルのカラムで絞り込むか:
NO
- 複数モデルの関連付けをキャッシュするか:
YES
preload
は内部的には複数のクエリに分けて関連付けを取得します。
下例では User とその全ての Post, Photo, Movie を取得しています。
User.preload(:posts, :photos, :movies)
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3)
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" IN (1, 2, 3)
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" IN (1, 2, 3)
preload
も関連付けをキャッシュするため N+1 は発生しません。
users = User.preload(:posts, :photos, :movies)
users.map { |u| [u.posts.map(&:name), u.photos.map(&:name), u.movies.map(&:name)].flatten }
#=> [["Post1", "Photo1", "Movie1"], ["Post2", "Photo2", "Movie2"], ["Post3", "Photo3", "Movie3"]]
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3)
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" IN (1, 2, 3)
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" IN (1, 2, 3)
eager_load
も同様に複数モデルの関連付けをキャッシュできますが推奨されません。関連付けが多くなるほど JOIN するテーブルも増えます。そのため一般的に preload
の方がパフォーマンス面で優位です。
users = User.eager_load(:posts, :photos, :movies)
users.map { |u| [u.posts.map(&:name), u.photos.map(&:name), u.movies.map(&:name)].flatten }
##=> [["Post1", "Photo1", "Movie1"], ["Post2", "Photo2", "Movie2"], ["Post3", "Photo3", "Movie3"]]
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."name" AS t1_r1, "posts"."user_id" AS t1_r2, "posts"."created_at" AS t1_r3, "posts"."updated_at" AS t1_r4, "photos"."id" AS t2_r0, "photos"."name" AS t2_r1, "photos"."user_id" AS t2_r2, "photos"."created_at" AS t2_r3, "photos"."updated_at" AS t2_r4, "movies"."id" AS t3_r0, "movies"."name" AS t3_r1, "movies"."user_id" AS t3_r2, "movies"."created_at" AS t3_r3, "movies"."updated_at" AS t3_r4 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" LEFT OUTER JOIN "photos" ON "photos"."user_id" = "users"."id" LEFT OUTER JOIN "movies" ON "movies"."user_id" = "users"."id"
また preload
は複数モデルの関連付けをキャッシュせずとも、下記を満たすときも利用できます。
- 関連付けをキャッシュするか:
YES
- 関連先テーブルのカラムで絞り込むか:
NO
- 複数モデルの関連付けをキャッシュするか:
NO
-
has_many
による関連付けであるか:YES
has_many
による関連付けをキャッシュするなら eager_load
で JOIN するより preload
でクエリを分けて取得した方が高速になるケースが多いです。
大量の値を IN 句に含めるとデータベースによっては問題が起きる1場合があるのでしっかり検証する必要がありそうです。
合せ技
フローチャートでは表現しきれませんでしたが以下のようなパターンもあるかと思います。
- 関連付けをキャッシュするか:
YES
- 関連先テーブルのカラムで絞り込むか:
YES
- 複数モデルの関連付けをキャッシュするか:
YES
-
has_many
による関連付けであるか:YES
そのようなケースでは joins (left_joins)
と preload
を併用して対応できます。
下例では id = 1 の Post を持つ User の関連付けを取得しています。
User.joins(:posts).preload(:posts, :photos, :movies).merge(Post.where(id: 1))
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."id" = 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" = 1
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" = 1
下例では id = 2 の Photo あるいは id = 3 の Movie を持つ User の関連付けを取得しています。
User.left_joins(:photos, :movies).preload(:posts, :photos, :movies).merge(Photo.where(id: 2).or(Movie.where(id: 3)))
SELECT "users".* FROM "users" LEFT OUTER JOIN "photos" ON "photos"."user_id" = "users"."id" LEFT OUTER JOIN "movies" ON "movies"."user_id" = "users"."id" WHERE ("photos"."id" = 2 OR "movies"."id" = 3)
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (2, 3)
SELECT "photos".* FROM "photos" WHERE "photos"."user_id" IN (2, 3)
SELECT "movies".* FROM "movies" WHERE "movies"."user_id" IN (2, 3)
おわりに
フローチャートを書くことで脳内がかなり整理されました。これで気持ちよく新年を迎えることができそうです。それではよいお年を!
参考記事
-
参考までに SQL Server では数千規模の値を IN 句に含めるとこのようなエラーが発生することがあります。
https://learn.microsoft.com/ja-jp/sql/t-sql/language-elements/in-transact-sql?view=sql-server-ver16#remarks ↩