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

Rails で includes して N+1 問題対策

More than 3 years have passed since last update.

はじめに

気を緩めてると気づいたら N+1 問題って起きてたりする。

N+1 問題とは何か

簡単に言うと必要以上に SQL が走るせいでパフォーマンスが低下する問題です。
例えば

User と Article の2つのモデルがある状態で、

app/models/user.rb
class User < ActiveRecord::Base
  has_many :articles
end
app/models/article.rb
class Article < ActiveRecord::Base
  belongs_to :user
end

上記のように User が複数の Article を持っているとする。
そして下記のコードで Article の title を表示する。

app/controllers/users_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end
end

app/views/articles/index.html.haml
- @articles.each do |article|
  = article.title
  = article.user.name

このコードだと以下のようなログを見ると SQL が発行される。

SELECT 'articles'.* FROM 'articles' # Article.all の実行

SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = 1 LIMIT 1 # article.user.name を実行する際に走る
SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = 2 LIMIT 1

このように Article の数だけ

SELECT 'users'.* FROM 'users' WHERE 'users'.'id' = n LIMIT 1

この SQL が発行されることになり、これが N+1 問題。

やりたいこと

N+1 問題を解決したい。
今回は includes を使って多段ネストの N+1 問題も解決する。
includes の挙動に関しては今回は省略。

解決方法

1 対 N の場合

つまり先程の例である。この場合コントローラで @articles を取得する際に

@articles = Article.all.includes(:user)

にすると

SELECT 'article'.* FROM 'articles'

SELECT 'users'.* FROM 'users' WHERE 'users'.'id' IN (1, 2)

の2つの SQL が発行されるだけになり、N+1 問題が解決する。

N 対 1 対 N の場合

先程のモデルに加えて、User が Comment も持っている場合。

app/models/user.rb
class User < ActiveRecord::Base
  has_many :articles
  has_many :comments
end
class Comment < ActiveRecord::Base
  belongs_to :user
end
app/views/articles/index.html.haml
- @articles.each do |article|
  = article.title
  = article.user.name
  - article.user.comments.each do |comment|
    = comment.content

上記のコードの時、Article の取得を先程と同じように

@articles = Article.all.includes(:user)

とすると、article.user.commentsuser.comments 部分で N+1 問題が起きてしまう。
この場合は

@articles = Article.all.includes(user: :comments)

とすると N+1 問題が解消される。

N 対 1 対 N 対 1 の場合

先程の例に加えて、さらに comment が 1 つのカテゴリに所属している場合(例がくそみたいに悪いですが気にしないでください。)

app/views/articles/index.html.haml
- @articles.each do |article|
  = article.title
  = article.user.name
  - article.user.comments.each do |comment|
    = comment.content
    = comment.category.name

この時は

@articles = Article.all.includes(user: {comments: :category})

で解決する。
またコメントが id が 1 のカテゴリに属しているか調べる場合(あまり使わない)

app/views/articles/index.html.haml
- article.user.comments.where(category_id: 1).present?

このように view で where を使ってしまうと、たとえ Article の取得を

@articles = Article.all.includes(user: {comments: :category})

としていても N+1 問題がおきてしまうので、

@articles = Article.all.includes(user: :comments)

として

- article.user.comments.pluck(:category_id).include?(1)

のようにすると N+1 が解決されるはず。

1 対 複数の N の場合

先程までのモデリングで view が

app/views/users/index.html.haml
- @users.each do |user|
  - user.articles.each do |article|
    = article.title
  - user.comments.each do |comment|
    = comment.content

の場合は、User の取得時に

@users = User.all.includes(:articles, :comments)

とすればよい

まとめ

N+1 問題は確かにパフォーマンスを下げはするが、N+1 問題を解決するために可読性の低いコードになってしまったり、余計に処理が複雑になってしまうことがあるので無理に直さなくてもいい場合もあると思う。
また、今回は includes を使ったがやり方はケースバイケースで違ったりもするので出くわした状況に応じて対策する必要がある。

途中くそみたいなモデリングのケースを出したが、あれは実際に私がどうしようか割りと悩んだところ。
今回は includesinclude? を使ったがもっといい方法があれば教えてほしい。

hirotakasasaki
マイブームはサーフィン、コーヒースタンド。ご一緒できる方ぜひに。
favy
デジタルマーケティングのスペシャリストと飲食業界出身の食のスペシャリストでチームは構成されていて、飲食市場に特化したマーケティング支援を軸に「飲食店がかんたんに潰れない世界を創る」を真剣に実現するためにチャレンジしています。
http://www.favy.jp/
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