Rails ActiveRecord上級クエリ
N+1問題の解決とパフォーマンス最適化
ActiveRecordの上級クエリテクニックを解説。N+1問題の解決、サブクエリ、集計関数、RAW SQL、パフォーマンス最適化まで学べます。
こんな人向けの記事です
- N+1問題を解決したい
- ActiveRecordの集計クエリを使いこなしたい
- RAW SQLの使い方を知りたい
- パフォーマンスを改善したい
Step 1N+1問題とは
N+1問題は、関連するデータを取得する際に大量のSQLクエリが発行されてしまう、ActiveRecordでもっとも多いパフォーマンス問題です。
N+1問題を放置すると、レコード数に比例してクエリ数が増加し、レスポンスが大幅に遅延します。100件のレコードなら101回のクエリが発行されます。
N+1問題の例
以下のモデルがあると仮定します。
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
end
以下のコードでは、N+1問題が発生します。
class PostsController < ApplicationController
def index
@users = User.all
end
end
# ビューで以下のように使うと...
# @users.each do |user|
# user.posts # ← ユーザーごとにSQLが発行される!
# end
発行されるSQLを確認してみましょう。
-- 1回目: 全ユーザー取得
SELECT "users".* FROM "users"
-- 2回目以降: ユーザーごとに投稿を取得(N回)
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 3
-- ... ユーザー数分だけ繰り返し
Bulletを使おう:bullet gem を導入すると、N+1問題が発生した箇所を自動で検出してくれます。開発環境では必ず導入しましょう。
Step 2includes / eager_load / preload の使い分け
N+1問題を解決するために、ActiveRecordは3つのメソッドを提供しています。それぞれの違いを理解して適切に使い分けましょう。
includes(推奨・自動判定)
class PostsController < ApplicationController
def index
# includesでN+1問題を解決
@users = User.includes(:posts)
end
end
includesは状況に応じてpreloadかeager_loadを自動で選択します。通常はこれを使えばOKです。
-- たった2回のクエリで完了!
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3, ...)
preload(常に2クエリ)
# 常に別クエリで関連データを取得
@users = User.preload(:posts)
# 発行されるSQL
# SELECT "users".* FROM "users"
# SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3)
eager_load(常にLEFT JOIN)
# LEFT OUTER JOINで1クエリにまとめる
@users = User.eager_load(:posts)
# 発行されるSQL
# SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, ...
# "posts"."id" AS t1_r0, "posts"."title" AS t1_r1, ...
# FROM "users"
# LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"
3つのメソッドの比較表
| メソッド | SQL方式 | クエリ数 | 使い分け |
|---|---|---|---|
includes |
自動判定 | 2 or 1 | 通常はこれを使う |
preload |
別クエリ(IN句) | 2 | 関連先でwhereを使わない場合 |
eager_load |
LEFT OUTER JOIN | 1 | 関連テーブルの条件で絞り込む場合 |
includesでwhereを使う場合:includes(:posts).where(posts: { published: true })のように関連テーブルの条件を指定すると、自動的にeager_load(LEFT JOIN)方式になります。
ネストした関連の読み込み
# 複数の関連を同時に読み込み
User.includes(:posts, :profile)
# ネストした関連の読み込み
User.includes(posts: :comments)
# 複雑なネスト
User.includes(posts: [:comments, :tags], profile: :avatar)
Step 3サブクエリの活用
ActiveRecordではサブクエリを使って複雑な条件を指定できます。SQLを直接書かなくても、Rubyのメソッドチェーンで実現できます。
whereでのサブクエリ
# 投稿が5件以上あるユーザーを取得
active_user_ids = Post.group(:user_id)
.having("COUNT(*) >= 5")
.select(:user_id)
User.where(id: active_user_ids)
# 発行されるSQL
# SELECT "users".* FROM "users"
# WHERE "users"."id" IN (
# SELECT "posts"."user_id" FROM "posts"
# GROUP BY "posts"."user_id"
# HAVING COUNT(*) >= 5
# )
ActiveRecordのサブクエリの利点:where(id: サブクエリ)の形で書くと、ActiveRecordが自動的にサブクエリとして展開してくれます。2回のクエリではなく、1回のクエリで実行されます。
NOT INサブクエリ
# 投稿がないユーザーを取得
User.where.not(id: Post.select(:user_id))
# 発行されるSQL
# SELECT "users".* FROM "users"
# WHERE "users"."id" NOT IN (
# SELECT "posts"."user_id" FROM "posts"
# )
EXISTSサブクエリ
# 公開済み投稿が存在するユーザーを取得
User.where(
Post.where("posts.user_id = users.id")
.where(published: true)
.arel.exists
)
# 発行されるSQL
# SELECT "users".* FROM "users"
# WHERE EXISTS (
# SELECT "posts".* FROM "posts"
# WHERE posts.user_id = users.id
# AND "posts"."published" = TRUE
# )
FROM句でのサブクエリ
# ユーザーごとの投稿数の平均を計算
subquery = Post.group(:user_id)
.select("user_id, COUNT(*) as post_count")
User.from("(#{subquery.to_sql}) AS subquery")
.select("AVG(subquery.post_count) as avg_posts")
# 発行されるSQL
# SELECT AVG(subquery.post_count) as avg_posts
# FROM (
# SELECT user_id, COUNT(*) as post_count
# FROM "posts" GROUP BY "posts"."user_id"
# ) AS subquery
Step 4集計関数(count, sum, average, maximum, minimum)
ActiveRecordには便利な集計メソッドが用意されています。SQLの集計関数をRubyメソッドとして簡単に呼び出せます。
基本の集計メソッド
# count: レコード数を取得
User.count
# => 150
User.where(active: true).count
# => 120
# sum: 合計値を取得
Order.sum(:amount)
# => 1500000
# average: 平均値を取得
Product.average(:price)
# => 2500.5
# maximum: 最大値を取得
Order.maximum(:amount)
# => 50000
# minimum: 最小値を取得
Order.minimum(:amount)
# => 100
group と組み合わせた集計
# カテゴリ別の商品数
Product.group(:category_id).count
# => {1 => 25, 2 => 18, 3 => 42}
# 月別の売上合計
Order.group("DATE_TRUNC(month, created_at)")
.sum(:amount)
# => {"2024-01-01" => 500000, "2024-02-01" => 620000}
# ユーザーごとの平均注文額(上位10件)
Order.group(:user_id)
.order("AVG(amount) DESC")
.limit(10)
.average(:amount)
# => {5 => 35000.0, 12 => 28000.0, ...}
having を使った集計の絞り込み
# 注文が10件以上のユーザーだけの合計額
Order.group(:user_id)
.having("COUNT(*) >= 10")
.sum(:amount)
# 平均単価が1000円以上のカテゴリ
Product.group(:category_id)
.having("AVG(price) >= ?", 1000)
.average(:price)
| メソッド | SQL関数 | 戻り値 | 使用例 |
|---|---|---|---|
count |
COUNT | Integer / Hash | User.count |
sum |
SUM | Numeric / Hash | Order.sum(:amount) |
average |
AVG | BigDecimal / Hash | Product.average(:price) |
maximum |
MAX | 値 / Hash | Order.maximum(:amount) |
minimum |
MIN | 値 / Hash | Order.minimum(:created_at) |
countの注意点:countはSQLのCOUNTを実行します。すでにメモリに読み込んだレコードの数を取得したい場合はlengthやsizeを使いましょう。
Step 5RAW SQLの実行
ActiveRecordのメソッドチェーンでは表現しきれない複雑なクエリには、RAW SQLを使います。ただし、SQLインジェクション対策は必ず行いましょう。
find_by_sql
# モデルオブジェクトの配列として結果を取得
users = User.find_by_sql([
"SELECT users.*, COUNT(posts.id) AS posts_count
FROM users
LEFT JOIN posts ON posts.user_id = users.id
GROUP BY users.id
HAVING COUNT(posts.id) > ?
ORDER BY posts_count DESC",
5
])
# 結果はUserオブジェクトとして返される
users.each do |user|
puts "#{user.name}: #{user.posts_count}件"
end
SQLインジェクション注意:ユーザー入力を直接SQL文字列に埋め込まないでください。必ずプレースホルダー(?)を使いましょう。
connection.execute
# 生のSQL結果を取得(モデルオブジェクトではない)
result = ActiveRecord::Base.connection.execute(
"SELECT DATE(created_at) as date, COUNT(*) as count
FROM users
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 7"
)
# 結果はハッシュの配列
result.each do |row|
puts "#{row[date]}: #{row[count]}人"
end
select_all(プレースホルダー対応)
# sanitize_sql_arrayでプレースホルダーを安全に展開
sql = ActiveRecord::Base.sanitize_sql_array([
"SELECT users.name, COUNT(orders.id) as order_count,
SUM(orders.amount) as total_amount
FROM users
INNER JOIN orders ON orders.user_id = users.id
WHERE orders.created_at >= ?
GROUP BY users.id, users.name
ORDER BY total_amount DESC
LIMIT ?",
1.month.ago, 10
])
results = ActiveRecord::Base.connection.select_all(sql)
results.each do |row|
puts "#{row[name]}: #{row[order_count]}件 / #{row[total_amount]}円"
end
find_by_sql vs connection.execute の比較
| メソッド | 戻り値 | 用途 |
|---|---|---|
find_by_sql |
モデルオブジェクトの配列 | モデルのメソッドを使いたい場合 |
connection.execute |
生のResult(ハッシュ配列) | 集計など、モデルが不要な場合 |
connection.select_all |
ActiveRecord::Result | プレースホルダーを使いたい場合 |
Step 6ActiveRecordのパフォーマンス最適化
ここまで学んだテクニックを総合し、ActiveRecordのパフォーマンスを最大限に引き出す方法をまとめます。
selectで取得カラムを限定する
# 悪い例: 全カラムを取得
users = User.all # SELECT "users".* FROM "users"
# 良い例: 必要なカラムだけ取得
users = User.select(:id, :name, :email)
# SELECT "users"."id", "users"."name", "users"."email" FROM "users"
pluckで値だけ取得する
# モデルオブジェクトを作らず、値だけ取得
names = User.where(active: true).pluck(:name)
# => ["田中", "佐藤", "鈴木"]
# selectとの違い: pluckはモデルオブジェクトを生成しない
# そのためメモリ使用量が大幅に少ない
find_eachでバッチ処理
# 悪い例: 全レコードを一度にメモリに読み込む
User.all.each do |user|
user.update(last_login_checked: true)
end
# 良い例: 1000件ずつバッチ処理
User.find_each(batch_size: 1000) do |user|
user.update(last_login_checked: true)
end
# find_in_batches: バッチごとの配列で処理
User.find_in_batches(batch_size: 1000) do |users|
# usersは最大1000件のUser配列
UserMailer.bulk_notify(users).deliver_later
end
インデックスの活用
class AddIndexes < ActiveRecord::Migration[7.1]
def change
# 単一カラムのインデックス
add_index :posts, :user_id
# 複合インデックス(検索条件の組み合わせに有効)
add_index :orders, [:user_id, :created_at]
# ユニークインデックス
add_index :users, :email, unique: true
# 部分インデックス(特定条件のレコードだけ)
add_index :posts, :created_at,
where: "published = true",
name: "index_published_posts_on_created_at"
end
end
explainでクエリを分析
# クエリの実行計画を確認
puts User.where(active: true).includes(:posts).explain
# 出力例:
# EXPLAIN SELECT "users".* FROM "users" WHERE "users"."active" = TRUE
# QUERY PLAN
# -----------------------------------------------------------
# Seq Scan on users (cost=0.00..1.05 rows=3 width=...)
# Filter: (active = true)
パフォーマンス最適化チェックリスト
- N+1問題を
includesで解決しているか - 不要なカラムを
selectで除外しているか - 大量データを
find_eachでバッチ処理しているか - 値だけ必要な場合に
pluckを使っているか - WHERE句で使うカラムにインデックスを張っているか
explainでクエリの実行計画を確認しているかbulletgem でN+1問題を検出しているか- カウンターキャッシュ(
counter_cache)を活用しているか
counter_cacheとは:belongs_to :user, counter_cache: trueと設定すると、関連レコードの数をキャッシュしてくれます。user.posts.countがSQLを発行せず即座に返るようになります。
まとめ:パフォーマンス最適化は「計測」から始めましょう。bulletでN+1問題を検出し、explainで実行計画を確認し、ログでクエリ時間を監視する習慣をつけることが大切です。