更新日:
【Rails】 N+1問題をincludesメソッドで解決しよう!
includesメソッドとは、アソシエーションの関連付けを事前に取得してN +1問題を解決してくれるメソッドです。これによって、指定された関連付けが最小限のクエリ回数で読み込まれます。
1
モデル名.includes(:関連名) # 関連名はテーブル名ではない
includesメソッドに渡す引数は、テーブル名ではなくアソシエーションで定義した関連名を指定します。それでは、includesメソッドの使い方とそもそもN+1問題とは何なのかを解説していきます。
includesメソッドの基礎知識
この章では、includesメソッドの基礎知識について解説します。
N + 1問題とは?
N + 1問題とは、必要以上にSQLが発行されてしまい、動作が悪くなってしまう問題のことです。
SQLとは、データベース言語の1つでデータベースの操作を行います。RailsではActive Recoredによってデータベース操作を行う事が出来ますが、裏側ではSQLが実行されています。
1
Owner Load (2.7ms) SELECT `owners`.* FROM `owners`
「Owner Load (2.7ms)」の様にSQLを実行する際にわずかですが時間が掛かります。これが数回でしたら特に動作に影響はありませんが、何万回と回数が増えてしまうと動作が悪くなってしまいます。
N + 1 問題をコードで確認する
ネコの飼い主のテーブルがownersテーブル、ネコの名前を扱うテーブルがcatsテーブルの2つのテーブルを例にN +1 問題を確認していきましょう。テーブルの構成は下記の通りです。
catsテーブルのowner_idカラムが外部キーとなって対応するownersテーブルのレコードを参照しています。主キーと外部キーが分からない方は、まずはこちらの「図とテーブルで理解するアソシエーション」を参考にしてください。
「1人のownerは複数のcatsを持つ関係(Owner has_many cats)」なので、Ownerモデルにhas_manyメソッドを定義し、Catモデルにはbelongs_toメソッドを定義します。
1
2
3
4
5
6
7
8
9
# owner.rb
class Owner < ActiveRecord::Base
has_many :cats
end
# cat.rb
class Cat < ActiveRecord::Base
belongs_to :owner
end
「全ての飼い主の猫の一覧」をviewで表示したい場合に、controller側で全ての飼い主をallメソッドで取得し、view側で飼い主の持つcatsをアソシエーションによって下記の様に記述する事が出来ます。
1
2
3
4
5
6
7
8
9
# controller側
@owners = Owner.all
# view側
@owners.each do |owner|
owner.cats.each do |cat|
cat.name
end
end
このコードを実行してしまうと、必要以上にSQLを発行するN+1問題が起こります。では、実際にコードを実行した時のテーブルとSQLの流れを確認していきましょう。
N + 1 問題をテーブルとSQLで確認する
まずは「@owners = Owner.all」のコードを確認していきます。このコードの裏側で発行されているSQL文に注目して下さい。
1
2
@owners = Owner.all
SELECT `owners`.* FROM `owners` #発行されるSQL文
「Owner.all」のコードが実行されると、下記の様に「ownersテーブルからownersテーブルの全てのカラム」が取得されていることが分かります。このSQLによって、ownersテーブルに1回のアクセスが行われました。
次にveiw側のコードを確認しましょう。SQLをみると、catsテーブルに対して、4回のアクセスが行われている事がわかります。この4回のアクセスが行われている流れを確認していきます。
1
2
3
4
5
6
7
8
9
10
11
@owners.each do |owner|
owner.cats.each do |cat| # このコードが4回のSQL文を発行している
cat.name
end
end
# 4回SQL文を発行している
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` = 1
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` = 2
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` = 3
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` = 4
まず、1行目の「@owners.each do |owner|」のeachメソッドが「@owners = Owner.all」で取得した全ての飼い主のレコードから1つずつレコードを取り出してownerに代入しています。
最初のownerには、田中さんのレコードが入っているので「owner.cats.each do |cat|」のowner.catsで田中さんの飼っている猫のレコードを全て取得します。これが1回目のSQLの発行で、ownersテーブルに関連するcatsテーブルに1回目のアクセスが行われました。
田中さんのレコードの処理が終わると、次のownerには、ownersテーブルのid=2の伊藤さんのレコードが入るので「owner.cats.each do |cat|」のowner.catsで伊藤さんの飼っている猫のレコードを全て取得します。これでownersテーブルに関連するcatsテーブルに2回目のアクセスが行われました。
そして、次にownersテーブルのid=3の高橋さんのレコードがownerに入り、高橋さんの飼い猫を全て取得します。これでownersテーブルに関連するcatsテーブルに3回目のアクセスが行われました。
最後にownersテーブルのid=4の加藤さんの飼い猫のレコードを見にいきます。加藤さんは猫を飼っていないのでレコードは存在しませんが、SQLは発行されています。これでownersテーブルに関連するcatsテーブルに4回目のアクセスが行われました。
SQLが「ownersテーブルへのアクセスが1回 」に対して「catsテーブルへのアクセスがownersテーブルのレコードの数(4回)」発行されています。
このようにownersテーブルへのアクセス1回に対して、関連するテーブルがN回発行されている1+Nの状況を「N+1問題」と言います。
ownersテーブルにアクセスする際に、関連するcatsテーブルのレコードを取得する事が出来れば、発行されるSQLも4回から1回にまとめる事が出来ます。
これを実現してくれるのがincludesメソッドなのです。
N+1問題については、こちらのRuby on Railsの良書として有名な書籍でも触れられているのでぜひ参考にしてみてください。
includesメソッド
includesメソッドは、引数にアソシエーションで定義した関連名を指定して定義します。(※テーブル名ではないので注意してください)
1
モデル名.includes(:関連名) # 関連名はテーブル名ではない
includesメソッドで指定された関連付けが最小限のクエリ回数で読み込まれます。これによってN+1問題を解決する事が出来ます。それでは、先ほどのサンプルコードをincludesメソッドを使って確認していきます。
includesメソッドを使ってN+1問題を解決しよう
ownersテーブルにアクセスする際に、関連するcatsテーブルをincludesメソッドを使って取得してみましょう。
Ownerモデルに定義されていたアソシエーションは下記の通りでした。
1
2
3
class Owner < ActiveRecord::Base
has_many :cats #これが関連名
end
「Owner has many cats」の関係なので、includes(:関連名)には、includes(:cats)が入ります。
@owners = Owner.all
で取得していた箇所を@owners = Owner.includes(:cats)
に変更します。
1
2
3
4
5
6
7
8
9
10
11
12
@owners = Owner.includes(:cats) # Owner.allから変更
# 発行される2つのSQL
SELECT `owners`.* FROM `owners`
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` IN (1, 2, 3, 4)
# 返り値
=> #<ActiveRecord::Relation
[#<Owner id: 1, name: "田中", created_at: "2019-11-25 08:51:22", updated_at: "2019-11-25 08:51:22">,
#<Owner id: 2, name: "伊藤", created_at: "2019-11-25 08:51:27", updated_at: "2019-11-25 08:51:27">,
#<Owner id: 3, name: "高橋", created_at: "2019-11-25 08:51:32", updated_at: "2019-11-25 08:51:32">,
#<Owner id: 4, name: "加藤", created_at: "2019-11-25 09:54:50", updated_at: "2019-11-25 09:54:50">]>
「Owner.includes(:cats)」のコードを実行する事によって、下記の2つのSQLが発行されます。1行目は、ownersテーブルの全てのレコードを取得するSQL文です。2行目では、catsテーブルからWHERE句で指定した条件にマッチするレコードを取得しています。
1
2
SELECT `owners`.* FROM `owners`
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` IN (1, 2, 3, 4)
WHERE句では、catsテーブルのowner_idカラムの値がIN句で指定している1,2,3,4のいずれかとマッチするレコードを取得しています。このIN句の1,2,3,4の値は、1行目で取得したownersテーブルの主キーの値が入っています。
includesメソッドを使わない場合は、関連するowner_idカラムの値を1つずつ指定して取得していたのでcatsテーブルに4回のアクセスが必要でしたが、IN句でカラムの値をまとめて指定した事によって1回のアクセスで取得出来るようになりました。
そして、includesメソッドを使った場合の返り値にも注目してください。取得されているのは、ownersテーブルのレコードのみに見えますが、内部的にはownersテーブルに関連するcatsテーブルのレコードも含めて取得する事が出来ています。
1
2
3
4
5
6
7
8
@owners = Owner.includes(:cats) # Owner.allから変更
# 返り値
=> #<ActiveRecord::Relation
[#<Owner id: 1, name: "田中", created_at: "2019-11-25 08:51:22", updated_at: "2019-11-25 08:51:22">,
#<Owner id: 2, name: "伊藤", created_at: "2019-11-25 08:51:27", updated_at: "2019-11-25 08:51:27">,
#<Owner id: 3, name: "高橋", created_at: "2019-11-25 08:51:32", updated_at: "2019-11-25 08:51:32">,
#<Owner id: 4, name: "加藤", created_at: "2019-11-25 09:54:50", updated_at: "2019-11-25 09:54:50">]>
この状態で、view側のコードを実行してみます。
1
2
3
4
5
6
7
8
9
10
11
@owners.each do |owner|
owner.cats.each do |cat|
puts cat.name
end
end
# コード実行結果
モモ
ミー
クロ
ハナ
特にSQLが発行される事なくコードが実行されます。これは、controller側でincludesメソッドを使って既にownersテーブルのレコードに関連するcatsテーブルのレコードが取得されている為、view側で必要以上のSQLを発行せずに済みます。
このように、includesメソッドを使って関連するテーブルのレコードをまとめて取得させる事によって必要以上のSQLを発行する事なく済みます。
Catモデルにincudesメソッドを使って関連するモデルも取得しよう
先ほどは、includesメソッドを使ってownersテーブルにアクセスする際に関連するcatsテーブルのレコードを事前に取得してN+1問題を解決することが出来ていましたが、catsテーブルのレコードを全て取得して関連するownersテーブルのレコードを取得する場合も、includesメソッドを利用しないとN+1問題が起こります。
N+1問題が発生する例を見ながら確認していきます。controller側にallメソッドを利用してcatsテーブルから全てのレコードを取得します。この時にcatsテーブルに対して1回のアクセスが行われます。
1
2
@cats = Cat.all
SELECT `cats`.* FROM `cats`
そして、catsテーブルの全てのレコードが入っている@catsをeachメソッドで1つ1つ取り出し、猫の飼い主の名前を「cat.owner.name」で指定します。この時にN+1問題がおこります。
1
2
3
4
5
6
7
8
9
@cats.each do |cat|
cat.owner.name # ここでN+1問題が発生する
end
# ownersテーブルに対して4回アクセスされてしまう
SELECT `owners`.* FROM `owners` WHERE `owners`.`id` = 2 LIMIT 1
SELECT `owners`.* FROM `owners` WHERE `owners`.`id` = 1 LIMIT 1
SELECT `owners`.* FROM `owners` WHERE `owners`.`id` = 3 LIMIT 1
SELECT `owners`.* FROM `owners` WHERE `owners`.`id` = 1 LIMIT 1
「cat.owner.name」で発行されるSQLを確認すると、catsテーブルのアクセスが1回だったのに対して、関連するownersテーブルに対してのアクセスは、4回行われています。
includesメソッドを使って、catsテーブルのレコードを取得する際に関連するownersテーブルのレコードを事前に取得することで、このN+1問題を解決することが出来ます。
まず、Catモデルに定義されていたアソシエーションは下記の通りでした。
1
2
3
class Cat < ActiveRecord::Base
belongs_to :owner #これが関連名
end
「Cat belongs to Owner」の関係になので、includes(:関連名)の関連名には「owner」を指定して、Cat.includes(:owner)になります。
1
2
3
4
5
6
7
8
9
10
11
12
@cats = Cat.includes(:owner)
# 発行される2つのSQL
SELECT `cats`.* FROM `cats`
SELECT `owners`.* FROM `owners` WHERE `owners`.`id` IN (2, 1, 3)
# 返り値
=> #<ActiveRecord::Relation
[#<Cat id: 1, name: "クロ", owner_id: 2, created_at: "2019-11-25 08:53:31", updated_at: "2019-11-25 08:53:31">,
#<Cat id: 2, name: "モモ", owner_id: 1, created_at: "2019-11-25 08:53:43", updated_at: "2019-11-25 08:53:43">,
#<Cat id: 3, name: "ハナ", owner_id: 3, created_at: "2019-11-25 08:53:59", updated_at: "2019-11-25 08:53:59">,
#<Cat id: 4, name: "ミー", owner_id: 1, created_at: "2019-11-25 08:54:39", updated_at: "2019-11-25 08:54:39">]>
Cat.includes(:owner)」のコードを実行する事によって、下記の2つのSQLが発行されます。1行目は、catsテーブルの全てのレコードを取得するSQL文です。2行目では、ownersテーブルからWHERE句で指定した条件にマッチするレコードを取得しています。
1
2
SELECT `cats`.* FROM `cats`
SELECT `owners`.* FROM `owners` WHERE `owners`.`id` IN (2, 1, 3)
WHERE句では、ownersテーブルのidカラムの値がIN句で指定している2,1,3のいずれかとマッチするレコードを取得します。このIN句の2,1,3の値は、1行目で取得したcatsテーブルのcat_idの外部キーの値になります。
IN句によってカラムで値を事前にまとめて取得しているので、ownersテーブルにアクセスしていた回数を4回から1回にまとめることが出来ました。
取得できる様になりN+1問題を解決することが出来ました。includesメソッドを使って事前にcatsテーブルのレコードと関連するownersテーブルのレコードが入った@catsを使いview側のコードを実行します。
1
2
3
4
5
6
7
8
9
@cats.each do |cat|
cat.owner.name
end
# コード実行結果
伊藤
田中
高橋
田中
事前にcatsテーブルに関連するownersテーブルのレコードを取得することが出来ているので、必要のないSQLは発行されずN+1問題を解決することが出来ました。
includesメソッドの応用的な使い方
この章では、includesメソッドの応用的な使い方について解説します。
includesメソッドのネスト
1人の飼い主は複数の猫を飼っていましたが、その猫には複数の子猫がいます。子猫のテーブルをchildrenテーブルとします。1匹の猫は複数の子猫を持ち「Cat has many Child」の関係になります。catsテーブルとchildrenテーブルの関係は下記の通りです。
さらに複数の猫は1人の飼い主に属しているので、ownersテーブルを含めた3つのテーブルの関係を整理すると下記の様になります。
ownersテーブルとchildrenテーブルは、直接の関係はありませんが、childrenテーブルが外部キー(cat_id)を通してcatsテーブルを参照し、catsテーブルが外部キー(owner_id)を通してownersテーブルを参照しています。
それぞれの関係性をアソシエーションで定義すると、下記の様になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# owner.rb
class Owner < ActiveRecord::Base
has_many :cats
end
# cat.rb
class Cat < ActiveRecord::Base
belongs_to :owner
has_many :children
end
# child.rb
class Child < ActiveRecord::Base
belongs_to :cat
end
先ほどのサンプルコードでは、includesメソッドを使って飼い主に関連する猫のレコードを取得する事によってN+1問題を解決する事が出来ていました。
1
2
3
4
5
@owners = Owner.includes(:cats)
# 発行される2つのSQL
SELECT `owners`.* FROM `owners`
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` IN (1, 2, 3, 4)
1
2
3
4
5
6
7
8
9
10
11
@owners.each do |owner|
owner.cats.each do |cat|
puts cat.name
end
end
# コード実行結果
モモ
ミー
クロ
ハナ
しかし、今回の様なテーブルがネストしている状態でcatsテーブルのレコードに関連するchildrenのレコードを取得する時は、どの様なSQLが発行されるのでしょうか? view側のコードを変更して確認していきます。
飼い主の猫の子供を取得してみよう
飼い主の猫の子供を取得するには、飼い主に関連する猫、更にその猫に関連する子供のレコードを取得する必要があります。
その為「cat.children.each do |child|」のコードを下記の様にview側に追加します。このコードによって発行されているSQLを確認すると、childrenテーブルに対して4回のアクセスが行われいる事が分かります。
1
2
3
4
5
6
7
8
9
10
11
12
13
@owners.each do |owner|
owner.cats.each do |cat| # includesメソッドで取得しているのでN+1問題は起こらない
cat.children.each do |child| # ここでN+1問題が起こる
puts child.name
end
end
end
# childrenテーブルに対して4回アクセスしている
SELECT `children`.* FROM `children` WHERE `children`.`cat_id` = 2
SELECT `children`.* FROM `children` WHERE `children`.`cat_id` = 4
SELECT `children`.* FROM `children` WHERE `children`.`cat_id` = 1
SELECT `children`.* FROM `children` WHERE `children`.`cat_id` = 3
どこかで見たSQLと似ていますね。これは、includesメソッドを使用していない時の「owner.cats.each do |cat| 」と一緒です。今回はcontroller側でincludesメソッドを使って関連するcatsテーブルのレコードを事前に取得しているのでN+1問題が起こりません。
しかし、catsテーブルのレコードと関連するchildrenテーブルのレコードは、事前に取得していないので、catsテーブルのレコードの回数だけchildrenテーブルにSQLが発行される事になります。
catsテーブルへのアクセスは、includesメソッドによって1回のアクセスだけになっています。SQLが「catsテーブルへのアクセスが1回 」に対して「 childrenテーブルへのアクセスがcatsテーブルのレコードの数(4回)発行」されるN+1問題が起きてしまっています。
連続して参照する様な関連の場合もincludesメソッドを使って解決する事が出来ます。
ネストしている状態のN+1問題もincludesメソッドで解決しよう
連続して参照している関連名がある場合に、includesメソッドは下記の様に定義します。
1
モデル名.includes(関連名1: :関連名2)
それでは、 先ほどのownersテーブルとcatsテーブル、childrenテーブルを例に確認してみましょう。
1匹の猫は複数の子猫を持つので「Cat has many Child」の関係なので、それぞれのアソシエーションは下記の様に定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# owner.rb
class Owner < ActiveRecord::Base
has_many :cats
end
# cat.rb
class Cat < ActiveRecord::Base
belongs_to :owner
has_many :children
end
# child.rb
class Child < ActiveRecord::Base
belongs_to :cat
end
そして、このアソシエーションを元に連続して参照している関連をincludesメソッドで指定します。includesメソッドの引数には、「モデル名.joins(Ownerモデルの関連名: :Catモデルの関連名)」つまりOwner.includes(cats: :children)を指定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
@owners = Owner.includes(cats: :children) #includes(Ownerモデルの関連名: :Catモデルの関連名)
# 発行される3つのSQL
SELECT `owners`.* FROM `owners`
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` IN (1, 2, 3, 4)
SELECT `children`.* FROM `children` WHERE `children`.`cat_id` IN (1, 2, 3, 4)
# 返り値
=> #<ActiveRecord::Relation
[#<Owner id: 1, name: "田中", created_at: "2019-11-25 08:51:22", updated_at: "2019-11-25 08:51:22">,
#<Owner id: 2, name: "伊藤", created_at: "2019-11-25 08:51:27", updated_at: "2019-11-25 08:51:27">,
#<Owner id: 3, name: "高橋", created_at: "2019-11-25 08:51:32", updated_at: "2019-11-25 08:51:32">,
#<Owner id: 4, name: "加藤", created_at: "2019-11-25 09:54:50", updated_at: "2019-11-25 09:54:50">]>
「Owner.includes(cats: :children)」のコードを実行する事によって、下記の3つのSQLが発行されます。1行目は、ownersテーブルの全てのレコードを取得するSQL文です。2行目では、catsテーブルからWHERE句で指定した条件にマッチするレコードを取得しています。そして3行目のchildrenテーブルですが、IN句を使ってまとめて取得しているので1回のアクセスのみになっています。
1
2
3
SELECT `owners`.* FROM `owners`
SELECT `cats`.* FROM `cats` WHERE `cats`.`owner_id` IN (1, 2, 3, 4)
SELECT `children`.* FROM `children` WHERE `children`.`cat_id` IN (1, 2, 3, 4)
更に返り値に注目すると、取得されているのは、ownersテーブルのレコードのみに見えますが、内部的にはownersテーブルに関連するcatsテーブルのレコードに更に関連するchildrenテーブルのレコードも含めて取得する事が出来ています。
1
2
3
4
5
6
7
8
@owners = Owner.includes(cats: :children) #includes(Ownerモデルの関連名: :Catモデルの関連名)
# 返り値
=> #<ActiveRecord::Relation
[#<Owner id: 1, name: "田中", created_at: "2019-11-25 08:51:22", updated_at: "2019-11-25 08:51:22">,
#<Owner id: 2, name: "伊藤", created_at: "2019-11-25 08:51:27", updated_at: "2019-11-25 08:51:27">,
#<Owner id: 3, name: "高橋", created_at: "2019-11-25 08:51:32", updated_at: "2019-11-25 08:51:32">,
#<Owner id: 4, name: "加藤", created_at: "2019-11-25 09:54:50", updated_at: "2019-11-25 09:54:50">]>
この状態で、view側のコードを実行してみます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# controller側
@owners = Owner.includes(cats: :children) #includes(Ownerモデルの関連名: :Catモデルの関連名)
# view側
@owners.each do |owner|
owner.cats.each do |cat|
cat.children.each do |child|
puts child.name
end
end
end
# 実行結果
クロコ
ハナコ
特にSQLが発行される事なくコードが実行されます。これは、controller側でincludesメソッドを使って既にownersテーブルのレコードに関連するcatsテーブルのレコードの更に関連するchildrenのレコードが取得されているからです。
このように、連続して関連する様な関係でもincludesメソッドを使ってまとめて関連するレコードを取得する事によってN+1問題を解決する事が出来ます。
この記事のまとめ
- includesメソッドとは、アソシエーションの関連付けを事前に取得してN +1問題を解決してくれるメソッドのこと
- N + 1問題とは、必要以上にSQLが発行されてしまい、動作が悪くなってしまう問題のこと
- SQLとは、データベース言語の1つでデータベースの操作を行う