はじめに
今回ポートフォリオ(以後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のトップ画面を例として見ていきたいと思います。
この画面では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)としています。) 取り出されたデータを表にすると以下のようになります。
続いて2行目では、いいねの情報を表示するためにfavoritesテーブル
の中からspot_id = 10
であるレコードを取り出しています。
イメージとしては下表のようになります。
そして3行目では2行目同様、favoritesテーブル
の中からspot_id = 9
であるレコードを取り出しています。
以後、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モデルに定義されているアソシエーションは下記の通りのため、
class Spot < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :favorites, dependent: :destroy
(以下略)
end
関連名はfavorites
となります。
そのため、homesコントローラーファイルにおける記述は以下のようになります。
(変更前) @spots = Spot.all
↓
(変更後) @spots = Spot.includes(:favorites)
allで全件取得していたところをincludes(:favorites)に変更します。
ちなみに、私のPFの場合、Kaminari(ページネーション用のgem)
およびRansack(検索・ソート機能を利用できるgem)
というgemを用いているため、
(変更前) @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_id
10件分のレコードを取得できていることがわかります。
3.関連しているテーブルが複数ある場合
次に、表示したいデータが3つ以上のテーブルを参照している(関連しているテーブルが複数ある)場合です。
私のPFでは下図のようにスポットごとのいいね数でランキングを表示する機能が実装されています。
見ていただくとわかりますが、この画面の場合では、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モデルに対する各テーブルの関連名を確認します。
class Spot < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :favorites, dependent: :destroy
(以下略)
end
user
とfavorites
がそれぞれ関連名となります。これより、コントローラーファイルへの記述は下記の通りとなります。
(変更前) @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制作においても是非導入してみてください。