N+1問題はこれで解決!Bullet gemの導入と実践的な使い方
はじめに
Railsアプリケーションのパフォーマンスを低下させる最も一般的な原因の一つが「N+1クエリ問題」です。開発中は気づきにくいものの、データ量が増えるにつれて顕在化し、ページの表示速度を著しく悪化させます。幸いなことに、Railsコミュニティにはこの問題を検出し、解決策を提示してくれるBulletという強力なgemが存在します。
この記事では、N+1問題とは何か、そしてBullet gemを使ってそれをどのように特定し、解決していくかを具体的なコード例と共に解説します。
N+1クエリ問題とは?
N+1クエリ問題とは、1つのクエリで親のレコードを取得し、その後、各親レコードに関連する子のレコードを取得するために、親の数(N)だけ追加のクエリが発行されてしまう状況を指します。
例えば、ブログの記事一覧ページで、各記事の投稿者名も表示したい場合を考えてみましょう。
問題のあるコード
コントローラ
# app/controllers/articles_controller.rb
def index
@articles = Article.all
end
ビュー
<%# app/views/articles/index.html.erb %>
<% @articles.each do |article| %>
<div>
<h2><%= article.title %></h2>
<p>投稿者: <%= article.user.name %></p> <%# ここでN+1が発生 %>
</div>
<% end %>
このコードが実行されると、Railsのログには以下のようなSQLクエリが記録されます。
-- 1. まず、すべての記事を取得する (1回)
SELECT "articles".* FROM "articles"
-- 2. その後、各記事のユーザー情報を取得するために、記事の数だけクエリが発行される (N回)
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
-- (記事の数だけ続く...)
もし記事が100件あれば、1 + 100 = 101回のクエリがデータベースに発行されてしまいます。これがN+1問題です。データが少ない開発環境では問題になりませんが、本番環境では深刻なパフォーマンスボトルネックとなります。
Bulletの導入と設定
Bulletは、このようなN+1クエリを開発中に検出し、開発者に警告してくれるgemです。
1. インストール
Gemfile
にBulletを追加します。開発環境でのみ使用するため、:development
グループに記述します。
# Gemfile
group :development do
gem 'bullet'
end
そしてbundle install
を実行します。
bundle install
2. 設定
次に、config/environments/development.rb
にBulletの設定を追記します。これにより、Bulletが有効になり、検出した問題をどのように通知するかを指定できます。
# config/environments/development.rb
Rails.application.configure do
# ... (既存の設定)
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # ブラウザにJavaScriptのアラートを表示
Bullet.bullet_logger = true # log/bullet.log にログを出力
Bullet.console = true # ブラウザのコンソールにログを出力
# Bullet.growl = true # Growl通知 (Mac)
# Bullet.rails_logger = true # Railsのログに出力
# Bullet.honeybadger = true # Honeybadgerに通知
Bullet.add_footer = true # ページのフッターに情報を追加
end
end
これで準備は完了です。Railsサーバーを再起動してください。
BulletによるN+1問題の検出と解決
設定が完了したら、先ほどのN+1問題が発生する記事一覧ページにアクセスしてみましょう。すると、ブラウザに以下のようなポップアップアラートが表示されるはずです。(設定による)
Bullet Notification: The request below has N+1 queries detected: GET /articles
USE eager loading detected Article => [:user] Add
.includes(:user)
to your query.Call stack: /path/to/app/views/articles/index.html.erb:5:in
_app_views_articles_index_html_erb___...
この通知は非常に親切で、以下のことを教えてくれます。
- どのページで問題が起きたか:
GET /articles
- どのモデルの関連で問題が起きたか:
Article => [:user]
- どう解決すればよいか:
Add .includes(:user) to your query.
- どこで問題のコードが呼び出されたか:
app/views/articles/index.html.erb:5
解決策: Eager Loading(事前読み込み)
Bulletが提案してくれた通り、Eager Loading(イーガーローディング、事前読み込み) を使ってこの問題を解決します。Eager Loadingとは、あらかじめ必要になる関連データをまとめて読み込んでおく手法です。
Railsでは主にincludes
メソッドを使います。
修正後のコントローラ
# app/controllers/articles_controller.rb
def index
# .includes(:user) を追加
@articles = Article.includes(:user).all
end
includes(:user)
を追加するだけで、Railsは賢くクエリを最適化してくれます。このコードが実行されると、発行されるクエリは以下の2つだけになります。
-- 1. すべての記事を取得
SELECT "articles".* FROM "articles"
-- 2. 取得した記事に関連するすべてのユーザーを一度に取得
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...) -- 記事のuser_idをまとめて指定
記事が100件あっても、発行されるクエリはたったの2回です。これにより、データベースへのアクセスが劇的に減り、パフォーマンスが大幅に改善します。
修正後に再度ページにアクセスすると、Bulletからの警告は表示されなくなります。
preload
とjoins
との違い
Eager Loadingにはincludes
の他にpreload
やeager_load
もあります。また、似たような機能を持つjoins
との使い分けも重要です。
includes
: ほとんどの場合、これでOK。Railsが状況に応じてpreload
かeager_load
のどちらか適切な方を選択してくれます。preload
: 関連テーブルを別のクエリで読み込みます(今回解決した例と同じ挙動)。関連テーブルのカラムをwhere
句などで使わない場合に適しています。eager_load
:LEFT OUTER JOIN
を使って、1つのクエリで親と子の両方のデータを取得します。関連テーブルのカラムをwhere
句で絞り込みたい場合に使います。joins
:INNER JOIN
を使います。関連先のデータが必要なレコードだけを親テーブルから取得したい場合に使います。ただし、joins
だけでは子のデータはセレクトされず、article.user
のようにアクセスするとN+1問題が再発するので注意が必要です。(joins
とpreload
の組み合わせが必要になることもあります)
Bulletはこれらの使い分けについても、「preload
を使いなさい」や「joins
とpreload
を組み合わせなさい」といった具体的なアドバイスをくれることがあります。
まとめ
N+1問題は、Rails開発者が必ず向き合うことになるパフォーマンスの課題です。Bullet gemは、この見つけにくい問題を開発の早い段階で自動的に検出し、具体的な解決策まで提示してくれる、まさに「相棒」のような存在です。
- 開発環境には必ず
bullet
gemを導入しましょう。 - Bulletの警告が表示されたら、それを無視せず、指示に従って
includes
などを追加しましょう。 - パフォーマンスは機能です。快適なアプリケーションを提供するために、N+1問題を意識した開発を心がけましょう。