N+1 問題
目次
N+1問題とは何か?またどのように解決すべきなのか解説します。
N+1問題とはなにか?
N+1問題とは端的に言うと
複数のmodelからのデータベースアクセスで無駄なSQLが走ることです。
これだけだとなかなか掴みづらいので具体例を用いて説明します。
まず以下のデータを用意しました。
- User x 1
- Message x 100
ユーザ1人(銀杏くん)とメッセージ100コ(sample message n)を用意しました。
一覧表示すると以下のような状態です。
あえて途中で切っていますが100件のデータがズラーっと並んでいます。
表示用のHTMLとしては以下です。
messageに対してeach文を回して1件ずつ表示しています。
<% @messages.each do |message| %>
<tr>
<td><%= message.text %></td>
<td><%= message.user.name %></td>
<td><%= link_to 'Show', message %></td>
<td><%= link_to 'Edit', edit_message_path(message) %></td>
<td><%= link_to 'Destroy', message, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
またmessageはuserとアソシエーションを組んでいます。
なのでmessage.userとするだけでuserのデータを引っ張ってくる事ができます。
class Message < ApplicationRecord
belongs_to :user
end
しかし、こちらを何も考えずに以下のように呼び出すと。。
N+1問題が発生します。
def index
@messages = Message.all
end
message loading time is =====> (4.0ms)
Rendering layout layouts/application.html.erb
Rendering messages/index.html.erb within layouts/application
Message Load (2.0ms) SELECT `messages`.* FROM `messages`
↳ app/views/messages/index.html.erb:14
User Load (2.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
〜以下略(100件続きます。)〜
上記のようにindex.html.erb:17(<%= message.user.name %>の記載)の箇所で毎回、「同じSQL」がmessageの数(n回)走ります。
1回読み込んでおけば良いものを無駄に何回も読みに行ってしまいます。
- controller側でのmessage.allによるメッセージ全体の呼び出し1回
- view側でのmessage毎のuserの呼び出しN回
これがN+1問題と言われている所以です。
どのように対処すればよいのか?
結論、呼び出すときにincludesメソッドを使うだけです。
対処策は至ってシンプルです。
def index
#@messages = Message.all ↓ 以下を追記
@messages = Message.all.includes(:user)
end
こちらの対処を行うことでログを見ても発行されるSQL量の差も明らかです。
N+1問題が発生している時
message loading time is =====> (4.0ms)
Rendering layout layouts/application.html.erb
Rendering messages/index.html.erb within layouts/application
Message Load (2.0ms) SELECT `messages`.* FROM `messages`
↳ app/views/messages/index.html.erb:14
User Load (2.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
User Load (0.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
↳ app/views/messages/index.html.erb:17
〜以下略(100件続きます。)〜
includsメソッドを活用した時
message loading time is =====> (0.1ms)
Rendering layout layouts/application.html.erb
Rendering messages/index.html.erb within layouts/application
Message Load (1.7ms) SELECT `messages`.* FROM `messages`
↳ app/views/messages/index.html.erb:14
User Load (1.9ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
↳ app/views/messages/index.html.erb:14
比較のために一番上の行に処理時間を書いています。
N+1問題が発生している時は4.0ms
includesメソッド使用時は0.1ms
その差はおよそ40倍になっています。
100件のレコードでこれまでの差がでるので対処しない理由はありません。
補足:ActiveStorageを使用した際のN+1問題の対処
上記の例は単純にアソシエーションを組んでいた際に発生していた問題でした。
ActiveStorageを活用した場合はまた対処に使うメソッドがことなります。
ActiveStorageの際は、
with_attached_images
というメソッドを活用することでN+1問題を回避できます。