3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Ruby on Rails] N+1問題の解決法(includesメソッド編)

Last updated at Posted at 2021-06-17

はじめに

今回ポートフォリオ(以後PF)制作で、
日本各地の名所を投稿できるサイトを制作しました。

実際に製作したサイトと、コード(GitHub)は下記のURLからご覧ください。
・サイトURL : https://japansiteinfo.com (今後予告なく公開停止する場合があります。ご了承ください。)
・GitHubのURL : https://github.com/yuta-pharmacy2359/dwc_JapanSiteInfo_app

今回はアプリのパフォーマンスに影響するいわゆる「N+1問題」とその解決法について
上記のPFの内容になぞらえて紹介したいと思います。

本題

1. 「N+1問題」について

N+1問題とは、「データベースからデータを取り出す際、不要なSQLが発行される」という問題です。
特に対処しなくてもアプリは正常に動作しますが、規模が大きくなるにつれパフォーマンス低下の原因となるため、
実際のサイトとして運用する際は解決するのが必要不可欠となります。

それでは、私のPFのトップ画面を例として見ていきたいと思います。

スクリーンショット 2021-06-17 17 23 28

この画面ではspotsテーブルfavoritesテーブルのデータが取り出され、「新着スポット一覧」として表示されています。

その際、ターミナルのログを確認すると、以下のようなSQLが発行されていることがわかります。

Spot Load (0.2ms) SELECT DISTINCT "spots".* FROM "spots" ORDER BY "spots"."id" DESC LIMIT ? OFFSET ? [["LIMIT", 10], ["OFFSET", 0]]
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 10]]
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 9]]
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 8]]
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 7]]
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 6]]
Favorite Load (0.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 5]]
Favorite Load (0.1ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 4]]
Favorite Load (0.1ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 3]]
Favorite Load (0.1ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 2]]
Favorite Load (0.1ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 1]]

1行目ではスポット(投稿)を一覧表示するために`spotsテーブル`から10件分レコードを取り出しています。 (当PFでは一覧画面でのスポット表示件数をページネーションにより10件までに制限しています。 また、投稿を新しい順に表示するために降順(DESC)としています。) 取り出されたデータを表にすると以下のようになります。 スクリーンショット 2021-06-17 17 26 33

続いて2行目では、いいねの情報を表示するためにfavoritesテーブルの中からspot_id = 10であるレコードを取り出しています。
イメージとしては下表のようになります。
スクリーンショット 2021-06-17 17 30 38

そして3行目では2行目同様、favoritesテーブルの中からspot_id = 9であるレコードを取り出しています。
スクリーンショット 2021-06-17 17 28 13

以後、spot_id = 8からspot_id = 1まで同じ動作を繰り返します。

結果、当PFでトップ画面の新着スポット一覧の部分を表示させる場合、
spotsテーブルへのアクセスに1回、favoritesテーブルへのアクセスに10回のSQLが読み込まれることとなります。
一般的にこのような現象をN+1問題と呼んでいます。

アクセス1回あたりの時間は一瞬(せいぜい0.3ms程度)ですが、これが数万件、数十万件のレベルになってくると話は変わってきます。
また、投稿一覧表示などではページネーション等で表示件数を制限することである程度読み込み回数を抑えることは可能ですが、
例えば「1人のユーザーが獲得したいいね数の合計」など、関連テーブルの全てのレコードを参照する必要がある場合は、
その方法を使うことはできません。

こうした無駄なアクセスを防ぐためには、
spotsテーブルにアクセスする際に、同時に関連するfavoritesテーブルのレコードを取得することが必要になります。

今回はincludesメソッドを用いた解決法を紹介します。

2.includesメソッドを用いたN+1問題の解決法

includesメソッドの基本的な定義は以下のようになります。

モデル名.includes(:関連名)

ここで、includesの引数はテーブル名ではなく関連名(モデルファイルでhas_many、またはbelongs_toの後ろに記述した名前)
であることに注意してください。


今回の場合、spotモデルに定義されているアソシエーションは下記の通りのため、
spot.rb
class Spot < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :favorites, dependent: :destroy
(以下略)
end

関連名はfavoritesとなります。
そのため、homesコントローラーファイルにおける記述は以下のようになります。

homes_controller.rb
(変更前) @spots = Spot.all
             
(変更後) @spots = Spot.includes(:favorites)

allで全件取得していたところをincludes(:favorites)に変更します。

ちなみに、私のPFの場合、Kaminari(ページネーション用のgem)およびRansack(検索・ソート機能を利用できるgem)というgemを用いているため、

homes_controller.rb
(変更前) @q = Spot.ransack(params[:q])
        @spots = @q.result(distinct: true).page(params[:page]).reverse_order
                                      
(変更後) @q = Spot.ransack(params[:q])
        @spots = @q.result(distinct: true).page(params[:page]).includes(:favorites).reverse_order

となります。

これで再度ページを表示させると、以下のSQLが発行されます。

