MENU
カテゴリー

【完全版】Rails ActiveRecordクエリメソッド実践ガイド|N+1対策とパフォーマンス改善

Rails ActiveRecordクエリメソッド実践ガイドのアイキャッチ。before/afterで効率的な書き方とSQLの違いを学ぶ実践ガイド
  • URLをコピーしました!

Railsで開発をしていて、こんな経験はないか?「とりあえず動くけど、なんとなくクエリの書き方に自信がない」「本番環境で急にレスポンスが遅くなった」「N+1という言葉は聞くけど、解決方法がよくわからない」。本記事では、現場で実際に使えるActiveRecordのクエリメソッドを、before/after形式のコード例と発行されるSQLをセットで解説する。Rails初級〜中級のエンジニアが、明日から自信を持ってクエリを書けるようになることを目指した実践ガイドである。

目次

はじめに:なぜActiveRecordのクエリを正しく理解すべきか

ActiveRecordはRailsが提供するORM(Object Relational Mapper)で、Rubyのオブジェクトとしてデータベースを操作できる強力な仕組みである。便利な反面、「裏でどんなSQLが発行されているか」を意識せずに書くと、知らないうちにパフォーマンスを大きく損ねてしまいる。

現場では、開発時には問題なく動いていたコードが、本番のデータ量で急に遅くなる、というケースが頻繁にある。原因の多くは「無駄なクエリの発行」「全カラムを引いてしまっている」「N+1問題」など、ActiveRecordの挙動を理解していれば防げるものである。本記事では、こうした現場でよくある問題に対応できる知識を整理していく。

基本のクエリメソッド(where, find, find_by, find_each, in_batches)

まずは基本のクエリメソッドを整理する。それぞれ似ているようで、用途と挙動が異なる。

find / find_by / where の使い分け

findは主キー(id)でレコードを1件取得し、見つからない場合はActiveRecord::RecordNotFound例外を発生させる。find_byは任意のカラムで1件取得し、見つからなければnilを返する。whereは条件に合致するレコードをActiveRecord::Relationとして返する(複数件・0件OK)。

# Before:whereで1件だけ取りたいのにfirstを呼ぶ
user = User.where(email: 'test@example.com').first
# => SELECT "users".* FROM "users" WHERE "users"."email" = 'test@example.com' ORDER BY "users"."id" ASC LIMIT 1

# After:find_byを使う方が意図が明確
user = User.find_by(email: 'test@example.com')
# => SELECT "users".* FROM "users" WHERE "users"."email" = 'test@example.com' LIMIT 1

現場では「1件だけ取りたい」場面でfind_byを使うことが多いである。where(...).firstと書くと不要なORDER BYが付くことがあり、意図も伝わりにくくなる。

大量データを扱う:find_each / in_batches

数十万件以上のレコードを一気にeachで回すと、メモリにすべて読み込まれてサーバーが落ちることがある。こういうときはfind_eachin_batchesを使いる。

# Before:100万件のUserを一括ロード(メモリを大量消費)
User.all.each do |user|
  user.send_notification
end
# => SELECT "users".* FROM "users"  ←全件を一度にメモリへ

# After:1000件ずつ取得して処理
User.find_each(batch_size: 1000) do |user|
  user.send_notification
end
# => SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1000
# => SELECT "users".* FROM "users" WHERE "users"."id" > 1000 ORDER BY "users"."id" ASC LIMIT 1000
# => ...(以降ループ)

バッチ処理や定期ジョブではfind_eachがほぼ必須である。レコード単位ではなく配列単位(バッチ単位)で処理したい場合はin_batchesを使うと、SQLのまとめ更新(update_allなど)と組み合わせやすくなる。

関連付けを伴うクエリ(joins / includes / preload / eager_load の違い)

関連付け(associations)を含むクエリで混乱しがちな4つを整理する。それぞれ「発行されるSQLの形」と「目的」が異なる。

  • joins:INNER JOINでテーブルを結合する。関連先のレコードはロードしない(絞り込みのみ)。
  • preload:関連先を別クエリで取得する(IN句)。関連先で絞り込みはできない。
  • eager_load:LEFT OUTER JOINで結合し、関連先もロードする。関連先で絞り込み可能。
  • includes:状況に応じてpreloadeager_loadのどちらかを自動選択する。
# joins:絞り込みたいだけのとき
User.joins(:posts).where(posts: { published: true })
# => SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."published" = TRUE

# preload:関連先をN+1なくロード(絞り込みなし)
User.preload(:posts)
# => SELECT "users".* FROM "users"
# => SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3, ...)

# eager_load:関連先をJOINで一度に取得
User.eager_load(:posts).where(posts: { published: true })
# => SELECT "users"."id" AS t0_r0, ..., "posts"."id" AS t1_r0, ... FROM "users" LEFT OUTER JOIN "posts" ...

現場での使い分けの目安は次のとおりである。「関連先を表示する → includesまたはpreload」「関連先で絞り込みつつ表示する → eager_load」「絞り込みのみで関連先は表示しない → joins」。迷ったらincludesを使い、SQLログを見ながら必要に応じてpreloadeager_loadに置き換える、という進め方が安全である。

N+1問題と解決方法(bulletジェムの活用も含める)

N+1問題とは、親レコードを取得するための1回のクエリに加えて、関連先を参照するたびに追加のクエリが発行されてしまう問題のことである。レコードが増えるほど指数的に遅くなる。

# Before:N+1が発生
users = User.all
users.each do |user|
  puts user.posts.count
end
# => SELECT "users".* FROM "users"
# => SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = 1
# => SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = 2
# => ...(ユーザー数だけ繰り返し)

# After:includesで関連先を事前ロード
users = User.includes(:posts)
users.each do |user|
  puts user.posts.size
end
# => SELECT "users".* FROM "users"
# => SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3, ...)

ポイントはcountsizeに変えていることである。countは常にCOUNTクエリを発行するが、sizeは関連先がロード済みならRubyの配列サイズを返してくれるため、追加クエリを防げる。

bulletジェムでN+1を自動検出

N+1は目視で発見しづらいので、開発環境ではbulletジェムを導入するのが定番である。N+1や不要なeager loadingを検出して警告してくれる。

# Gemfile
group :development do
  gem 'bullet'
end

# config/environments/development.rb
config.after_initialize do
  Bullet.enable        = true
  Bullet.alert         = true
  Bullet.bullet_logger = true
  Bullet.console       = true
  Bullet.rails_logger  = true
end

現場ではPRレビュー時に「bullet通してね」と言われることも多いである。CIでbulletの検出を失敗扱いにする運用も有効である。

パフォーマンスを意識した書き方(pluck, select, exists?, size vs count vs length)

pluck:必要なカラムだけ取得する

# Before:全カラムをロードしてからmap
emails = User.all.map(&:email)
# => SELECT "users".* FROM "users"  ←全カラムを取得(無駄)

# After:pluckで必要なカラムだけ
emails = User.pluck(:email)
# => SELECT "users"."email" FROM "users"

pluckはActiveRecordオブジェクトを生成しないため、メモリ効率もよく高速である。IDの一覧やメールアドレスの一覧などを取りたいときに重宝する。

select:必要なカラムだけのモデルを取得する

# 必要なカラムだけを持つUserオブジェクトを返す
users = User.select(:id, :email)
# => SELECT "users"."id", "users"."email" FROM "users"

モデルとして扱いたいけど不要なカラムまでロードしたくない、というときに使いる。ただしselect外のカラムにアクセスするとActiveModel::MissingAttributeErrorになるので注意が必要である。

exists?:存在チェックは exists? を使う

# Before:present?やany?は全件ロードする可能性がある
if User.where(active: true).present?
  # ...
end
# => SELECT "users".* FROM "users" WHERE "users"."active" = TRUE

# After:exists?ならEXISTS相当のクエリで済む
if User.where(active: true).exists?
  # ...
end
# => SELECT 1 AS one FROM "users" WHERE "users"."active" = TRUE LIMIT 1

size vs count vs length

  • count:常にCOUNTクエリを発行する。
  • length:レコードを全件ロードしてRubyの配列長を返す。
  • size:ロード済みならlength、未ロードならcountとして動く。

基本的にはsizeを使うのが安全である。すでにincludesなどでロード済みのときは追加クエリを発行せず、未ロードならCOUNTで済ませてくれる。

実務でハマりやすいポイント(scopeの使い方、merge、distinct問題など)

scopeで再利用性を高める

# Before:あちこちにwhereが散らばる
User.where(active: true).where('last_login_at >= ?', 30.days.ago)

# After:scopeでまとめる
class User < ApplicationRecord
  scope :active,        -> { where(active: true) }
  scope :recently_active, -> { where('last_login_at >= ?', 30.days.ago) }
end

User.active.recently_active
# => SELECT "users".* FROM "users" WHERE "users"."active" = TRUE AND (last_login_at >= '...')

scopeはチェーン可能なので、組み合わせて使えるのが利点である。なお、scope内でnilを返してしまうと挙動が崩れるので、必ずActiveRecord::Relationを返すように書きよう。

mergeで関連先のscopeを使う

# Postに published scope があるとき
class Post < ApplicationRecord
  scope :published, -> { where(published: true) }
end

# Before:Userクエリ側で条件をベタ書き
User.joins(:posts).where(posts: { published: true })

# After:mergeでPostのscopeを使い回す
User.joins(:posts).merge(Post.published)
# => SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."published" = TRUE

scopeの定義を一箇所に集約できるので、Postの公開条件が変わっても呼び出し側を直す必要がない。

JOINで重複が出る問題:distinct

# Before:joinsで重複Userが出る
User.joins(:posts).where(posts: { published: true })
# 同じUserが複数回出てくる場合がある

# After:distinctで重複を排除
User.joins(:posts).where(posts: { published: true }).distinct
# => SELECT DISTINCT "users".* FROM "users" INNER JOIN "posts" ...

1対多の関連でJOINすると、親レコードが関連先の数だけ重複することがある。一覧表示などでdistinctを付け忘れると不具合の原因になりがちなので、現場では「joins + 関連先の絞り込み」とセットでdistinctを意識する癖をつけておくと安心である。

まとめ

本記事では、ActiveRecordのクエリメソッドを実務目線で整理した。ポイントを振り返る。

  • 1件取得はfind_by、大量データはfind_eachin_batchesを使う。
  • 関連を伴うクエリはjoinspreloadeager_loadincludesを使い分ける。
  • N+1問題はincludesで予防し、bulletジェムで自動検出する。
  • パフォーマンス最適化はpluckselectexists?sizeを活用する。
  • scopemergeでロジックを集約し、distinctでJOINの重複に注意する。

ActiveRecordは、慣れてくると「裏で発行されるSQL」が頭に浮かぶようになる。そうなれば、パフォーマンス問題は事前に防げるし、SQLログを読みながら最適化する力もつく。本記事のコード例を実際に手元で動かして、SQLの出方を確認しながら、ぜひ現場のコードに活かしてみてください。

よかったらシェアしてね!
  • URLをコピーしました!
目次