LoginSignup
1
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-06-20

はじめに

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

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

今回は前回も取り上げた「N+1問題」に関して、includesメソッドで解決できないものについて
joinsメソッドを用いた解決法を紹介したいと思います。

本題

1. 「N+1問題」と前回のおさらい

「N+1問題」とは、「データベースからデータを取り出す際、不要なSQLが発行される」ことで、
パフォーマンスの低下を招くため、実際のサイトでは解決することが必要不可欠となります。

前回の記事 (https://qiita.com/yuta-pharmacy2359/items/61cff1aed171fedeff6e) では
includesメソッドによる解決法を紹介しました。

基本的に、「単に2つ以上のテーブルの情報を表示するような一覧画面」などは、
以下の式でN+1問題を解決することができます。

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

しかし、以下に挙げるような、
「別のテーブルで計算(合計、平均など)した結果を表示する」場合は
includesメソッドでは解決することができません。

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

今回は上記のうち、「(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示」機能におけるN+1問題の解決法を、
joinsメソッド用いて解決していきたいと思います。

2. 実際に発生した問題

当PFでは、ユーザー詳細画面でそのユーザーが獲得した総いいね数を表示しています。
スクリーンショット 2021-06-19 16 52 01


そのカウントの仕組みを模式的に表したのが下図です。
スクリーンショット 2021-06-19 18 01 56

この図の場合、id = 1のユーザーが投稿したスポットのidを外部キーとして持ついいねの数を合計した値を
「1人のユーザーが獲得した総いいね数」として表示しています。

この機能をN+1問題を考慮しないで実装する場合、usersコントローラーおよびshowビューファイルでの記述は以下の通りになります。
(もちろん、記述方法は他にもあると思いますので、あくまで一例とお考えいただければと思います。)

users_controller.rb
# showアクションのうち、今回の機能に関係のある部分のみ抜粋
def show
  @user = User.find(params[:id])
  @user_all_spots = @user.spots
  @user_all_favorites_count = 0
  @user_all_spots.each do |spot|
    @user_all_favorites_count += spot.favorites.count
  end
end
users/show.html.erb
<!--今回の機能に関係のある部分のみ抜粋-->
<tr>
  <td>総いいね数</td>
  <td class="user-favorite-count-<%= @user.id %>"><%= @user_all_favorites_count %></td>
</tr>

コントローラーファイルに関して簡単に説明すると、showアクションの中身において
【1行目】
当該のユーザーidのユーザーを選択
【2行目】
対象のユーザー(@user)のスポットを@user_all_spotsに代入
(上図の例では、id=1のユーザーのスポット(id=2,13,28,144,186,207)を代入)
【3行目】
@user_all_favorites_countに0を代入して値をリセット
【4~6行目】
対象のユーザーの各スポットが獲得したいいね数を
順に@user_all_favorites_countに加算していく
(上図の例では
spot_id=2 => いいね数3
spot_id=13 => いいね数2
spot_id=28 => いいね数1
spot_id=144 => いいね数2
spot_id=186 => いいね数1
spot_id=207 => いいね数3
@user_all_favorites_count=3+2+1+2+1+3=12)

となります。【4~6行目】の説明を見て薄々お気づきかと思いますが、
この記述では、以下のようなアクセスが行われています。

User Load (0.2ms) SELECT "users".* FROM "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ? [["id", 1], ["LIMIT", 1]]
Spot Load (0.2ms) SELECT "spots".* FROM "spots" WHERE "spots"."user_id" = ? [["user_id", 1]]
  (0.2ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 2]]
  (0.2ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 13]]
  (0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 28]]
  (0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 144]]
  (0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 186]]
  (0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 207]]

SQL2行目では、showアクションの【4行目】に則り、
user_id=1の全スポットが呼び出されています。
そしてSQL3行目以降では、showアクションの【5行目】に則り、
user_id=1の各スポットにそれぞれ紐づけられたいいねの情報が呼び出されています。
まさに「N+1問題」が発生している状況ですね。

そして、今回の例ではスポット数が6でしたので、アクセス回数は7で済んでいますが、
極端な例ですが「三度の飯より旅行」なユーザーが1000件、10000件投稿していたとしたら...
想像するだけで頭が痛くなってしまいますね。

それでは、この問題を解決する記述方法を紹介したいと思います。

3. 解決法

users_controller.rb
# showアクションのうち、今回の機能に関係のある部分のみ抜粋
def show
  @user = User.find(params[:id])
  @user_all_spots = @user.spots
  @user_all_favorites_count = @user_all_spots.joins(:favorites).count
end

早速usersコントローラーでの記述から紹介しました。(showのビューファイルは変更なし)
showアクションの中身において、【1~2行目】は先ほどと同じです。
問題の【3行目】に関して、順を追って説明していきます。

ここで出てきたjoinsメソッドは、「関連するテーブル同士を内部結合するメソッド」です。
基本的な定義は以下の通りです。

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

引数は、includesメソッドと同じく関連名(モデルファイルでhas_many、またはbelongs_toの後ろに記述した名前)ですのでご注意ください。

「結合」とは、一言で言えば「結合条件に従って複数のテーブルを1つのテーブルとして結合させること」です。
そして結合も「内部結合」および「外部結合」の2種類に大別されますが、今回のテーマからそれてしまうため、
詳しい説明は省略させていただきます。
(簡単に言えば、複数のテーブルを1つにした際に、
・「内部結合」・・・片方のテーブルに情報が入ってない場合、もう片方のテーブルに情報があっても表示されない
・「外部結合」・・・片方のテーブルに情報が入ってない場合でも、もう片方のテーブルの情報があれば、それを表示する
です。機会があれば取り上げたいと思います。)

ひとまず今回は、下図のようなイメージを持っていただければと思います。

スクリーンショット 2021-06-20 11 58 35

ここで、「spotsテーブルにおけるid」と「favoritesテーブルにおけるspot_id」は同じ内容であることに着目すると、
これら2つのテーブルを結合した際に上図の右側のようにデータが配置され、あたかも
「id=4,16,39...348,371,397のいいねは全てid=1のユーザーに対して付与されたもの」
と捉えることができます。これがミソであり、今まで1人のユーザーが獲得したいいね数をカウントする場合は、
「id=1のユーザーが投稿したのはid=2,13,28,144,186,207のスポットである。そしてそれぞれのスポットが獲得したいいね数は3,2,1,2,1,3なので、id=1のユーザーが獲得した総いいね数は12である。」
と考える必要があったのが、
「結合後の旧spotテーブル側において、user_id=1であるレコード数は12なので、id=1のユーザーが獲得した総いいね数は12である。」
と捉えることができるようになります。
(要は「手順を挟まずにダイレクトに集計できるようになる」ということです。)

あとは結合後のレコード数を数えれば良いので、countメソッドを用いればOKです。

この記述に変更し、再度ターミナルのログを確認すると、

User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  (0.2ms)  SELECT COUNT(*) FROM "spots" INNER JOIN "favorites" ON "favorites"."spot_id" = "spots"."id" WHERE "spots"."user_id" = ?  [["user_id", 1]]

無事「N+1問題」を解決することができました。

終わりに

今回は、includesメソッドで「N+1問題」を解決できない機能のうち、「(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示機能」について、joinsメソッドを用いてN+1問題を解決しました。
残りの5項目に関しては、joinsメソッドの他にgroupメソッドを使用する必要があります。それに関してはまた記事を上げたいと思っています。

使用画像素材
・いらすとや(https://www.irasutoya.com/)
・ぱくたそ(https://www.pakutaso.com/)

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