Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Active Recordメソッドのパフォーマンス改善とN+1問題の克服(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

  • 2020/03/11: 初版公開
  • 2023/09/27: 更新

Rails: Active Recordメソッドのパフォーマンス改善とN+1問題の克服(翻訳)

Railsアプリケーションのパフォーマンスは多くの変数に依存していますが、その中のひとつは、アクション完了のために実行されるクエリ数です。データベース呼び出しの回数が少ないほどメモリアロケーションが削減され、ひいては操作完了に要する時間も削減できます。

そうした問題のひとつがN+1クエリ問題です。projectsテーブルとcommitsテーブルがあるときに、projectを2件読み込んでからそれらのprojectのすべてのcommitsを読み込むと、projectを読み込むクエリが1件と、projectごとにcommitsをフェッチするクエリがN件生成されます。追加操作は可換なので、1+NともN+1とも書けます。

# projects = Project.where(id: [1, 2])
> SELECT "projects".* FROM "projects" WHERE "projects"."id" IN (1, 2)

# projects.map { |project| project.commits }
> SELECT "commits".* FROM "commits" WHERE "commits"."project_id" = $1  [["project_id", 1]]
> SELECT "commits".* FROM "commits" WHERE "commits"."project_id" = $1  [["project_id", 2]]
....

N+1クエリを解決する一般的な方法は、includesを用いて関連付けをeager loadingすることです。

参考: Rails API includes -- ActiveRecord::QueryMethods

🔗 includesのしくみ

includespreloadeager_loadのショートハンドです。

参考: Rails API preload -- ActiveRecord::QueryMethods
参考: Rails API eager_load -- ActiveRecord::QueryMethods

preloadは2つのクエリを開始します。最初のクエリはメインのモデルをフェッチし、次のクエリは関連付けられているモデルをフェッチします。eager_loadはLEFT JOINを行い、メインのモデルと関連付けられているモデルの両方をフェッチする1つのクエリを開始します。

preloadはメモリ使用量の点でeager_loadよりずっと有利です。eager_loadで使われる他の戦略を強制しない限り、preloadActive Recordのデフォルト戦略です。理由についてはActiveRecord::Associations::Preloaderクラスに記載されています。

rubydocにある昔のRails 4.1.7のPreloaderドキュメントはもっと参考になります。

Authorモデルに'name'カラムと'age'カラム、Bookモデルに'name'カラムと'sales'カラムがあるとします。Active Recordはこの戦略に基づいて、以下のように1件のクエリで1件のauthorとその全bookをすべて取り出そうとします。

SELECT * FROM authors
LEFT OUTER JOIN books ON authors.id = books.author_id
WHERE authors.name = 'Ken Akamatsu'

しかしこれでは余分なデータを含んだ行が大量に返される可能性があります。最初の行が返った時点で、Authorオブジェクトをインスタンス化するのに十分なデータがあります。以後の多数の行で有用なのは、JOINされた'books'テーブルのデータだけあり、JOINされた'authors'のデータは冗長であるにもかかわらずメモリとCPU時間を食います。この問題はeager loadingのレベルが増すに連れてたちまち悪化します(つまりActive Recordが関連付けの関連付けもeager loadingする)。

🔗 preloadのしくみ

preloadは、preloadするすべての関連付けをループして、関連付け1件ごとにクエリを作成します。

# Project.preload(:commits)
> Project Load (1.8ms)  SELECT "projects".* FROM "projects"
> Commit Load (128.3ms)  SELECT "commits".* FROM "commits" WHERE "commits"."project_id" IN ($1, $2) [["project_id", 1], ["project_id", 2]]

🔗 eager_loadのしくみ

eager_loadは、LEFT OUTER JOINでレコードをeager loadingします。JOINのRIGHT側とマッチするかどうかにかかわらずJOINのLEFT側のすべての行を保持するのではありません。

eager_loadJoinDependencyの内部に実装されています。返される結果では、LEFT側のテーブルの各レコードについて1行しか含まれません。

# Project.eager_load(:commits)
> SELECT "projects"."id" AS t0_r0, "projects"."name" AS t0_r1, "projects"."org" AS t0_r2, "projects"."connected_branch" AS t0_r3,
  "projects"."enabled_by" AS t0_r4, "projects"."created_at" AS t0_r5, "projects"."updated_at" AS t0_r6, "projects"."permalink" AS t0_r7,
  "projects"."domain" AS t0_r8, "commits"."id" AS t1_r0, "commits"."author" AS t1_r1, "commits"."committer" AS t1_r2, "commits"."message"
  AS t1_r3, "commits"."sha" AS t1_r4, "commits"."parents" AS t1_r5, "commits"."project_id" AS t1_r6, "commits"."created_at" AS t1_r7,
  "commits"."updated_at" AS t1_r8, "commits"."status" AS t1_r9, "commits"."committed_at" AS t1_r10 FROM "projects"
  LEFT OUTER JOIN "commits" ON "commits"."project_id" = "projects"."id"

# Project.includes(:commits).references(:commits).count
# includesにreferencesを追加することでもeager_loadがトリガされる
# ... (上と同じクエリ) ...

eager_loadcountを組み合わせるとDISTINCTキーワードが自動で追加されることに気づいた方もいるでしょう。

このDISTINCTは、eager loadingおよびCOUNT SQL操作が検出されたときにActive Recordによって追加されます。

# Project.eager_load(:commits).count
# 'DISTINCT'が自動で追加されたことがわかる
  SELECT COUNT(DISTINCT "projects"."id") FROM "projects" LEFT OUTER JOIN "commits" ON "commits"."project_id" = "projects"."id"
> 2

🔗 関連付けのサブセットをeager loadingする

場合によっては、キューに乗ったすべてのcommitなどのデータで、データのサブセットだけをeager loadingしたいことがあります。これについては後述の「メソッドをスコープにチェインしてしまう」や「スコープはeager loadingできないので関連付けに変換する」で解説しています。

🔗 動的な条件に基づいてeager loadingする

上のセクションの続きです。フィルタの基準が動的に決定されるデータのサブセットをeager loadingしたいときはどうすればよいでしょうか。たとえば、現在ログイン中のユーザーのcommitをすべてeager loadingしたいとします。それには、ActiveRecord::Associations::PreloaderというドキュメントのないAPIを使う必要があります。

# projects = Project.all.to_a
> SELECT "projects".* FROM "projects"

# ActiveRecord::Associations::Preloader.new.preload(
#   projects,
#   :commits,
#   Commit.where("author ->> 'email' = ?", current_user.email)
# )
> SELECT "commits".* FROM "commits" WHERE (author ->> 'email' = > 'current_user_email')

原注: この機能は今のところRails 6では動かなくなっています(#36638

訳注

その後、#36638は以下のプルリク(6-0-stableブランチ)で修正されました。

🔗 集計クエリをeager loadingする

Railsには組み込みのカウンタキャッシュがあり、COUNT集計関数の問題解決に利用できます。カウンタキャッシュを追加してメンテしたくないのであれば、activerecord-precounterなどのgemを利用できます。

k0kubun/activerecord-precounter - GitHub

その他のAVGSUMなどの集計関数については、eager_group gemを利用できます。

flyerhzm/eager_group - GitHub

また、余分な依存関係を追加せずに解決する方法もあります。

# projects.map {|project| project.commits.count}
> SELECT COUNT(*) FROM "commits" WHERE "commits"."project_id" = $1  [["project_id", 1]]
> SELECT COUNT(*) FROM "commits" WHERE "commits"."project_id" = $1  [["project_id", 2]]

# Commit.where(project_id: projects).group(:project_id).count
> SELECT COUNT(*) AS count_all, "commits"."project_id" AS
  commits_project_id FROM "commits" WHERE "commits"."project_id"
  IN (SELECT "projects"."id" FROM "projects" WHERE "projects"."id" IN ($1, $2))
  GROUP BY "commits"."project_id"  [["id", 1], ["id", 2]]

# Commit.where(project_id: projects).group(:project_id).sum(:comments)
> SELECT SUM(comments) AS sum_comments, "commits"."project_id" AS
  commits_project_id FROM "commits" WHERE "commits"."project_id"
  IN (SELECT "projects"."id" FROM "projects" WHERE "projects"."id" IN ($1, $2))
  GROUP BY "commits"."project_id"  [["id", 1], ["id", 2]]

🔗 eager loadingのよくある間違い

🔗 1. 誤ったオーナーで関連付けがeager loadingされる

次の例をご覧ください。

class Project < ApplicationRecord
  has_many :commits
  has_many :posts, through: :commits
end
class Commit < ApplicationRecord
  belongs_to :project
  has_one :post
end
class Post < ApplicationRecord
  belongs_to :commit
end

Projectでpostsをeager loadingしたのにcommitでpostを呼び出すと、データベースへのクエリが発生します。このposts(ターゲット)はproject(オーナー)で読み込まれるので、このprojectで読み込まれたpostsしかフェッチできなくなります。

# projects = Project.includes(:commits, :posts)
> SELECT "projects".* FROM "projects"
> SELECT "commits".* FROM "commits" WHERE "commits"."project_id" IN ($1, $2, ...)
> SELECT "posts".* FROM "posts" WHERE "posts"."commit_id" IN ($1, $2, ...)

# projects.first.commits.first.post
>

postを個別のcommitで呼びたい場合は、そのcommitでeager loadingする必要があります。

Project.includes(commits: :post)
> ...

projects.first.commits.first.post

🔗 2. selectではなくpluckを使ってしまう

以下の例を見てみましょう。指定の組織にあるprojectのcommitをすべて読み込みたいとします。

# Commit.where(project_id: Project.where(org: 'rails').pluck(:id))
> SELECT "projects"."id" FROM "projects" WHERE "projects"."org" = $1  [["org", "rails"]]
> SELECT "commits".* FROM "commits" WHERE "commits"."project_id" IN ($1, $2)  [["project_id", 1], ["project_id", 2]]

上のコードではクエリが2件発生します。最初のクエリではprojectsのidをSELECTし、次でcommitsをフェッチします。このpluckselectに置き換えてみると、サブクエリのおかげでクエリが1件だけになります。

# Commit.where(project_id: Project.where(org: 'rails').select(:id))
> SELECT "commits".* FROM "commits" WHERE "commits"."project_id" IN (SELECT "projects"."id" FROM "projects" WHERE "projects"."org" = $1)  [["org", "rails"]]

メモ: pluckは、行全体ではなく値のサブセットをSELECTしたい場合に使いましょう。

🔗 3. メソッドをスコープにチェインしてしまう

あるprojectのすべてのpostsを、読み込み済みのpostsのサブセット(公開済みのpostsのみ、など)に応じて読み込みたい場合、結果セットが小さいのであれば、メモリ上でフィルタすることでデータベースにクエリを2件送信することを回避できます。

projects.posts           # DBクエリ1件
projects.posts.published # DBクエリ1件

# 以下ならデータベースへのクエリが1件で済む
projects.posts.select {|p| p.status.eql?('published') }

🔗 4. スコープはeager loadingできないので関連付けに変換する

class Commit < ApplicationRecord
  ...

  scope :queued, -> { where(status: :queued) }

  ...
end

projects = Project.includes(:commits)

# キューに乗ったcommitをフェッチするクエリを送信する
projects.commits.queued

commitsをすべてeager loadingするのではなく、キューに乗ったcommits(commitsのサブセット)だけをeager loadingしたい場合は、以下のようにスコープ付きの関連付けを作成してからeager loadingする必要があります。

class Project < ApplicationRecord
  ...

  has_many :queued_commits, -> { where(status: :queued) }, class_name: 'Commit'

  ...
end

projects = Project.includes(:queued_commits)
projects.queued_commits

🔗 5. プリロードしたデータでexists?を呼んでしまう

exists?を呼び出すと常にデータベースクエリが発生します。データが読み込み済みであれば、レコードが空でないかどうかの確認にはpresent?を使うべきです。

別の方法として、存在(presence)チェック後にデータが読み込まれることがわかっていれば、単にレコードを読み込んでから存在チェックします。

🔗 6. countではなくsizeを使おう

countを使うと常にデータベースクエリが発生します。データが読み込み済みであれば、sizeを呼べば関連付けのサイズを取得できます。常にsizeを使うようにすれば、データベースクエリは関連付けが読み込まれていない場合にのみ発生します。

🔗 まとめ

本記事では、よくあるN+1問題を克服するのに使えるActive Recordのメソッドをいくつかご紹介いたしました。シナリオによっては、大量のオブジェクト読み込みやクエリ実行を削減するヒントをRailsに提供できます。Railsアプリケーションのスピードアップについては今後の記事でフォローする予定です。

関連記事

Rails: Bulletで検出されないN+1クエリを解消する

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

Rails向け高機能カウンタキャッシュ gem「counter_culture」README(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。