N+1問題
例えば、user(has_many :posts)なるテーブルと、post(belongs_to :user)なるテーブルの二つがあるとする。
userは0個以上のpostを持っていて、postには各userのid(user_id)が必ず対応している。
ここで、各userの持っているpostを取り出し、userの名前(user.name)と、postのタイトル(post.content)を全件表示したいとき、どのようなコードを書けばよいだろうか。
最も分かりやすいのは以下のコードだろう。
User.all.each do |user|
user.posts.each do |post|
puts "#{user.name} / #{post.title}"
end
end
このコードで実行される内容は、
(1). userの情報(user.id,user.name)を取得するために、userテーブルにアクセス
2. id = 1のuserのpostの情報を取得するためにpostテーブルにアクセス
3. id = 2のuserのpostの情報を取得するためにpostテーブルにアクセス
4. id = 3のuserのpostの情報を取得するためにpostテーブルにアクセス
5. id = 4のuserのpostの情報を取得するためにpostテーブルにアクセス
...
n+1. id = nのuserのpostの情報を取得するためにpostテーブルにアクセス
とまあこんな感じで、n+1回の処理が行われることになる。
しかし、n+1回も処理してたら重いし問題だよねということで、このような状況が「N+1問題」と呼ばれる。
解決策
以下のようにcontroller内でincludesメソッドを使ってインスタンス変数を定義しておくことは、ポピュラーな解決策の一つ。
@users = User.includes(:posts)
@users.each do |user|
user.posts.each do |post|
puts "#{user.name} / #{post.title}"
end
end
このコードで実行される処理は
- userの情報を取得するために、userテーブルにアクセス
- postの情報を取得するためにpostテーブルにアクセス
- userのidと、postに関連付けられているuser_idを結びつけてテーブル同士を結合
- 結合されたテーブルの情報を基にviewにuserの名前とpostのタイトルが表示される
と、テーブルへのアクセスが最初の2回のみに抑えられている。
先程の例との違いは、一回一回データを取りにテーブルを出入りするのではなく、userデータとpostデータ、それぞれ一回でそのテーブルの全てのデータをまとめて取得しておいて、user_idというキーでテーブル同士を結びつけ、順番に表示する点。
また、テーブルをJOINしてから取得する方法も解決策として考えられる。
includeとJOINの違いは、includeがuserのデータと、postのデータをそれぞれ別々に取得して、アプリケーションでそれらを紐づけていくのに対して、JOINの場合はデータを取得する段階で、userとpostを結びつけた状態でデータを取得する。このため、includeの場合は発行されるクエリは2回であるのに対し、JOINの場合は発行するクエリは1回のみ。ただし、データを結びつけた状態で取得するJOINの処理には時間がかかる。
参考