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

# はじめに

今回ポートフォリオ(以後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のトップ画面を例として見ていきたいと思います。

<img width="859" alt="スクリーンショット 2021-06-17 17 23 28" src="https://user-images.githubusercontent.com/75191225/122359748-c575bd00-cf90-11eb-9d87-9e60611fbf31.png">

この画面では`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]]
```

<br>
1行目ではスポット(投稿)を一覧表示するために`spotsテーブル`から10件分レコードを取り出しています。
(当PFでは一覧画面でのスポット表示件数をページネーションにより10件までに制限しています。
また、投稿を新しい順に表示するために降順(DESC)としています。)
取り出されたデータを表にすると以下のようになります。
<img width="858" alt="スクリーンショット 2021-06-17 17 26 33" src="https://user-images.githubusercontent.com/75191225/122360252-387f3380-cf91-11eb-8bc1-bee1ea2105c0.png">

続いて2行目では、いいねの情報を表示するために`favoritesテーブル`の中から`spot_id = 10`であるレコードを取り出しています。
イメージとしては下表のようになります。
<img width="515" alt="スクリーンショット 2021-06-17 17 30 38" src="https://user-images.githubusercontent.com/75191225/122360937-c3f8c480-cf91-11eb-9ab7-09838638b7fd.png">

そして3行目では2行目同様、`favoritesテーブル`の中から`spot_id = 9`であるレコードを取り出しています。
<img width="518" alt="スクリーンショット 2021-06-17 17 28 13" src="https://user-images.githubusercontent.com/75191225/122361029-d83cc180-cf91-11eb-87de-b32b1c0f1238.png">

以後、`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の後ろに記述した名前)**
であることに注意してください。

<br>
今回の場合、spotモデルに定義されているアソシエーションは下記の通りのため、

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

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

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

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

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

```ruby: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_id`10件分のレコードを取得できていることがわかります。

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

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

<img width="891" alt="スクリーンショット 2021-06-17 18 55 43" src="https://user-images.githubusercontent.com/75191225/122374879-b34e4b80-cf9d-11eb-844d-bcdb977adc26.png">

見ていただくとわかりますが、この画面の場合では、`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モデルに対する各テーブルの関連名を確認します。

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

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

```ruby: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
<br>
これで再度ページを表示させると、発行される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制作においても是非導入してみてください。