ActiveRecordのjoins, includes, preload, eager_loadを比較したまとめ

BY Derek Haynes

February 06, 2020

ActiveRecord の joins, includes, preload, eager_load メソッドは非常に便利ですが、使い方を間違えると非常に危険です。

それぞれのアプローチをどこで使用するのか、そしてどのように組み合わせるべきかを知っておくことはアプリケーションの成長に伴う多くの問題を省くことに繋がります。

以下に、各メソッドをいつ、どこで使用すべきかについて説明していきます。

joins

What's the ideal use case for joins?

リレーションシップのレコードにアクセスせず、単に結果をフィルタリングしたいだけであれば、まさに joins を利用するのに適したケースです。

以下の例は Derek によってコメントがされた全てのブログ投稿を取得しています。関連するコメントには一切アクセスしていないため、joins を利用するのに最適と言えます。

Post.joins(:comments).where(:comments => {author: 'Derek'}).map { |post| post.title }
  Post Load (1.2ms)  SELECT  "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."author" = $1
=> ["One weird trick to better Rails apps",
 "1,234 weird tricks to faster Rails apps",
 "You wouldn't believe what happened to this Rails developer after 14 days"]

Do joins prevent N+1 queries?

joins 自体が防いでくれるかというと、答えは NO です。joins はリレーションシップからメモリにデータをロードしません。リレーションシップから列にアクセスすることで、N + 1 問題が引き起こされます。

例えば、Comment リレーションにアクセスする際に発行される全ての追加クエリに注目してみてください。

