Edited at

N+1の予感がしたらincludesを追加🔎

こんにちは!

入社したてのころ、右も左もわからずにコーディングをしていました。

そんな中で、僕もよく悩まされたN+1についての対策について簡単にまとめてみました。

N+1は簡単に防げてパフォーマンスをあげることができます。

すぐできて、効果大なのでぜひ実践してみてください!


そもそもN+1とは


SQLクエリが 「データ量N + 1回 」走ってしまい、取得するデータが多くなるにつれて(Nの回数が増えるにつれて)パフォーマンスを低下させてしまう問題です。


N+1問題 / Eager Loading とは

(引用させていただきました)

→簡単にいうとデータ取得の際、余計にSQLを発行してしまいパフォーマンスを下げてしまうことです。


テーブルの定義

例えば配列展開でBook(本)からAuthor(著者)の名前を出力したい場合(以下ER図&作成データになります)。

スクリーンショット 2019-05-05 20.13.02.png

念の為コードも

Author

class Author < ApplicationRecord

has_many :books
end

Book

class Book < ApplicationRecord

belongs_to :author
end


データの用意

author(諫山さん)が6冊の本(book)のリレーションを持っています。

irb(main):004:0> Author.all # 全てのAuthorレコード

Author Load (0.5ms) SELECT "authors".* FROM "authors"
+----+--------+-------------------------+-------------------------+
| id | name | created_at | updated_at |
+----+--------+-------------------------+-------------------------+
| 1 | 諫山創 | 2019-05-05 10:44:51 UTC | 2019-05-05 10:44:51 UTC |
+----+--------+-------------------------+-------------------------+

irb(main):003:0> Book.all # 全てのBookレコード

Book Load (1.3ms) SELECT "books".* FROM "books"
+----+---------+-----------+-------------------------+-------------------------+
| id | title | author_id | created_at | updated_at |
+----+---------+-----------+-------------------------+-------------------------+
| 1 | 進撃の1 | 1 | 2019-05-05 10:46:17 UTC | 2019-05-05 10:46:17 UTC |
| 2 | 進撃の2 | 1 | 2019-05-05 10:46:25 UTC | 2019-05-05 10:46:25 UTC |
| 3 | 進撃の3 | 1 | 2019-05-05 10:46:28 UTC | 2019-05-05 10:46:28 UTC |
| 4 | 進撃の4 | 1 | 2019-05-05 10:46:31 UTC | 2019-05-05 10:46:31 UTC |
| 5 | 進撃の5 | 1 | 2019-05-05 10:46:35 UTC | 2019-05-05 10:46:35 UTC |
| 6 | 進撃の6 | 1 | 2019-05-05 10:46:38 UTC | 2019-05-05 10:46:38 UTC |
+----+---------+-----------+-------------------------+-------------------------+

irb(main):004:0> Author.first.books # 諫山さんが6冊の本(book)のリレーションを保持

Author Load (1.7ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
+----+---------+-----------+-------------------------+-------------------------+
| id | title | author_id | created_at | updated_at |
+----+---------+-----------+-------------------------+-------------------------+
| 1 | 進撃の1 | 1 | 2019-05-05 10:46:17 UTC | 2019-05-05 10:46:17 UTC |
| 2 | 進撃の2 | 1 | 2019-05-05 10:46:25 UTC | 2019-05-05 10:46:25 UTC |
| 3 | 進撃の3 | 1 | 2019-05-05 10:46:28 UTC | 2019-05-05 10:46:28 UTC |
| 4 | 進撃の4 | 1 | 2019-05-05 10:46:31 UTC | 2019-05-05 10:46:31 UTC |
| 5 | 進撃の5 | 1 | 2019-05-05 10:46:35 UTC | 2019-05-05 10:46:35 UTC |
| 6 | 進撃の6 | 1 | 2019-05-05 10:46:38 UTC | 2019-05-05 10:46:38 UTC |
+----+---------+-----------+-------------------------+-------------------------+


コンソールで実行

配列展開でBookから親モデルのAuthorのnameを呼び出すとBookのデータ数分(6個)SQLを発行してしまいます。

→つまり5回もSQLが無駄に発行されてしまうのです。

books = Book.all

> Book Load (0.2ms) SELECT "books".* FROM "books"

irb(main):037:0* books.each do |book|

irb(main):038:1* book.author.name
irb(main):039:1> end
Author Load (0.6ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
irb(main):040:1>

スクリーンショット 2019-05-05 20.25.32.png

この無駄なクエリを防ぐにはincludesを追加するのがもっとも簡単です


includes追加

  books = Book.all.includes(:author) #追加

Book Load (2.1ms) SELECT "books".* FROM "books"
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? [["id", 1]]

お気づきでしょうか、includes追加により関連レコード(author)も一緒に取得されています。

→つまり、Bookと一緒に紐づいたAuthorモデルのレコードも取得し変数に代入していることになります。

Before

books = Book.all

> Book Load (0.2ms) SELECT "books".* FROM "books"

books = Bookモデルの全てのデータ

After

スクリーンショット 2019-05-05 20.33.31.png

books = Bookモデルの全てのデータとそれらにひもづくAuthorデータ

この状態でもう一度booksを展開してみましょう!

irb(main):051:0* books.each do |book|

irb(main):052:1* book.author.name
irb(main):053:1> end
irb(main):054:0>

今度は展開のたびにAuthorを取得していません。

SQLの発行をおさえてパフォーマンス低下を防ぐことができましたね。


includesで色々なリレーションを取得する

先ほどはN:1(Book:Author)でのパターンでしたが、実際はもっと複雑な利用パターンが多いと思います。

そんな時に利用できる書き方をご紹介します。


N:1 = Book:Author

  books = Book.all.includes(:author)

こちらは先ほどのパターンでしたね


N:1:1 = Book:Author:Profile

では、Authorのプロフィール情報を保存するAuthors::Profileがあった場合

  books = Book.all.includes(author: :profile)

Book Load (1.6ms) SELECT "books".* FROM "books" LIMIT ? [["LIMIT", 11]]
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
Authors::Profile Load (0.4ms) SELECT "authors_profiles".* FROM "authors_profiles" WHERE "authors_profiles"."author_id" = ? [["author_id", 1]]


N:1:1:1 = Book : Author : Profile : ProfileImage

さらにProfileに1つのプロフィール写真Authors::ProfileImageひもづく場合

  books = Book.all.includes(author: [profile: :profile_image])

  Book Load (0.5ms) SELECT "books".* FROM "books" LIMIT ? [["LIMIT", 11]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
Authors::Profile Load (0.2ms) SELECT "authors_profiles".* FROM "authors_profiles" WHERE "authors_profiles"."author_id" = ? [["author_id", 1]]
Authors::ProfileImage Load (0.1ms) SELECT "authors_profile_images".* FROM "authors_profile_images" WHERE "authors_profile_images"."id" = ? [["id", nil]]

逆に1:N(Author:Book)なら?

これはリレーションを使い関連データを全て取得できますね。

  > Author.first.books

Author Load (0.3ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]

N+1の予感とタイトルにありますが、余計にクエリを投げるケースは配列展開が多いと思います。

慣れていない方はeachやmap, selectなど配列展開のメソッドを使う際にN+1が起きていないかぜひ意識してみてください!


おまけ

副業案件お待ちしております。得意分野はRuby on Railsです!

職務経歴書