Spot Load (0.2ms) SELECT DISTINCT "spots".* FROM "spots" ORDER BY "spots"."id" DESC LIMIT ? OFFSET ? [["LIMIT", 10], ["OFFSET", 0]]
Favorite Load (0.3ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" IN (?, ?, ?, ?, ?, ? , ?, ?, ?, ?) [["spot_id", 10], ["spot_id", 9], ["spot_id", 8], ["spot_id, 7"], ["spot_id", 6], ["spot_id", 5], ["spot_id", 4], ["spot_id", 3], ["spot_id", 2], ["spot_id", 1]]

1行目には特に変化が見られませんが、
2行目では1回のアクセスでfavoritesテーブルからspot_id10件分のレコードを取得できていることがわかります。

3.関連しているテーブルが複数ある場合

次に、表示したいデータが3つ以上のテーブルを参照している(関連しているテーブルが複数ある)場合です。
私のPFでは下図のようにスポットごとのいいね数でランキングを表示する機能が実装されています。

スクリーンショット 2021-06-17 18 55 43

見ていただくとわかりますが、この画面の場合では、spotsテーブルfavoritesテーブルの他にusersテーブル
のデータも取り出されています。

同様にターミナルのロゴを確認してみると、以下のSQLが発行されていることがわかります。

Spot Load (0.3ms) SELECT "spots".* FROM "spots" WHERE "spots"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5], ["id", 6], ["id", 7], ["id", 8], ["id", 9], ["id", 10], ["id", 11], ["id", 12], ["id", 13], ["id", 14], ["id", 15]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 2]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 3]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 4]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 5]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 6]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 7]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 8]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 9]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 10]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 6], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 11]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 9], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 12]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 7], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 13]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 11], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 14]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 6], ["LIMIT", 1]]
Favorite  Load (1.2ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 15]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 10], ["LIMIT", 1]]

1行目でspotsテーブルに1回アクセスした後、そこから読み込んだspotの件数分の回数だけ、
favoritesテーブルusersテーブルに交互にアクセス
しています。
2行目以降のアクセスは完全に無駄(いわゆる「2N+1問題」状態)ですので、これもincludesメソッドを用いて解決する必要があります。

まずは例のごとくspot.rbでspotモデルに対する各テーブルの関連名を確認します。

spot.rb
class Spot < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :favorites, dependent: :destroy
(以下略)
end

userfavoritesがそれぞれ関連名となります。これより、コントローラーファイルへの記述は下記の通りとなります。

rankings_controller.rb
(変更前) @all_ranks = Spot.create_spot_favorite_ranks
                          
(変更後) @all_ranks = Spot.includes(:user, :favorites).create_spot_favorite_ranks

includesメソッドの引数が2つになったこと以外は先程のケースと同じです。
ちなみに、その後ろについているcreate_spot_favorite_ranksメソッドは、ランキング機能におけるオリジナルメソッドです。
詳しくは以下の文献記事を参照してください。

https://qiita.com/mitsumitsu1128/items/18fa5e49a27e727f00b4
https://qiita.com/Onoshunn/items/edc8f992bdaef88f6889


これで再度ページを表示させると、発行されるSQL文は以下のようになります。

Spot Load (0.3ms) SELECT "spots".* FROM "spots" WHERE "spots"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5], ["id", 6], ["id", 7], ["id", 8], ["id", 9], ["id", 10], ["id", 11], ["id", 12], ["id", 13], ["id", 14], ["id", 15]]
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5], ["id", 6], ["id", 7], ["id", 9], ["id", 10], ["id", 11]]
Favorite Load (0.5ms) SELECT "favorites".* FROM "favorites" WHERE "favorites"."spot_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["spot_id", 1], ["spot_id", 2], ["spot_id", 3], ["spot_id", 4], ["spot_id", 5], ["spot_id", 6], ["spot_id", 7], ["spot_id", 8], ["spot_id", 9], ["spot_id", 10], ["spot_id", 11], ["spot_id", 12], ["spot_id", 13], ["spot_id", 14], ["spot_id", 15]]

だいぶスッキリしましたね。
このようにN+1問題は対処するかしないかでパフォーマンスがかなり変わってくるので、
PF制作レベルでも是非取り組まれることをお勧めします。

4.includesメソッドで対処できないケース

ここまでincludesメソッドを用いたN+1問題の解決法を紹介してきましたが、
今回私がPFを制作する中で全てこのメソッドで解決できたかというとそうではありません。
以下に挙げる機能に関してはincludesメソッドを用いてもN+1問題は解消されませんでした。

・1人のユーザーが獲得した総いいね数表示
・(キーワード一覧画面において)1つのキーワードにおけるスポット評価の平均
・ランキング機能(ユーザー1人ごとの総獲得いいね数)における1人のユーザーの総いいね数表示
・ランキング機能(ユーザー1人ごとの総獲得いいね数)における1人のユーザーの総スポット数表示
・フォロー数、フォロワー数表示
・(フォロー・フォロワー画面における)各ユーザーの最終更新日表示

主に何かを集計したり計算したりする機能で多い傾向ですね。
これらに関してはjoinsメソッドgroupメソッドを用いて解決することとなりました。
具体的な解決法に関しては今後記事にしたいと思っています。

追伸('21 6/20)
「1人のユーザーが獲得した総いいね数表示」に関して、joinsメソッドを用いた「N+1問題」の解決法の記事を上げました。
URL: https://qiita.com/yuta-pharmacy2359/items/cf30a20fbea9347c0b72

追伸('21 6/24)
「1つのキーワードにおけるスポット評価の平均値表示」に関して、joinsメソッドおよびgroupメソッドを用いた「N+1問題」の解決法の記事を上げました。
URL: https://qiita.com/yuta-pharmacy2359/items/a55873b03066579dd5cd

おわりに

今回はN+1問題について、includesメソッドによる解決法を紹介しました。
もちろん、最後に触れたように全部が全部このメソッドで解決できるわけではありませんが、
投稿の一覧表示などでは比較的簡単に実装できるので、PF制作においても是非導入してみてください。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?