# ActiveRecord:Ruby on RailsにおけるN+1クエリ問題の解消
> ActiveRecordを用いてRailsのN+1クエリを検出・解決する完全ガイドです。includes、preload、eager_load、そして自動検出ツールを習得します。
- Published: 2026-02-23
- Updated: 2026-04-29
- Author: Anthony Fillion-Maillet
- Tags: ruby on rails, activerecord, performance, n+1 queries, sql optimization
- Reading time: 10 min
---
N+1クエリは、Railsアプリケーションで最も一般的なパフォーマンス問題の一つです。レコードに対する単純なループが、数百もの不要なSQLクエリを発生させ、レスポンスタイムを大幅に低下させることがあります。本ガイドでは、性能の高いRailsアプリケーションを実現するための検出と解消の手法を扱います。
> **本番環境への影響**
>
> 50件の記事を著者とともに表示するページが、たった1件ではなく51件のSQLクエリを発生させる場合があります。数千人のユーザーを抱える本番環境では、この問題はレスポンスタイムとサーバー負荷の観点で重大なものとなります。
## N+1問題の理解
N+1問題は、コードがレコード一覧を取得するために1回のクエリ(1クエリ)を実行し、その後それぞれのレコードに対して関連付けへアクセスするために追加のクエリ(N回)を実行する場合に発生します。「N+1」という名称は、まさにこのパターンを示しています。すなわち、初期クエリ1件 + 関連付けに対するN件のクエリです。
記事と著者を例にした具体的なシナリオが、この問題を分かりやすく示してくれます。最適化を行わない場合、記事の著者にアクセスするたびに新しいSQLクエリが発生します。
```ruby
# app/controllers/articles_controller.rb
# Example code generating an N+1 problem
class ArticlesController < ApplicationController
def index
# 1 query: SELECT * FROM articles
@articles = Article.all
end
end
```
ビュー側では、`article.author`の呼び出しごとにデータベースへの追加クエリが発生します。
```erb
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% end %>
```
100件の記事に対しては、このコードは101件のSQLクエリを発生させます。Railsのログには繰り返されるクエリにより問題が明確に表示されます。
```sql
-- Rails logs showing the N+1 problem
-- 1 initial query
SELECT "articles".* FROM "articles"
-- N queries for authors (repeated for each article)
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
-- ... 97 more queries
```
## includesによる解決
`includes`メソッドはN+1問題を解消するために最も一般的かつ推奨される手段です。ActiveRecordに対して、関連付けを1〜2件の最適化されたクエリでプリロードするように指示します。
```ruby
# app/controllers/articles_controller.rb
# Solution with includes - preloading authors
class ArticlesController < ApplicationController
def index
# Preloads authors with articles
# Generates only 2 queries instead of N+1
@articles = Article.includes(:author).all
end
end
```
`includes`を使うと、ActiveRecordは記事の件数にかかわらず2件のクエリのみを実行します。1件目で全記事を取得し、2件目で関連する全著者を取得します。
```sql
-- Rails logs with includes (only 2 queries)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)
```
入れ子の関連付けについても、ハッシュ構文でプリロードできます。ビューが複数階層の関連付けにアクセスする場面では、この手法が不可欠です。
```ruby
# app/controllers/articles_controller.rb
# Preloading nested associations
class ArticlesController < ApplicationController
def index
# Preloads author -> company and all comments
@articles = Article.includes(author: :company, comments: :user)
end
end
```
> **黄金律**
>
> ビューがループの中で関連付けにアクセスする場合、その関連付けはコントローラーで`includes`によりプリロードしておく必要があります。ビュー内の関連付けへのアクセスパターンは常に確認することが重要です。
## includes・preload・eager_loadの違い
Railsは関連付けのプリロードに対して3種類のメソッドを提供しています。それぞれ異なるSQL戦略を採用しており、用途も異なります。
### preload:別々のクエリ
`preload`メソッドは関連付けごとに常に別々のクエリを実行します。WHERE条件で関連付けを絞り込まない場合に効率的に機能します。
```ruby
# app/models/article.rb
# preload always uses separate queries
class Article < ApplicationRecord
scope :with_authors, -> { preload(:author) }
end
# Usage in controller
@articles = Article.with_authors.limit(20)
# SQL generated:
# SELECT "articles".* FROM "articles" LIMIT 20
# SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)
```
### eager_load:LEFT OUTER JOIN
`eager_load`メソッドはLEFT OUTER JOINを用いて、データを単一のクエリで読み込みます。関連付けのカラムで絞り込みや並び替えを行う場合は必須となります。
```ruby
# app/controllers/articles_controller.rb
# eager_load allows filtering on associations
class ArticlesController < ApplicationController
def verified_authors
# Filters articles by author status
# Requires eager_load because WHERE references users
@articles = Article.eager_load(:author)
.where(users: { verified: true })
.order("users.name ASC")
end
end
# SQL generated (single query with JOIN):
# SELECT "articles"."id", "articles"."title", ...
# FROM "articles"
# LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id"
# WHERE "users"."verified" = TRUE
# ORDER BY "users"."name" ASC
```
### includes:賢い振る舞い
`includes`メソッドは最適な戦略を自動的に選択します。デフォルトでは`preload`を使用しますが、WHERE句が関連付けを参照する場合は`eager_load`に切り替えます。
```ruby
# app/controllers/articles_controller.rb
# includes adapts automatically to context
class ArticlesController < ApplicationController
def index
# No condition on association: uses preload (2 queries)
@articles = Article.includes(:author).all
end
def by_verified_authors
# With condition on association: uses eager_load (JOIN)
@articles = Article.includes(:author)
.where(users: { verified: true })
end
end
```
以下の表は、3つのメソッドの違いをまとめたものです。
| メソッド | SQL戦略 | ユースケース |
|----------|---------|--------------|
| `preload` | 別々のクエリ | シンプルなプリロード、絞り込みなし |
| `eager_load` | LEFT OUTER JOIN | 関連付けでの絞り込みや並び替え |
| `includes` | 自動 | 一般用途、推奨されるデフォルト |
## N+1の自動検出
N+1問題を手作業で見つけるのは骨の折れる作業であり、見落としも発生しやすくなります。複数のツールがこの検出を開発環境やCIで自動化します。
### Bullet:リアルタイム検出
Bullet gemはSQLクエリをリアルタイムに解析し、検出されたN+1問題について警告します。さらに、適切な修正方法も提案してくれます。
```ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
end
```
開発環境での設定によって、さまざまな警告モードを有効化できます。
```ruby
# config/environments/development.rb
# Bullet configuration to detect N+1
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
# Display JavaScript alert in browser
Bullet.alert = true
# Add footer with details
Bullet.bullet_logger = true
# Display in Rails logs
Bullet.rails_logger = true
# Raise exception (useful in CI)
Bullet.raise = false
end
end
```
N+1問題が検出されると、Bulletは推奨される解決策とともに明確なメッセージを表示します。
```text
# Example Bullet alert in logs
USE eager loading detected
Article => [:author]
Add to your query: .includes([:author])
Call stack:
/app/views/articles/index.html.erb:5
```
> **CI環境でのBullet**
>
> 継続的インテグレーションにおいて`Bullet.raise = true`を有効化すると、N+1問題が検出された際にテストが失敗します。これによりパフォーマンスのリグレッションを防止できます。
### Prosopite:軽量な代替手段
Prosopite gemは、Bulletに比べて軽量で、最小限の設定とテストとの互換性を備えた代替手段を提供します。
```ruby
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
gem 'prosopite'
end
```
```ruby
# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = Rails.env.test?
end
end
```
## 高度な最適化テクニック
基本的な手法以外にも、ActiveRecordクエリの最適化を細かく調整するための複数の技術があります。
### Strict Loading:デフォルトでの予防
Rails 6.1以降では、プリロードされていない関連付けにアクセスした際に例外を発生させるStrict Loadingモードが提供されています。この予防的な手法は、開発段階でN+1の解消を強制します。
```ruby
# app/models/article.rb
# Enable strict loading by default on the model
class Article < ApplicationRecord
# Any non-preloaded association access raises an exception
self.strict_loading_by_default = true
belongs_to :author
has_many :comments
end
```
Strict Loadingは特定のクエリ単位でも有効化できます。
```ruby
# app/controllers/articles_controller.rb
# Strict loading on a specific query
class ArticlesController < ApplicationController
def index
# Raises StrictLoadingViolationError if a non-included
# association is accessed
@articles = Article.strict_loading.includes(:author)
end
end
```
### 部分データのためのSelectとPluck
特定のカラムだけが必要な場合、`select`と`pluck`はデータベースから転送されるデータ量を削減します。
```ruby
# app/controllers/reports_controller.rb
# Optimization with select and pluck
class ReportsController < ApplicationController
def titles_only
# select returns Article objects with only id and title
@articles = Article.select(:id, :title)
end
def title_array
# pluck returns an Array of values, not AR objects
# More performant when only values are needed
@titles = Article.pluck(:title)
# => ["First article", "Second article", ...]
end
end
```
### カウントのためのCounter Cache
関連付けのカウント(`article.comments.count`)は呼び出しのたびにSQLクエリを発生させます。Counter cacheはこのカウントを直接親テーブルに保存します。
```ruby
# app/models/comment.rb
# Counter cache configuration
class Comment < ApplicationRecord
# Rails automatically maintains the counter in articles.comments_count
belongs_to :article, counter_cache: true
end
```
マイグレーションでは、デフォルト値付きのカウントカラムを追加します。
```ruby
# db/migrate/20260223_add_comments_count_to_articles.rb
# Migration to add counter cache
class AddCommentsCountToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :comments_count, :integer, default: 0, null: false
# Initialize counters for existing data
Article.find_each do |article|
Article.reset_counters(article.id, :comments)
end
end
end
```
この設定の後は、`article.comments_count`が追加のSQLクエリなしでカラムを直接読み取ります。
```ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>