RailsのActiveRecordによるJOINの挙動について改めて調べてみた

Ruby

西田@大阪です。ActiveRecordでSQLのJOIN相当を行う際の方法について何度も調べ直すので、整理し直してみました。

RailsのActiveRecordで関連のあるデータを取得する方法

以下の内容のModelの定義を前提にご説明します。

class Blog < ApplicationRecord
  has_many :comments
end
class Comment < ApplicationRecord
  belongs_to :blog
end

そのままeachを呼ぶ

Blog.all.each do |b| 
  b.comments.each {|c| }
end

単純にそのままループで処理してしまう方法です。
この方法ですと、例で言うところのBlogのレコード数だけCommentをDBから取得するためのSQLが発行されます。
俗にいうN+1問題を引き起こしてしまい、レコード数によってはとても遅くなってしまいます。

  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 2]]                            
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 3]]                            
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 4]] 

joins

Blog.joins(:comments) do |b| 
  b.comments.each {|c| }
end
  Blog Load (2.5ms)  SELECT "blogs".* FROM "blogs" INNER JOIN "comments" ON "comments"."blog_id" = "blogs"."id"                         
  Comment Load (1.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (3.2ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]]                            
  Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?  [["blog_id", 1]] 
  ....

joinsを使う方法です。INNER JOINを用いて結合してDBからデータを取得しています。
ただし、クエリを確認してみるとわかるのですが、

SELECT "blogs".* FROM

Blogのデータしか取得していません。

ActiveRecordはassociationが貼られたインスタンスをキャッシュする機能があります。
association系のメソッド(例で言うとblog.comments; comment.blog等)、を使って呼び出した場合にキャッシュがあるとデータを取得しに行きません。

# SQLが発行されDBにデータ取得しに行く
blog.comments.each {|c| } 
# Comment Load (0.8ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?

# キャッシュされたインスタンスを利用が利用されデータ取得に行かない
blog.comments.each {|c| } 
# 

# ローカルのキャッシュを破棄して再度データを取得しに行く
blog.comments.reload.each {|c| } 
# Comment Load (0.8ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?

joinsはこのassociationのキャッシュを行わないため、association系のメソッドを呼び出した際にデータの取得が行われ、レコード数が多い場合に、高速に動くことが期待できません。
※ 用途としては、where句による絞り込みに別テーブルの値を利用したい場合に使います。

# "abc"とCommentがついたBlogの件数を取得する
Blog.joins(:comments).where("comments.value" => "abc").count
# SELECT COUNT(*) FROM "blogs" INNER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ?

preload

Blog.preload(:comments).each do |b| 
  b.comments.each {|c| }
end
  Blog Load (0.2ms)  SELECT "blogs".* FROM "blogs"
  Comment Load (1.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" IN (1, 2, 3, 4)

preloadを使う方法です。
CommentのレコードをあらかじめIN句を使って取得します。
SQLとしては、Blogの取得とCommentの取得で2回発行されます。 associationのインスタンスをキャッシュするので、b.commentsとassociationのメソッドを呼ばれた際にDBに取得に行きません。
レコード数が大量になっても、高速に動くことが期待できます。

ただし、データ取得のSQLを2回に分けてデータを取得する特性上whereにCommentのフィールドを条件に含めることができません。

Blog.preload(:comments).where("comments.value" => "abc")
  Blog Load (1.6ms)  SELECT  "blogs".* FROM "blogs" WHERE "comments"."value" = ? LIMIT ?  [["value", "abc"], ["LIMIT", 11]]
ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: comments.value: SELECT  "blogs".* FROM "blogs" WHERE "comments"."value" = ? LIMIT ?

eager_load

Blog.eager_load(:comments).each do |b| 
  b.comments.each {|c| }
end
  SQL (2.9ms)  SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."created_at" AS t0_r2, "blogs"."updated_at" AS t0_r3, "comments"."id" AS t1_r0, "comments"."value" AS t1_r1, "comments"."blog_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4 FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id"

eager_loadを使う方法です。
SQLのJOIN句を使って関連テーブルを結合してデータを取得します。

SELECT "blogs"."id"... "comments"."id"  FROM "blogs" LEFT OUTER JOIN "comments" ...

SQLを確認すると、Commentのデータも取得しています。preloadと違いデータを取得するSQLは一回のみです。 eager_loadはassociationのキャッシュをするので、レコードが大量になっても高速に動くことが期待できます。

また、JOIN句を使って一度にデータを取得するのでwhereに関連テーブルのフィールドを含めることが可能です。

Blog.eager_load(:comments).where("comments.value" => "abc")
SQL (0.8ms)  SELECT  DISTINCT "blogs"."id" FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ? LIMIT ?  [["value", "abc"], ["LIMIT", 11]]
  SQL (2.8ms)  SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."created_at" AS t0_r2, "blogs"."updated_at" AS t0_r3, "comments"."id" AS t1_r0, "comments"."value" AS t1_r1, "comments"."blog_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4 FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ? AND "blogs"."id" IN (1, 2, 3, 4)  [["value", "abc"]]

includes

Blog.all.includes(:comments).each do |b| 
  b.comments.each {|c| }
end
  Blog Load (0.2ms)  SELECT  "blogs".* FROM "blogs" LIMIT ?  [["LIMIT", 11]]
  Comment Load (2.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" IN (1, 2, 3, 4)

includes を使う方法です。
CommentのレコードをあらかじめIN句を使って取得します。 レコード数が増えた場合でも、SQLの発行回数は抑えられ高速に動くことが期待できます。

基本的な動きはpreloadと変わりませんが、whereに関連テーブルのフィールドを含めた場合にJOIN句を使ってのデータ取得に変わります。

Blog.all.includes(:comments).each do |b| 
  b.comments.each {|c| }
end
SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."created_at" AS t0_r2, "blogs"."updated_at" AS t0_r3, "comments"."id" AS t1_r0, "comments"."value" AS t1_r1, "comments"."blog_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4 FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ?  [["value", "abc"]]

まとめ

基本的にincludesを使っておき、
条件にだけ関連テーブルを使いたい場合にjoins
JOIN句を使いたくなくて、関連テーブルを取得したい場合はpreload
関連テーブルを含めたデータを1回のSQLで取得したい場合はeager_load
と使い分ければ良さそうです。

参考

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い