Ruby

joinsを使ってeager loadingをする方法

More than 5 years have passed since last update.

joinsを使ってeager loadingをする方法です。

その前提の話として


  • N + 1 問題

  • eager lodingとは

  • includesの欠点

  • joins

と長くなってしまいましたが、そんなん知ってるという方は最後の


  • joinsを使ってeager loadingをする方法

そこだけ見てください。


N + 1 問題

class Guild < ActiveRecord::Base

has_many :users
end

class User < ActiveRecord::Base
belongs_to :guild
end

というモデルがあった時

users = User.all

users.each { |user| puts user.guild.name }

みたいなことをした時に(適当ですが)

SELECT `users`.* FROM `users`

SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 1 LIMIT 1
SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 2 LIMIT 1
SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 3 LIMIT 1

users.allで一回。

users.each { |user| puts user.guild.name }usersの数の分だけSQLが発行されます。

これが N + 1 問題です。この問題を回避するのが eager lodingです


eager loadingとは

users = User.includes(:guild)

上記は以下のSQLを発行されます。

SELECT `users`.* FROM `users`

SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` IN (1, 2, 3)

includesを使用することで2つのSQLを発行し、usersguildsの両方を一気に読み込みます。

そのため、users.each { |user| puts user.guild.name }では一度もSQLを発行することなく処理ができます。

N + 1だったクエリ数が2回になります。この仕組みがeager loadingです。


includesの欠点

eager loadingはパフォーマンス向上のために必要です。

しかし、includesには問題があります。

User.includes(:guild).where("guilds.id IN (1, 2, 3)")

というようにwhere句でincludesで読み込んだテーブルを条件にしようとしたとき

SELECT `users`.`id` AS t0_r0, `users`.`guild_id` AS t0_r1, `users`.`name` AS t0_r2, `guilds`.`id` AS t1_r0, `guilds`.`name` AS t1_r1 

FROM `users`
LEFT OUTER JOIN `guilds` ON `guilds`.`id` = `users`.`guild_id`
WHERE (guilds.id IN (1, 2, 3))

というSQLになります。発行されるのはLEFT JOINなのでguildsが存在しない時でもusersは全部ロードされます。

意図通りであれば問題なのですが、基本的にはこういう場合joinsを使うことが推奨されています。


rails4

rails4ではreferencesを明示的に指定するようになってます。(らしい)

http://qiita.com/joker1007/items/e951d0cf98309dba90db

User.includes(:guild).where("guilds.id IN (1, 2, 3)").references(:guilds)


joins

http://guides.rubyonrails.org/active_record_querying.html#specifying-conditions-on-eager-loaded-associations

関連するテーブルを読み込みwhere句で関連テーブルを条件にするときはincludesではなくjoinsが推奨されています。

User.joins(:guild).where("guilds.id IN (1, 2, 3)")

SELECT `users`.* FROM `users` INNER JOIN `guilds` ON `guilds`.`id` = `users`.`guild_id` WHERE (guilds.id = 1,)

こちらはINNER JOINなので先ほどのincludesの問題がありせん。

しかし、eager loadingが効いていないのです。

users.each { |user| puts user.guild.name }を実行した時に

SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 1 LIMIT 1

SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 2 LIMIT 1
SELECT `guilds`.* FROM `guilds` WHERE `guilds`.`id` = 3 LIMIT 1

というSQLが発行されてしまい、N + 1問題が起きます。


joinsを使ってeager loadingをする方法

そこで、joinsを使ってeager loadingをする方法です。

selectを使います。

User.joins(:guild)

.where("guilds.id IN (1, 2, 3)")
.select("users.*, guilds.name AS hoge")

次のSQLが発行されます。

SELECT users.*, guilds.name AS hoge FROM `users` 

INNER JOIN `guilds` ON `guilds`.`id` = `users`.`guild_id`
WHERE (guilds.id IN (1, 2, 3))

もちろんですが、SELECT users.*, guilds.name AS hogeとなり、users.*guilds.nameがロードされます。

ロードしておきたいguildsのカラムがたくさんあればその分selectの中に書く必要があります。

ロードしたデータにはuser.guild.hogeuser.guild.nameではなくuser.hogeという風にアクセスします。

users.each { |user| puts user.hoge }

user.guild.hogeはもちろんエラーになります。

user.guild.nameではguildのロードが実行されてしまってN + 1問題になってしまいます。