Help us understand the problem. What is going on with this article?

似ているようで全然違う!?Activerecordにおけるincludesとjoinsの振る舞いまとめ

More than 1 year has passed since last update.

Activerecordを使ってるとき、関連(Association)のあるmodel同士をまとめて取得したい時がけっこうある。そんな時、includesjoinsを使えば効率良くデータを取得出来るんだけど、実はこの二つは振る舞いや特徴が全然違ってたりする。ややこしい気がしたので、ここでちょっとまとめておく。

先に結論を書いておくと、基本的には

  • includesは先読みしてキャッシュしておく。
  • joinsはただINNER JOINしてくれる。

と思っておけばOK。

ちなみに、railsのversionは4.1.0。Web上に落ちてる情報は古いせいか若干現状の挙動とは違ってたりしたので、気をつけた方が良さそう。

includes

includesはデータの先読みをしてくれる。その為、関連modelに対する参照を持ちたい場合に使う。そう言われてもよくわからないと思うので、実際に使用例を見てみる。

taggingが中間テーブルとなり、blogtagの間に多対多の関係が出来ているとする。

この時、

ruby
Blog.includes(:tags).each { |blog| p blog.tags.map(&:name) }

の様なincludesを含んだコードを実行すると、

postgresql
SELECT "blogs".* FROM "blogs"
SELECT "taggings".* FROM "taggings"  WHERE "taggings"."blog_id" IN (`取得したblogsのID`)
SELECT "tags".* FROM "tags"  WHERE "tags"."id" IN (`取得したtaggingのid`)

というSQLが発行される。

blogs, taggings, tagsへのsqlの発行がそれぞれ1回ずつ行われている事が分かる。blogsを取得し、取得したblogsに相当するtaggingを取得し、さらに取得したtaggingに相当するtagを取得するといった具合である。

当然の挙動とおもうかも知れないが、includes(:tags)しなかった場合にはこうはいかない。blogからtagsへの参照が発生するたびに新規のsqlを発行してtagsを取得しようとしてしまうため、いわゆる N+1問題 が発生する。これは大変効率が悪い....!!!

ruby
Blog.each { |blog| p blog.tags.map(&:name) }
postgresql
SELECT "blogs".* FROM "blogs"
SELECT "tags".* FROM "tags" INNER JOIN "taggings" ON "tags"."id" = "taggings"."tag_id" WHERE "taggings"."blog_id" = `blogのidのうち一つ`
// このtagsへのクエリが取得したblogの数N回だけ発行される!!!

そんな訳で、includesは別のmodelへの参照が必要となる場合にN+1問題を避ける為に使われる。

joins

joinsの仕組みはめちゃくちゃシンプルで、ただINNER JOINしてくれる。

ruby
Blog.joins(:tags)
postgresql
SELECT "blogs".* FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id"

joinsを使えば、joins先のテーブルのカラムの値を使って絞り込んだり出来る。

ruby
Blog.joins(:tags).where(tags: {id: [*1..3]})
postgresql
SELECT "blogs".* FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" AND INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (1, 2, 3)

注意しなければいけないのは、INNER JOINした結果のレコード1つ1つが1つのオブジェクトにマッピングされて返ってくる事。当然、返ってくるレコードの数は違ってくる。

ruby,postgresql
Blog.all.size
SELECT COUNT(*) FROM "blogs"
=> 2923

Blog.includes(:tags).all.size
SELECT COUNT(*) FROM "blogs"
=> 2923

Blog.joins(:tags).all.size
SELECT COUNT(*) FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id"  INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id"
=> 38943

blogのレコード数自体が2923なのに対して、joins(:tags)してINNER JOINするとレコード数が38943まで増えている事が分かる。

それから、joinsしたテーブルのカラムにアクセスするには、明示的にselectをしてやらなければならない。

ruby
Blog.first.attributes
=> {"id"=>1,
 "title"=>"眠りに恐怖を感じる「睡眠恐怖症」。難病患者の苦しみに見る社会の闇。"}
postgresql
SELECT  "blogs".* FROM "blogs"   ORDER BY "blogs"."id" ASC LIMIT 1
ruby
Blog.joins(:tags).select('blogs.*, tags.name').first.attributes
=> {"id"=>1,
 "title"=>"眠りに恐怖を感じる「睡眠恐怖症」。難病患者の苦しみに見る社会の闇。",
 "name"=>"睡眠"}
