0
1

More than 3 years have passed since last update.

クリスマスのN + 1

Last updated at Posted at 2020-12-25

はじめに

12月25日。世間はクリスマス当日ですが、そんなことは置いておいて
分かっているようであやふやだったN+1問題について、備忘録として今一度まとめました。
(書いていたら、タッチの差で日付変わってましたが…)

開発環境

Ruby 2.6.5
Rails 6.0.3.4
MySQL
Visual Studio Code
(GoogleChrome)

N+1問題とは

N+1問題とは、テーブルがアソシエーションを利用することで、結びつくテーブルが多くなり、データベースへのアクセス回数が多くなってしまう問題です。
SQLが沢山発行されてしまうことで、データベースに負荷がかかり、アプリケーションのパフォーマンス低下の原因となります。

N+1問題の例

個人で作成した、ToDoリストアプリ的なもので例をあげます。
ToDoリストそのものはlistsテーブルと、リスト内の項目はitemsテーブルです。
一つのリストは沢山の項目を持っているので、下記のような1対多のアソシエーションが組まれています。

listsテーブル

Column Type Options
name string null: false
text text
trip references null: false, foreign_key: true
lists.rb
class List < ApplicationRecord
  has_many :items
  validates :name, presence: true
end

itemsテーブル

Column Type Options
name string null: false
text text
list references null: false, foreign_key: true
item.rb
class Item < ApplicationRecord
  belongs_to :list
  validates :name, presence: true
end

全リストを表示したい場合、controllerでallメソッドを使って取得し、view側でeachメソッドを用いて記述します。

lists_controller.rb
class ListsController < ApplicationController
  def index
    @lists = List.all
  end
(省略)
end

また、アソシエーションを組んでいるため、リストに紐づいた項目はlist.itemsで取得可能です。

index.html.erb
<% @lists.each do |list| %> # 全てのリストを一覧で表示
 <div class="list">
  <div class="list-name">
   リスト名:<%= list.name %>
  </div>
  <% list.items.each do |item| %> # 各リスト内の項目を全て一覧で表示
   <div class="item-name">
    <%= item.name %>
   </div>
  <% end %>
 </div>
<% end %>

しかし、このコードを実行すると…
猫も走るほど忙しいこんな年末に…ターミナルも忙しいことになっています。

ターミナル
# @List.allの挙動
  List Load (0.2ms)  SELECT `lists`.* FROM `lists`

# list.items.eachの挙動
  Item Load (0.2ms)  SELECT `items`.* FROM `items` WHERE `items`.`list_id` = 1
  Item Load (0.2ms)  SELECT `items`.* FROM `items` WHERE `items`.`list_id` = 2
  Item Load (0.1ms)  SELECT `items`.* FROM `items` WHERE `items`.`list_id` = 3
  Item Load (0.1ms)  SELECT `items`.* FROM `items` WHERE `items`.`list_id` = 4
  Item Load (0.1ms)  SELECT `items`.* FROM `items` WHERE `items`.`list_id` = 5

list.items.eachによって、listに紐づいたitemsを探しに
listsテーブルのレコード数だけ、itemsテーブルにアクセスしています。
このように、必要以上にSQLを発行されてしまうのがN+1問題です。

〜何故"N+1"と言うのか〜
SQLがlistsテーブルへの1回アクセスする毎に、itemsテーブルへのアクセスがlistsテーブルのレコードの数だけ(上記では5回)発行されています。

このようにlistsテーブルへのアクセス1回に対して、関連するテーブルがN回発行されることから1+Nとなり、N+1と言われます。

N+1問題の対策

Railsでは

モデルに関連するデータを一度にまとめて取得することが出来ます。includesメソッドを利用します。
引数にアソシエーションで定義した関連名を指定して定義します。
(テーブル名ではなく、関連名なのがポイントです)

Listモデルにhas_many :itemsとアソシエーションを記述したので、コントローラーは下記のようになります。

lists_controller.rb(修正)
class ListsController < ApplicationController
  def index
    @lists = List.all.includes(:items)
  end
(省略)
end

その結果…忙しかったターミナルは

ターミナル
# @List.allの挙動
  List Load (0.2ms)  SELECT `lists`.* FROM `lists`

# list.items.eachの挙動
  Item Load (0.3ms)  SELECT `items`.* FROM `items` WHERE `items`.`list_id` IN (1, 2, 3, 4, 5)

itemsテーブルの情報も一度に取得するようになったので、list.items.eachの挙動がスッキリしました!!

ちなみに、

引数にアソシエーションで定義した関連名を指定して定義します。
(テーブル名ではなく、関連名なのがポイントです)

と念を押したこの件ですが、1対多のアソシエーションの場合
多の方から1を取得する際には

(例)
@items = Item.all.includes(:list)

と言うような記述になります。
これは子モデル側に、belongs_to :listとアソシエーションを記述したからですね。

終わりに/感想

何となく「ターミナルにSQLがめっちゃある」位のイメージでしかなかったN+1問題でしたが、今回理解を深めることが出来たと感じます。
N+1問題を意識し、データベースを労わります。
こんなクリスマスも、たまには良いと思いました。

初学者で拙い記事ですが、少しでもお役に立てると嬉しく思います。
最後まで読んでいただき、誠にありがとうございました。

参考記事

【Qiita】N+1問題
【Pikawaka】Rails/N+1問題をincludesメソッドで解決しよう!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1