こんにちは。
Webアプリケーションを長期間運用していると、次第に直面するのが「ページの表示速度が遅い」というパフォーマンスの問題です。特にRuby on RailsなどのORマッパー(ActiveRecord)を採用しているフレームワークでは、気づかないうちに大量のデータベースクエリが発行されてしまう 「N+1問題」 が頻発しがちです。
先日、私たちが運用しているRailsアプリケーションでも、特定の詳細ページでレスポンスタイムが悪化していることが判明しました。
この記事では、運用中のアプリケーションでN+1問題をどのように発見し、どのように根本解決していったのか、そのベストプラクティスをご紹介します。
1. ボトルネックの特定:なぜページが重いのか?
詳細ページにアクセスした際のサーバーログを確認したところ、以下のような状態になっていました。
User Load (1.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
Item Load (0.8ms) SELECT "items".* FROM "items" WHERE "items"."user_id" = 1
Category Load (0.5ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1
Category Load (0.4ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1
... (これが数十回続く)
見事なまでのN+1問題です。一覧や詳細ビューで関連レコード(この場合はカテゴリーや画像などのアソシエーション)をループで呼び出す際、一つひとつのレコードに対して個別のSQLクエリが発行されてしまっていました。データ量が増えれば増えるほど、雪だるま式にパフォーマンスが悪化します。
2. bullet gemの導入による自動検知
手動でログを追うのは限界があるため、開発環境にN+1問題の検知ツールである bullet gem を導入しました。
# Gemfile
group :development do
gem 'bullet'
end
設定ファイル(config/environments/development.rb)でアラートを有効にします。
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # ブラウザにJavaScriptのアラートを表示
Bullet.bullet_logger = true # log/bullet.log に出力
Bullet.console = true # ブラウザのコンソールに表示
Bullet.rails_logger = true # サーバーのログに出力
end
これを導入することで、開発中にページをリロードするだけで「どのファイルの何行目で、どのアソシエーションがN+1を引き起こしているか」をブラウザ画面に直接警告してくれるようになりました。これにより、パフォーマンス悪化の主要因となっている箇所(高インパクトなページ)を効率よく洗い出すフローが確立できました。
3. includes と eager_load による根本解決
問題箇所が特定できたので、コントローラーのクエリを修正します。
修正前(N+1が発生するコード)
def show
@item = Item.find(params[:id])
# ビュー側で @item.categories を呼ぶたびにクエリが発行される
end
修正後(Eager Loadingを利用)
def show
@item = Item.includes(:categories, :images).find(params[:id])
end
includes を使うことで、Railsが裏側で関連データを事前にまとめて取得(Eager Loading)してくれます。先ほど数十回発行されていたクエリが、たった2回のクエリ(Itemの取得と、それに関連するCategoriesのIN句を用いた一括取得)に削減されました。
※ includes と eager_load の使い分け
状況に応じて eager_load (または joins)を明示的に使うこともありました。
includes: 基本的に推奨。Railsがよしなにpreload(クエリを分ける)かeager_load(LEFT OUTER JOINで1つの巨大なクエリにする)かを選んでくれます。eager_load: 関連先のテーブルの値で絞り込み(where)や並び替えを行いたい場合は、JOINが必須となるためこちらを明示的に指定します。
4. 改善の結果と今後の運用
これらの修正を行い、テスト(spec)を通した上で本番環境へデプロイした結果、該当ページのデータベース応答時間は劇的に改善し、体感速度も驚くほど速くなりました。
また、単発の修正で終わらせるのではなく、今後の開発サイクルにおいても 「機能追加時には必ず bullet の警告が出ないか確認する」「CIで極端なクエリ増を検知する」 といったパフォーマンス監視の文化をチーム内に根付かせることができました。
まとめ
RailsにおけるN+1問題は、開発初期(データ量が少ない時期)には見過ごされがちですが、本番運用においては致命的なパフォーマンス劣化を招きます。
bulletgem を導入し、N+1を可視化・自動検知する仕組みを作る- インパクトの大きいページから優先して対処する
includesやeager_loadを適切に使ってクエリを最適化する
このステップを踏むことで、保守性の高いサクサク動くアプリケーションを維持できるようになります。同じような問題に直面している方の参考になれば幸いです!
![[Rails]mySQL8.0のデフォルトのcollationとActiveRecordの差分に困った話](/assets/ogp/mysql8-collation-and-activerecord.webp)