N + 1問題

はじめに

Ruby on RailsなどDB(データベース)を使用するサーバーサイド言語で必ず上がる「N + 1問題」。

主に1対多のアソシエーション関係がある時に起こる問題です。 Railsでは一覧表示機能を含む1対多の関係が不可欠な機能が実装されるので、N + 1問題は理解しておくべきかと思います。

対象者

rails tutorialを学習済みの方 SQL初学者の方

使用環境

ruby 2.6.4 ・Rails 5.2.3 ・MySQL 5.4

N+1問題とは

一言で表すと、

「テーブル参照のSQLが大量に発行されてしまうこと」

実際に見ていきましょう。 今回は掲示板投稿機能を実装するためのrailsのファイルを使用します。

まず、N + 1問題を引き起こす投稿機能モデルのコントローラーとビューのファイルの中身です。

app/controllers/boards_controller.rb(コントローラー)

class BoardsController < ApplicationController

  def index
    @boards = Board.all
  end
end

app/views/boards/index.html.erb(ビュー)

  <div class="row">
    <div class="col-12">
      <div class="row">
        <% if @boards.present? %>
        <%= render @boards %>
        <% else %>
        <p><%= t('.no_board') %></p>
        <% end %>
      </div>
    </div>
  </div>

/render @boards/ これによってrailsはファイルの同じディレクトリにある_board.html.erbファイルをパーシャルとして読み込んでくれる仕様になっています。 (よしなにやってくれるrailsの特徴ですね)

同時にコントローラーで取得した@boardsを繰り返し処理で1つずつboardとしてパーシャルに渡します。 (長いですが、一言で言うと掲示板の投稿が1つずつ作成されているコードです)

app/views/boards/_board.html.erb(ビュー)

<div class="col-sm-12 col-lg-4 mb-3">
  <div id="board-id-<%= board.id %>">
    <div class="card">
      <%= image_tag 'board_placeholder.png', class: 'card-img-top', width: 300, height: 200 %>
      <div class="card-body">
        <h4 class="card-title">
          <%= link_to  board.title, "#" %>
        </h4>
        <div class='mr10 float-right'>
          <%= link_to '#', id: 'button-edit-#{board.id}' do %>
          <%= icon 'fa', 'pen' %>
          <% end %>
          <%= link_to '#', id: 'button-delete-#{board.id}', method: :delete, data: {confirm: ''} do %>
          <%= icon 'fas', 'trash' %>
          <% end %>
        </div>
        <ul class="list-inline">
          <li class="list-inline-item"><i class="far fa-user"></i>
            <%= board.user.decorate.full_name %>
          </li>
          <li class="list-inline-item"><i class="far fa-calendar"></i>
            <%= l board.created_at, format: :short %>
          </li>
        </ul>
        <p class="card-text">
          <%= board.body %>
        </p>
      </div>
    </div>
  </div>
</div>

この掲示板一覧を表示するタイミングで発行されるSQL文が以下になります。

 Rendering boards/index.html.erb within layouts/application
  Board Load (1.6ms)  SELECT "boards".* FROM "boards"
  ↳ app/views/boards/index.html.erb:16
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/views/boards/_board.html.erb:19
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]

めちゃくちゃ長い、、、 これrails tutorialではマイクロポストで投稿一覧を表示しますが、同じように長いSQL文が発行されます。

なぜこのようなことが起きるのか? 諸悪の根源はたった一文です

@boards = Board.all

ん?この部分はrails tutorialで習った通りじゃないですか?

何が問題かというと、@boardsに格納されている投稿(board)が呼び出される度に、そのboardがどのuserのものであるかを検索してい状態です。

つまり、投稿画面に遷移する時に、掲示板の全ての投稿を取得するために

Boardテーブル全体を参照するSQL文が1回発行される

Board Load (1.6ms)  SELECT "boards".* FROM "boards"

そして、のUsersテーブルを参照するSQLの文章が

掲示板の投稿の数(N回)だけ発行されている状態

  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

この投稿数の数(N) + 最初の全体参照(1)が"N + 1問題"です。

解決方法

しかし、このN + 1問題は問題の文を以下のように書き換えるだけで簡単に解決できてしまいます。

@boards = Board.all.includes(:user)

includesメソッドによりBoardテーブル参照時に、(boardの外部参照のためのuser_idカラムを元に)Userテーブルも同時に参照するようにしています。 よってテーブル参照回数も2回となります!! (たとえ掲示板の投稿が増えたとしても)

  Board Load (3.0ms)  SELECT "boards".* FROM "boards" ORDER BY "boards"."created_at" DESC
  ↳ app/views/boards/index.html.erb:16
  User Load (0.9ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?)  [["id", 2], ["id", 1], ["id", 3]]
  ↳ app/views/boards/index.html.erb:16

SQL文の発行も2回で済みます。 1対多のアソシエーション関係を持つモデルを扱う場合は是非覚えておきましょう!