postgresql
SELECT  blogs.*, tags.name FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id"  ORDER BY "blogs"."id" ASC LIMIT 1 

1レコードが1つのオブジェクトにマッピングされるため、取得したオブジェクトのnameプロパティにtagnameが格納されているのが特徴的。includesと違って先読みはしていない為、tagsへの参照はSQLの発行を意味する事にも気をつけなければならない。

ちょっと細かい話。includes + referencesで先読みしつつ絞り込みも行う。

includesは先読みと書いたが、referencesを付ける事でLEFT OUTER JOINにもなる。OUTER JOINは片方にしか存在しないレコードも結果に含めるJOINで、INNER JOINよりも広い範囲でレコードを取得する。

ruby
Blog.includes(:tags).references(:tags).where('tags.id < ?', 3)
postgresql
SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1 FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE (tags.id < 3)

sizeでレコード数を見ると、元のincludesの処理に合わせてblogsの重複を無くした数を返してくれる(その為にDISTINCTが使われている。)。

ruby
Blog.includes(:tags).references(:tags).where('tags.id < ?', 3).size
postgresql
SELECT COUNT(DISTINCT "blogs"."id") FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE (tags.id < 3)
=> 97

joinsの様にJOIN先のテーブルのカラムを使って絞り込む事が出来るのも特徴的で、上記の例ではどちらもtagsidが[1, 2, 3]に含まれる様なblogsだけを返している。

先読みの機能も残してくれていて、blogからtagsへの参照も余分なクエリを発行する事無く行える。

ruby
Blog.includes(:tags).references(:tags).where('tags.id < ?', 3).each do |blog|
  p "#{blog.title}, #{blog.tags.first}"
end
postgresql
SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1 FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE (tags.id < 3)

元のincludesjoinsの良いとこどり見たいな感じで、すごい。

ただ、気をつけなければいけないのはlimitつけた場合で、DISTINCTを使って決められたレコード数のblogを取得してからOUTER JOINのクエリを発行しようとする。このDISTINCTの処理がなかなか重かったりするので、乱用は良く無いと思った。

ruby
Blog.includes(:tags).references(:tags).where('tags.id in (?)', [*100..200]).limit(100)
postgresql
SELECT  DISTINCT "blogs"."id" FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) LIMIT 100

SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1 FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) AND "blogs"."id" IN (`取得したblogのid`)

LEFT OUTER JOINのちょっとオシャレな方法

where句の条件として文字列を使わない場合には、referencesを付けなくてもLEFT OUTER JOINが出来る。

ruby
Blog.includes(:tags).where(tags: {id: [*100..200]})
postgresql
SELECT "rss_feeds"."id" AS t0_r0, "rss_feeds"."title" AS t0_r1, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1 FROM "rss_feeds" LEFT OUTER JOIN "taggings" ON "taggings"."rss_feed_id" = "rss_feeds"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`)

この方がオシャレ感が出て良いかもしれない。

includes + joinsで先読みしつつ絞り込み。

INNER JOINの場合でも、includes + joinsで先読みしつつJOIN先の条件で絞り込みが出来る。includes + references使った時との違いは、sizeでDISTINCTがかからない事。

ruby
Blog.includes(:tags).joins(:tags).where(tags: {id: [*100..200]}).limit(100)
postgresql
SELECT  DISTINCT "blogs"."id" FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) LIMIT 100

SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1 FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`取得されたtagsのid`)
ruby
Blog.includes(:tags).joins(:tags).where(tags: {id: [*100..200]}).size
postgresql
SELECT COUNT(*) FROM "blogs" INNER JOIN "taggings" ON "taggings"."blogs_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`)
=> 74

includes+referencesよりもincludes+joinsの方が使用頻度は高いかもしれない。

まとめ

Activerecordでは、関連するモデルを効率良く取得する為の手段としてincludesjoinsメソッドが提供されている。ただ参照先を利用したいならincludes, INNER JOINして絞り込みたいならjoins, 絞り込みつつ参照先も利用したいならincludes + referencesincludes + joinsを使えば良い。

と言いつつ、一番大事なのは実際に発行されたSQLと処理にかかった時間を見て判断する事だったりするので、ちょっと複雑なSQL書きたい時はきちんと確認する癖をつけると良いのかなと思う。

south37
RubyとかJavaScriptとか書きます。
http://south37.hatenablog.com/
wantedly
「シゴトでココロオドル」ためのビジネスSNS「Wantedly」の開発・運営をしています。
https://wantedlyinc.com/ja/presentations
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした