ORM

Rails ActiveRecord上級クエリ|N+1問題の解決とパフォーマンス最適化

Ruby on Rails ActiveRecord クエリ

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問題の例

以下のモデルがあると仮定します。

app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
end

以下のコードでは、N+1問題が発生します。

app/controllers/posts_controller.rb(悪い例)
class PostsController < ApplicationController
  def index
    @users = User.all
  end
end

# ビューで以下のように使うと...
# @users.each do |user|
#   user.posts  # ← ユーザーごとにSQLが発行される!
# end

発行されるSQLを確認してみましょう。

発行されるSQL(N+1問題あり)
-- 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(推奨・自動判定)

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    # includesでN+1問題を解決
    @users = User.includes(:posts)
  end
end

includesは状況に応じてpreloadeager_loadを自動で選択します。通常はこれを使えばOKです。

発行されるSQL(includes使用時・preload方式)
-- たった2回のクエリで完了!
SELECT "users".* FROM "users"
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3, ...)

preload(常に2クエリ)

preloadの使用例
# 常に別クエリで関連データを取得
@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)

eager_loadの使用例
# 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)方式になります。

ネストした関連の読み込み

ネストしたincludesの例
# 複数の関連を同時に読み込み
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サブクエリ

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サブクエリ

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句でのサブクエリ

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 と組み合わせた集計

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 を使った集計の絞り込み

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を実行します。すでにメモリに読み込んだレコードの数を取得したい場合はlengthsizeを使いましょう。

Step 5RAW SQLの実行

ActiveRecordのメソッドチェーンでは表現しきれない複雑なクエリには、RAW SQLを使います。ただし、SQLインジェクション対策は必ず行いましょう。

find_by_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

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(プレースホルダー対応)

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で取得カラムを限定する

selectでカラムを限定
# 悪い例: 全カラムを取得
users = User.all  # SELECT "users".* FROM "users"

# 良い例: 必要なカラムだけ取得
users = User.select(:id, :name, :email)
# SELECT "users"."id", "users"."name", "users"."email" FROM "users"

pluckで値だけ取得する

pluckの使用例
# モデルオブジェクトを作らず、値だけ取得
names = User.where(active: true).pluck(:name)
# => ["田中", "佐藤", "鈴木"]

# selectとの違い: pluckはモデルオブジェクトを生成しない
# そのためメモリ使用量が大幅に少ない

find_eachでバッチ処理

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

インデックスの活用

db/migrate/xxxx_add_indexes.rb
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でクエリを分析

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でクエリの実行計画を確認しているか
  • bullet gem でN+1問題を検出しているか
  • カウンターキャッシュ(counter_cache)を活用しているか

counter_cacheとはbelongs_to :user, counter_cache: trueと設定すると、関連レコードの数をキャッシュしてくれます。user.posts.countがSQLを発行せず即座に返るようになります。

まとめ:パフォーマンス最適化は「計測」から始めましょう。bulletでN+1問題を検出し、explainで実行計画を確認し、ログでクエリ時間を監視する習慣をつけることが大切です。