welcome to ginnan blog.

Ginnan blog

銀杏ブログへようこそ。
主にプログラミングの学習アウトプットです。

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問題を回避できます。

written at 2021/3/16.
銀杏くん
2年間メーカーSEとして勤務した後、プログラミング教育事業へ転職。
タイトルの由来は居酒屋でたまたま銀杏串を食べてる時にブログやろうと思い立ったから。
無駄なく"シンプルなブログ"を目指したい。
※ブログはまだまだ改修中です。