14
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ActiveRecordのjoins, eager_load, preloadで一生迷うのでフローチャートにした

Last updated at Posted at 2023-12-23

はじめに

メリークリスマス!ZOZOの@sasamukuです。

昨年は開発未経験の SRE がバックエンドエンジニアに転籍した話を書きました。

転籍から1年経過し Ruby on Rails での開発にも慣れてきました。

しかし未だに迷うことがあります。それは joins (left_joins), eager_load, preload の使い分けです。忘れてはググる日々に終止符を打つべく YES or NO 形式のフローチャートにしました。

本記事ではフローチャートの設問に基づきながら、各メソッドを利用すべきケースとその特性について説明していきます。

フローチャート

フローチャートはこちらになります。

active_record_flowchart.jpg

設問は全部で5項目です。

  • 関連付けをキャッシュするか
  • 関連先テーブルのカラムで絞り込むか
  • 複数モデルの関連付けをキャッシュするか
  • has_many による関連付けであるか
  • 関連先テーブルのレコードが存在するデータのみ必要か

想定するモデル

下記のモデルとその関連付けを用いて実装とクエリを確認していきます。

models/**.rb
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 or belongs_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)

おわりに

フローチャートを書くことで脳内がかなり整理されました。これで気持ちよく新年を迎えることができそうです。それではよいお年を!

参考記事

  1. 参考までに SQL Server では数千規模の値を IN 句に含めるとこのようなエラーが発生することがあります。
    https://learn.microsoft.com/ja-jp/sql/t-sql/language-elements/in-transact-sql?view=sql-server-ver16#remarks

14
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?