Post.joins(:comments).where(:comments => {author: 'Derek'}).map { |post| post.comments.size }
  Post Load (1.2ms)  SELECT  "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."author" = $1
   (1.0ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (3.0ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (0.3ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (1.0ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (2.1ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
   (1.4ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1
=> [3,5,2,4,2,1]

Can joins be combined with includes, preload, and eager_load?

できます。joins によって指定された結合タイプ(デフォルトは INNER JOIN)は includes や eager_load によって適用された結合タイプを上書きします。preload には join を適用することが出来ない点に注意してください。

includes

Can includes prevent N+1 queries?

できます。includes は(1) 親の全てのレコードと(2) includesメソッドの引数により参照される全てのレコードをロードします。

以下の例では includes を使用することで1つしか追加のクエリが発行されていない点に注目してください。 includes を使用しない場合、全ての投稿に対してのコメント件数を取得するクエリが発行されることになります。

Post.includes(:comments).map { |post| post.comments.size }
  Post Load (1.2ms)  SELECT  "posts".* FROM "posts"
  Comment Load (2.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 3, 4, 5, 6)
=> [3,5,2,4,2,1]

Does includes always generate a separate query to fetch the records in the relationship?

いいえ、includes は個別のクエリ(前項で記載したような)と LEFT OUTER JOIN どちらも使用します。リレーションシップを参照する際に where句または order句が用いられている場合、個別のクエリではなく、LEFT OUTER JOIN が使用されます。

Is a single query or two queries faster?

ActiveRecord のソースを読み込んだ結果、ActiveRecord がパフォーマンスに基づいて2つのクエリまたは単一のクエリを発行し分けているのではないと考えられます。includes クエリでパフォーマンスが出ない場合は、ローカルでScout DevTrace などのツールを使用して、 ActiveRecord が includes を実行している際にどちらのアプローチを選んでいるのか調べてみることをお勧めします。2つのクエリが利用されている場合、ActiveRecord リレーションへの参照を追加することで、単一の LEFT OUTER JOIN クエリが利用されるよう試すことができます。

Post.includes(:comments).references(:comments).map { |post| post.comments.size }

What happens when I apply conditions to a relationship referenced via includes?

ActiveRecord はすべての親のレコードと条件に一致したリレーションシップのレコードのみを返します。下記の例では Derek によってコメントされた全ての投稿レコードと、Derekのコメントのみを返します。

Post.includes(:comments).references(:comments).where(comments => {author: 'Derek'}).map { |post| post.comments.size }

Does includes prevent all N+1 queries?

いいえ、ネストされたリレーションシップの中のデータにアクセスする場合、そのデータはプリロードされません。例えば、コメントごとにComment#likes の情報を得ようとする場合追加のクエリが必要になります。

<% post.comments.each do |comment| %>
  <%= comment.likes.map { |like| like.user_avatar_url }
<% end %>

Can I prevent N+1s in nested relationships?

はい。ネストされたリレーションシップを includes 経由ロードすることができます。

Post.includes(comments => :likes).references(:comments).map { |post| post.comments.size }

Should I always always load data from nested relationships?

いいえ。大量のレコードを初期化するのは簡単です。例えば、人気のあるコメントには何千もの「いいね」のレコードがあり、クエリは遅くなり、多くのメモリが割り当てられてしまいます。本番環境のようなデータに対して、ローカルでScout DevTrace などのツールを実行することで、より速いアプローチを取る助けになるでしょう。

preload

Should I ever use preload by itself?

時として必要ですが、デフォルトで必要というものではありません。LEFT OUTER JOIN を使用したリレーションシップのロードが著しく遅いと分かっている場合は、preload と includes を組み合わせて使用します。それ以外の場合、後日 where句または order 句を追加した場合、eager_load が実行され、それにより joins が呼ばれることとなります。

Is it common to combine joins with preload?

全てのリレーションシップレコードが必要な場合(リレーションシップの条件に一致するケースではなく)preload と joins を組み合わせて使用します。例えば、

  1. Derek のコメントのついた全ての投稿レコードを検索する場合
  2. それらの投稿と個々の投稿のコメント数の合計を表示する場合

includes はDerekによるコメントのみを取得し、投稿に関連付けられた全てのコメントを取得はしません。

Post.joins("LEFT OUTER JOIN comments ON comments.post_id = posts.id").where(:comments => {author: 'Derek'}).preload(:comments).map { |post| post.comments.size }

eager_load

includes は where句または、 order 句がリレーションシップの参照に存在する際、 eager_load に処理を委譲します。

Should I ever use eager_load by itself?

はい。includes が 2つのクエリを使用して遅いことわかった場合、 eager_load を使用することで、 LEFT OUTER JOIN で生成された単一のクエリの利用を強制することができます。eager_load がコードの中に明記されていることで、将来にわたって、2つのクエリを発行しないことが保証されているということになります。

Can I combine eager_load with joins?

はい。以下の例のように可能です。

Post.joins(:comments).eager_load(:comments).map { |post| post.comments.size }

ActiveRecord は以下を行います。

  1. 投稿の配列をコメントと共にかえします
  2. 投稿に関連づけられたコメントをロードします

ここにはINNER JOIN と LEFT OUTER JOIN の両方が含まれています。

TL;DR

これらのメソッドに対するアプローチを次のように大まかにまとめます。

  1. フィルタリングしたいだけの場合は、 join を使用する
  2. リレーションシップにアクセスする場合は、 includes から利用開始してみる
  3. includes が2つのクエリを使用し遅い場合、 eager_load を利用し単一のクエリを矯正し、パフォーマンスを比較してみる

ActiveRecord を介してリレーションシップにアクセスする場合、多くのエッジケースがあります。 joins, includes, preload, and eager_load を使用する際の基本的なパフォーマンス不良を防ぐための知見となることを願います。

Also See

Ready to optimize your site?

もしご興味ありましたら、こちらからプロダクトデモの予約、またはsupport@scoutapm.comまでご連絡ください。14日間無料トライアル(クレジットカード不要)も実施中です!

ここまで、閲読頂きありがとうございます!犬のイラストをクリックしていただきますと無料でScout関連商品をお届けします

doggo-chill.png