😉

N+1問題が発生したらincludesすればいいんでしょ?

2023/08/27に公開

はじめに

Rails を触っているととよく遭遇する問題に N+1 問題があります。
N+1 問題が発生したらとりあえず includes するぐらいの知識しか持っていなかったため、改めて調べ直してみました。
この記事では次の 3 点について解説します。

  • N+1 問題とは
  • どうやって検知するか
  • どうやって解決するか

N+1 問題についてあやふやな人は読んでいただけると嬉しいです。

N+1 問題とは

N+1 問題とは、ある一つのデータを表示するために一回のクエリ発行で済んだものが、追加で N 個のクエリが発行されてしまっている状態を指します。

例え話

https://medium.com/doctolib/understanding-and-fixing-n-1-query-30623109fe89

こちらの記事の例えがわかりやすいなと思ったので内容を拝借すると、、

あなたはレシピを見ながらチョコレートケーキを作ろうとしています。
材料をチェックするとダークチョコレートが 200g と書かれています。
あなたはパントリーにチョコレートを取りに行きます。
続いてレシピを見ると、次は卵が 3 つ必要と書かれています。
またパントリーに行って卵を取ってきます。
レシピの次の行を読むとバターが 100g と書かれています。
またパントリーに行ってバターを取ってきます。

ケーキを作るためにパントリーに 3 回も行っていますが、材料を最初の 1 回で全て持っていくようにするとパントリーに行く回数は一度で済むはずです。
ここでいうパントリーをデータベースに置き換えたものが N+1 問題となります。

Active Record の例

次は Active Record で N+1 問題が発生する典型的なコードの例を紹介します。
次のような 1 対多の関係にあるユーザーモデルとポストモデルがあり、ポストの一覧表示を行います。

posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

view では post の内容と、そのポストに紐づくユーザー名を表示します。

<% @posts.each do |post| %>
  <p class="content">
    <strong>Content:</strong>
    <%= post.content %>
  </p>
  <p class="name">
    <strong>Name:</strong>
    <%= post.user.name %>
  </p>
<% end %>

この状態で/posts にアクセスすると次のような SQL が発行されます。

Post Load (0.7ms)  SELECT `posts`.* FROM `posts` // Postを全件取得
User Load (0.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 // 1つ目のPostに紐づくユーザーを取得
User Load (0.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1 // 2つ目のPostに紐づくユーザーを取得
User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1 // 3つ目のPostに紐づくユーザーを取得
.
.
.

コントーローラーでは Post モデルしか取得していないため、ポストを表示するたびにそのポストに紐づくユーザーを取得している状態です。
このように N+1 問題は暗黙的に SQL が発行される ORM を使用する場合に注意する必要がある問題です。

Bullet を使って N+1 問題を検知する

Rails には N+1 問題を検知をしてくれる Bullet という定番の gem があります。
https://github.com/flyerhzm/bullet
Bullet の使い方は以下の通りです。

  1. gem をインストール
gem install bullet
  1. Bullet を有効にする
bundle exec rails g bullet:install

上記コマンドを実行すると development.rb に以下の記述が追加されます。
設定できる項目は初期設定の項目以外にもあり Sentry に通知することもできるようです。
その他の設定項目は README に記載されています。

development.rb
config.after_initialize do
  Bullet.enable        = true // Bulletを有効にする
  Bullet.alert         = true // ブラウザにalertを表示する
  Bullet.bullet_logger = true // 専用のログファイルに出力する
  Bullet.console       = true // ブラウザのコンソールに出力する
  Bullet.rails_logger  = true // Railsのログに出力する
  Bullet.add_footer    = true // フッターにアラートを表示する
end

設定は以上です。
非常に簡単ですね。
設定が完了したら再度/posts にアクセスします。
すると以下のようなログが出力され N+1 を検知してくれます。

user: root
GET /posts
USE eager loading detected
  Post => [:user]
  Add to your query: .includes([:user])
Call stack
  /app/app/views/posts/_post.html.erb:8:in `_app_views_posts__post_html_erb___4373112660496697115_31340'
  /app/app/views/posts/index.html.erb:7:in `block in _app_views_posts_index_html_erb__628684022364918036_31320'
  /app/app/views/posts/index.html.erb:6:in `_app_views_posts_index_html_erb__628684022364918036_31320'

N+1 問題を解決する

includes メソッド

今回の場合はログにも表示されているように includes メソッドを使ってユーザーをポストと同時に取得することで、N+1 問題を解決することができます。
コントローラーを次のように変更します。

posts_controller.rb
 def index
-  @posts = Post.all
+  @posts = Post.includes(:user)
 end

/post にアクセスしたときのログが以下のように変わりました。

Post Load (0.8ms)  SELECT `posts`.* FROM `posts`
User Load (0.8ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3...)

SQL がポストの取得とユーザーの取得の 2 行しか表示されなくなり、Bullet のログも表示されなくなりました。
基本的には includes メソッドを使うことで N+1 問題を解決することができますが、それ以外の方法もあるため紹介します。

preload メソッドと eager_load メソッド

includes メソッド以外の方法としては、preload メソッドまたは eager_load メソッドを使うことで N+1 問題を解決することができます。

preload
指定された関連レコードを別のクエリとして読み込む。
最初に親テーブルを読み込み、次に関連テーブルを読み込む SQL を発行する。

eager_load
指定された関連テーブルを LEFT OUTER JOIN して単一のクエリで読み込む。

先ほどの includes で表示した際のクエリを見ると親テーブルと関連テーブルをそれぞれ別の SQL で取得しており preload と同じ動きをしていることがわかります。
実は includes メソッドは状況によって preload と eager_load の動きを使い分けています。
(デフォルトでは preload と同じ動きをし、テーブルの結合を行なっている場合は eager_load と同じ動きをする。)
ただし includes メソッドは複数の関連テーブルを読み込む際に、preload と eager_load を別々に行うことはできません。(どちらも preload になるか eager_load になる。)
つまり、よりパフォーマンスを意識する場合には preload と eager_load を明示的に選択して使い分ける必要があります。

preload と eager_load どちらを使うと良いのか?

preload の場合はテーブルの結合処理を行わないためレスポンスが早くなる可能性がありますが、関連テーブルでの絞り込みができません。
(例えばユーザーテーブルに age カラムがあったとして、20 歳以上のユーザーのポストを取得するような処理を書く場合)
また has_one の関係の場合は eager_load を使って 1 つの SQL でデータを取得した方が効率が良くなると予想されるケースもあります。
結論どちらを使った方が良いかは場合によるため、それぞれの挙動を理解した上で適切な方を選ぶことが望ましいです。

さいごに

Active Record は便利な反面、意図せずパフォーマンスの悪い書き方になっている場合があるため gem を使ったりログをしっかり見て気を付ける必要がありますね。
最後まで読んでいただきありがとうございます。

Discussion