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
のしくみ
includes
はpreload
とeager_load
のショートハンドです。
参考: Rails API preload
-- ActiveRecord::QueryMethods
参考: Rails API eager_load
-- ActiveRecord::QueryMethods
preload
は2つのクエリを開始します。最初のクエリはメインのモデルをフェッチし、次のクエリは関連付けられているモデルをフェッチします。eager_load
はLEFT JOINを行い、メインのモデルと関連付けられているモデルの両方をフェッチする1つのクエリを開始します。
preload
はメモリ使用量の点でeager_load
よりずっと有利です。eager_load
で使われる他の戦略を強制しない限り、preload
はActive 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_load
はJoinDependency
の内部に実装されています。返される結果では、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_load
とcount
を組み合わせると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を利用できます。
その他のAVG
やSUM
などの集計関数については、eager_group gemを利用できます。
また、余分な依存関係を追加せずに解決する方法もあります。
# 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をフェッチします。このpluck
をselect
に置き換えてみると、サブクエリのおかげでクエリが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アプリケーションのスピードアップについては今後の記事でフォローする予定です。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。