はじめに
現在、ポートフォリオとしてRailsを使って簡易的なSNSアプリを開発しています。
開発を進める中で、いいね機能の実装でN+1問題に直面して苦戦しました。
そこで、本記事ではN+1問題の基礎知識と自分の経験をまとめました。
目次
1. N+1問題とは
2. N+1問題の対処法
3. いいね機能のN+1問題
N+1問題とは
概要
- データベースクエリを扱う際に発生することがあるパフォーマンスの問題
- 1つのクエリで親テーブルからデータを取得し、それぞれのレコードに対してさらに子テーブルからデータを取得する度にクエリを発行すること
- ループ処理の中で無駄なデータベースアクセスが発生し、アプリケーションのパフォーマンスが低下する
具体例
# 投稿を全件取得して投稿したユーザー名を表示するコード
tweets = Tweet.all
tweets.each { puts _1.user.name }
上記のコードを実行して得られる結果は何も問題ないが、結果を得る過程で発行されるクエリが無駄に多くなりデータベースに負荷がかかってしまう。
例えば、合計で10件の投稿があった場合、まず、10件分の投稿を取得するクエリを1回発行し、次にループの中で投稿したユーザー名を取得するクエリを10回発行する。つまり、合計で11回のクエリが発行されてしまう。
投稿数を一般化してN件とすると、合計N+1回クエリが発行されてしまうのでN+1問題と呼ばれている。
N+1問題の対処法
Railsでは以下のメソッドを使ってN+1問題に対処することができる。
- includes
- preload
- eager_load
includes
includes
メソッドを使うと、指定されたすべての関連付けを最小限のクエリ回数で読み込む。
tweets = Tweet.all.includes(:user)
tweets.each { puts _1.user.name }
今回の例では、クエリの発行数が2回だけで済むようになる。
Tweet Load (3.1ms) SELECT `tweets`.* FROM `tweets`
User Load (2.4ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
preload
preload
メソッドを使うと、指定された関連付けを、1つの関連付けにつき1件のクエリで読み込む。
tweets = Tweet.all.preload(:user)
tweets.each { puts _1.user.name }
今回の例では、クエリの発行数が2回だけで済むようになる。(今回の条件では発行されるクエリもincludes
と同じとなった)
Tweet Load (5.0ms) SELECT `tweets`.* FROM `tweets`
User Load (1.5ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
eager_load
eager_load
メソッドを使うと、指定されたすべての関連付けをLEFT OUTER JOINで読み込む。
tweets = Tweet.all.eager_load(:user)
tweets.each { puts _1.user.name }
tweetsテーブルとusersテーブルをLEFT OUTER JOINしているので、クエリの発行数が1回だけとなっている。
SQL (24.4ms) SELECT `tweets`.`id` AS t0_r0, `tweets`.`user_id` AS t0_r1, `tweets`.`title` AS t0_r2, `tweets`.`content` AS t0_r3, `tweets`.`created_at` AS t0_r4, `tweets`.`updated_at` AS t0_r5, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`email` AS t1_r2, `users`.`password_digest` AS t1_r3, `users`.`created_at` AS t1_r4, `users`.`updated_at` AS t1_r5 FROM `tweets` LEFT OUTER JOIN `users` ON `users`.`id` = `tweets`.`user_id`
いいね機能のN+1問題
実現したいこと
- 画像のように投稿一覧ページで、各投稿にいいねボタンを表示させる
- いいねボタンはログインユーザーがいいねしている場合は緑色、いいねしていない場合は白色で表示する
修正前のコード
モデル
投稿がユーザーによっていいねされているか判定するメソッドfavorited_by?
メソッドを定義する
class Tweet < ApplicationRecord
validates :user, presence: true
validates :title, presence: true
validates :content, presence: true
belongs_to :user
has_many :favorites
# いいねされいているか否かを判定するメソッド
def favorited_by?(target_user_id)
favorites.where(user_id: target_user_id).exists?
end
end
コントローラー
Tweetモデルの全てのレコードをデータベースから取得し、関連テーブルのuser
とfavorites
のデータ配列を取得し、キャッシュする。
class TweetsController < ApplicationController
before_action :check_logged_in, only: %i[index show new create]
before_action -> { check_edit_authority(Tweet.find(params[:id]).user_id) }, only: %i[edit update destroy]
# GET /tweets
def index
@tweets = Tweet.all.preload(:user, :favorites)
end
# 他のアクションは省略
end
ビュー
favorited_by?
メソッドを使って、投稿がログインユーザーによっていいねされているか判定して、いいねボタンの色を変える
<% @tweets.each do |tweet| %>
<!-- ユーザー名、メールアドレス、タイトル、投稿内容などを表示する処理 -->
<% if tweet.favorited_by?(current_user.id) %>
<!- ログインユーザーにいいねされている時の処理 -->
<% else %>
<!- ログインユーザーにいいねされていない時の処理 -->
<% end %>
<% end %>
N+1問題が発生しているクエリ
投稿一覧画面を表示した時に発行されるクエリを確認すると...
Tweetsテーブルの全レコードを取得した後、preloadによって関連付けられたUsersテーブルのレコードとFavoritesテーブルのレコードを取得しているが、ビューのループでfavorite_by?
メソッドが呼ばれる度にクエリが発行されていることがわかる。
# Tweetsテーブルから全てのレコードを取得する
Tweet Load (0.5ms) SELECT `tweets`.* FROM `tweets`
# Usersテーブルから特定のIDのユーザーを取得する
User Load (0.5ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2)
# Favoritesテーブルから特定のtweets_idに関連付けられたいいねを取得する
Favorite Load (0.4ms) SELECT `favorites`.* FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3)
# 以下は投稿ごとにログインユーザーいいねされているかの判定
Favorite Exists? (0.4ms) SELECT 1 AS one FROM `favorites` WHERE `favorites`.`tweet_id` = 1 AND `favorites`.`user_id` = 1 LIMIT 1
Favorite Exists? (0.2ms) SELECT 1 AS one FROM `favorites` WHERE `favorites`.`tweet_id` = 2 AND `favorites`.`user_id` = 1 LIMIT 1
Favorite Exists? (0.2ms) SELECT 1 AS one FROM `favorites` WHERE `favorites`.`tweet_id` = 3 AND `favorites`.`user_id` = 1 LIMIT 1
N+1問題を解消する
原因を見つける
def favorited_by?(target_user_id)
favorites.where(user_id: target_user_id).exists?
end
favorite_by?
メソッドを確認すると...
指定した条件のレコードがDBに存在するかどうかを真偽値で返すexists?
メソッドによりクエリが発行されていそうだと予測できる。
Railsドキュメント|exists?
修正の方針を考える
preloadによって取得したキャッシュを使い、クエリを発行せずに指定したレコードが存在するか判定できるようにするという方針で修正したい。
Railsでレコードの存在チェックをする際によく使われるメソッドが他にないか調べてみたところexitst?
の他にpresent?
とany?
があった。
今回のケースではany?
を使うことで追加のクエリは発行することなく、コントローラのpreload
でキャッシュされたデータに対して存在チェックができる。
exitst?
,present?
,any?
の違いは下記の記事を参考にした。
Ruby on Railsのexists? / any? / present?
コードを修正する
any?
を使ってfavorite_by?
メソッドを修正する。
any?メソッドの解説
-
any?
とブロックを使って、各要素と比較するオブジェクトや条件を指定する方法を使っている。 -
Rubyリファレンスマニュアル|any?
オブジェクト.any? { |ブロック引数| 条件 }
# 修正後
def favorited_by?(target_user_id)
favorites.any? { |favorite| favorite.user_id == target_user_id }
end
修正後、投稿一覧画面を表示した時に発行されるクエリを確認すると...
favorite_by?
メソッドが呼ばれる度に発行されていたクエリがなくなり、N+1問題が解消されていることがわかる!
Tweet Load (0.4ms) SELECT `tweets`.* FROM `tweets`
User Load (0.7ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2)
Favorite Load (0.7ms) SELECT `favorites`.* FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3)