Rails「N+1問題」超分かりやすい解説・解決方法【includesを使おう】

スポンサーリンク

 

こんにちは、Railsエンジニアにょけん(@nyoken_box)です。

allメソッドなどを使うと度々起こる「N+1問題」について、概要と解決策をまとめました。

 

スポンサーリンク

「N+1問題」とは、データベースへのアクセスが非効率な状態のこと

超カンタンに言っちゃうと、「N+1問題」はRailsさんがデータベースを有効活用できていない状態です。

具体的には、1回のアクセスで済むところを何回もアクセスしてしまっている感じ。

「コンビニでパン・おにぎり・飲み物買ってきて」と頼んだら、まずパンを買って帰ってきて、次におにぎりを買ってまた帰ってきて、次に飲み物を買ってくるみたいな。

困ったマン
1回で全部買えばいいのに

って思いません?

まさにこれが「N+1問題」発生中の状態。

次から具体的に書いていきます。

 

スポンサーリンク

「N+1問題」は、1:多などモデルの関連付けをしている場合に起きやすい

「User:Post=1:多」の状態を想定します。

class User < ActiveRecord::Base
  has_many :posts
end
class Post < ActiveRecord::Base
  belongs_to :user
end

 

データベースはこんな感じ。

User_Table
id name age
1 Ken 30
2 Sara 24
3 Mike 17

 

Post_Table
id user_id content
1 1 Hello, I’m Ken.
2 2 Hi, I’m Sara.
3 1 I’m sleepy.
4 3 Eating now. 
5 1 Good morning.

各Postに、Userが紐付いています。

このとき、各Userのpost.contentを表示したかったら、以下のように書きますよね?

@users = User.all

@users.each do |user|
  puts user.post.content
end

これを実行すると、

①まず「Userモデル」を全て取得(User,all)

User_Table
id name age
1 Ken 30
2 Sara 24
3 Mike 17

↑こいつを読み込む

 

②最初の「puts user.post.content」で、Postのデータベースにアクセスしてuser_id=1のデータを取ってくる(以下緑色の部分)

Post_Table
id user_id content
1 1 Hello, I’m Ken.
2 2 Hi, I’m Sara.
3 1 I’m sleepy.
4 3 Eating now. 
5 1 Good morning.

 

③次の「puts user.post.content」で、またPostのデータベースにアクセスしてuser_id=2のデータを取ってくる(以下緑色の部分)

Post_Table
id user_id content
1 1 Hello, I’m Ken.
2 2 Hi, I’m Sara.
3 1 I’m sleepy.
4 3 Eating now. 
5 1 Good morning.

 

④最後に「puts user.post.content」で、Postのデータベースにアクセスしてuser_id=3のデータを取ってくる(以下緑色の部分)

Post_Table
id user_id content
1 1 Hello, I’m Ken.
2 2 Hi, I’m Sara.
3 1 I’m sleepy.
4 3 Eating now. 
5 1 Good morning.

 

このように、「Userテーブルに対して1回+Postテーブルに対してUser数の3回=計4回」のアクセスが起きます。

これが「N+1問題」と言われる理由!
感覚的には、「1+N問題」って言った方が分かりやすいかも。

 

これって、「user_id=1〜3をまとめて取得できれば、②〜④を1回で済ませられる」はずですよね?

そのやり方を次で解説します。

 

「N+1問題」は、includesメソッドで解決!

「N+1問題」を解決するには、Railsのincludesメソッドを使いましょう。

「include=含む」という意味で、「モデルA.include(:モデルB)」とすると、Aに基づくBを取得できます。

今回は、Userに紐付くPostを取得したいので以下のように書きます。

@users = User.includes(:post).all

@users.each do |user|
  puts user.post.content
end

これを実行すると、

①まず「Userモデル」を全て取得(ここは同じ)

User_Table
id name age
1 Ken 30
2 Sara 24
3 Mike 17

↑こいつを読み込む

 

②その後、Postのデータベースにアクセスして「user_id=1〜3のデータ」を取ってくる(以下緑色の部分)

Post_Table
id user_id content
1 1 Hello, I’m Ken.
2 2 Hi, I’m Sara.
3 1 I’m sleepy.
4 3 Eating now. 
5 1 Good morning.

こんな感じで、「N+1」のN部分を1回にまとめられます。

 

「N+1問題」発生中と、includes使った時の違いをSQLで表すと…

ついでにSQLで表すとどんな感じに違うのか書いておきます。

 

「N+1問題」発生中

@users = User.all

@users.each do |user|
  puts user.post.content
end

SQLで表すと…

SELECT 'users'.* FROM 'users'
SELECT 'posts'.* FROM 'posts' WHERE 'posts'.'user_id' = 1
SELECT 'posts'.* FROM 'posts' WHERE 'posts'.'user_id' = 2
SELECT 'posts'.* FROM 'posts' WHERE 'posts'.'user_id' = 3

 

「includes」使用

@users = User.includes(:post).all

@users.each do |user|
  puts user.post.content
end

SQLで表すと…

SELECT 'users'.* FROM 'users'
SELECT 'posts'.* FROM 'posts' WHERE 'posts'.'user_id' IN (1,2,3)

allでは「=」で1つ1つ取得していたのを、「IN」というメソッドでまとめて取得しています。

 

【応用】いろんな関連付けでの「includes」

シンプルなincludesはカンタンですが、関連付けが複雑化するに連れてめんどくさくなります。

 

「1 対 多」の場合

さっき説明した事例です。

「User:Post=1:多」とすると、以下でOK

@users = User.includes(:post).all

 

ちなみに、Postに基づくUserを取得したければ、関係性を入れ替えるだけ。

@posts = Post.includes(:user).all

SQLで表すと…

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

ちょっとした変化としては、idが普通にuser.idになります。(user.post_idっていうのはないので)

 

「多 対 1 対 多 」の場合

UserがPostとLike(お気に入り)の2つをhas_manyな場合に、「あるpostのuserのlikesを表示したい(=post投稿者のお気に入りを表示したい)」とします。

今まで通り

@posts = Post.include(:user).all

@posts.each do |post|
  puts post.user.likes
end

とすると、PostとUser間の「N+1問題」は解決できますが、UserとLike間で「N+1問題」が発生します。

解決するには、

@posts = Post.includes(user: :like).all

とすればOK

汎用的に書くと、「多A : 1: 多B」の場合は、「多A.includes(1: :多B)」としましょう。

ちなみにSQLで表すと…

SELECT 'posts'.* FROM 'posts'
SELECT 'users'.* FROM 'users' WHERE 'users'.'id' IN (1,2,3)
SELECT 'likes'.* FROM 'likes' WHERE 'likes'.'user_id' IN (1,2,3)

という処理が走ります。

 

「1 対 多×複数 」の場合

1つ上の例では、post.user.likesで「postに紐付くuserを取得→そのuserのlikesを取得」といったように、postとlikesを1文の中に使っていましたよね?

そうではなく、単純に「user.posts」「user.likes」の2つを表示したいだけの場合は、以下のコードで大丈夫です。

@users = User.include(:post, :like).all

@users.each do |user|
  puts user.posts
  puts user.likes
end

SQLでは…

SELECT 'users'.* FROM 'users'
SELECT 'posts'.* FROM 'posts' WHERE 'posts'.'user_id' IN (1,2,3)
SELECT 'likes'.* FROM 'likes' WHERE 'likes'.'user_id' IN (1,2,3)

という処理が走ります。

スポンサーリンク