Ruby
Rails

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

More than 1 year has 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? を使ったがもっといい方法があれば教えてほしい。