Railsにおけるn+1問題とその対策についてまとめた
n+1問題とは?
n+1問題は、プログラミングにおいてデータベースを使ったアプリケーション開発でよく見られるパフォーマンスの問題です。この問題は、データの取得方法が非効率的であるために発生し、アプリケーションの速度を大幅に低下させることがあります。
具体的にn+1が発生する例
例えば、ブログの投稿(Post)とそれに紐づく投稿者(User)がいるとします。以下のようなシンプルな操作でn+1問題が発生します。
- すべての投稿を取得します:
Post.all
- 各投稿に対して、関連する投稿者の情報を取得します:
post.user
@posts = Post.all
@posts.each do |post|
puts post.user.name
end
このようなコードを実行したときに、全ての投稿を取得するクエリと、各投稿に対して個別ユーザー情報を取得するクエリが実行されます。
生成されるクエリ例
全ての投稿を取得するクエリ
SELECT "posts".* FROM "posts";
各投稿に対して個別ユーザー情報を取得するクエリ(投稿の数だけ繰り返される)
SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1;
このクエリは投稿の数だけ繰り返され、ここで「?」は各投稿に紐づくユーザーのIDに置き換えられます。たとえば、ユーザーIDが1, 2, 3のユーザー情報を取得する場合、以下のようなクエリが各ユーザーごとに発行されます
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1;
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1;
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1;
n+1の問題点
この操作において、最初のPost.allで全投稿を取得する際に1回のクエリが実行されます。問題は、それぞれの投稿に対して投稿者の情報を取得するために、投稿の数だけ追加のクエリが発行されることです。
例えば、100件の投稿があれば、投稿者情報を取得するためにさらに100回のクエリが発行されます。これにより合計101回のクエリが実行されることになり、これが「n+1」問題(1回のクエリ + n回のクエリ)と呼ばれる所以です。
n+1問題の解決策
Railsでは、n+1問題を解決するために以下の三つのメソッドが提供されています。
- preload
- eager_load
- includes
preload
preload
メソッドは、関連データを事前にロードするために、複数のクエリを使います。これは、関連するすべてのデータをまとめて読み込むため、関連オブジェクトに対しては別個のクエリを発行します。preload
は関連するすべてのレコードを一度に取得するため、条件を加えたり特定の順序で並べ替えたりする場合には使えません。
使用例:
@posts = Post.preload(:user)
SELECT "posts".* FROM "posts";
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, ...);
このコードは、まずすべてのPost
を取得するクエリを実行し、次に関連するUser
を取得する別のクエリを実行します。
eager_load
eager_load
メソッドは、LEFT OUTER JOIN
を使用して関連データを一つのクエリで取得します。この方法は、関連するすべてのレコードを一緒に取得するため、特定の条件や並べ替えを関連データにも適用できる利点があります。しかし、取得するデータ量が多くなるため、場合によってはパフォーマンスが低下することがあります。
使用例:
@posts = Post.eager_load(:user)
SELECT
"posts"."id" AS t0_r0,
"posts"."title" AS t0_r1,
"posts"."body" AS t0_r2,
"posts"."user_id" AS t0_r3,
"posts"."created_at" AS t0_r4,
"posts"."updated_at" AS t0_r5,
"users"."id" AS t1_r0,
"users"."name" AS t1_r1,
"users"."email" AS t1_r2,
"users"."created_at" AS t1_r3,
"users"."updated_at" AS t1_r4
FROM
"posts"
LEFT OUTER JOIN
"users" ON "users"."id" = "posts"."user_id"
このコードは、posts
とusers
テーブルを結合するクエリを一度に実行し、必要なデータを全て取得します。
includes
includes
メソッドはpreload
かeager_load
をよしなに切り替えてくれるメソッドです。
Railsはクエリの内容に基づいて、内部的にpreload
かeager_load
のどちらを使用するかを決定します。この判断は、クエリに含まれるその他の条件(例えば、ソートやフィルタリング)に基づいています。
-
フィルターやソートなどの条件が関連オブジェクトに対して適用される場合、
includes
はeager_load
を使用し、LEFT OUTER JOIN
を利用して一つのクエリでデータを取得します。 -
特に条件がない場合、
includes
はpreload
のように動作し、関連オブジェクトを別々のクエリで取得します。
使用例:
条件なしの場合
@posts = Post.includes(:user)
この場合は条件がないので、以下のクエリが発行されます。
SELECT "posts".* FROM "posts";
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, ...);
条件ありの場合
一方で条件を指定した場合はまた異なる動作をします。
@posts = Post.includes(:user).where(users: {name: 'Alice'})
SELECT
"posts"."id" AS t0_r0,
"posts"."title" AS t0_r1,
"posts"."body" AS t0_r2,
"posts"."user_id" AS t0_r3,
"posts"."created_at" AS t0_r4,
"posts"."updated_at" AS t0_r5,
"users"."id" AS t1_r0,
"users"."name" AS t1_r1,
"users"."email" AS t1_r2,
"users"."created_at" AS t1_r3,
"users"."updated_at" AS t1_r4
FROM
"posts"
LEFT OUTER JOIN
"users" ON "users"."id" = "posts"."user_id"
WHERE
"users"."name" = 'Alice';
とりあえずincludesを使えば良いのか?
includes
は非常に便利で柔軟なメソッドで、多くの場合においてpreload
やeager_load
を直接使うよりも良さそうに見えますね。
ただし、includes
が常にpreload
やeager_load
の代わりになるわけではありません。その理由は、includes
の動作がクエリの文脈に依存するため、特定の状況で予期しないパフォーマンス問題を引き起こす可能性があるからです。
includes
のメリットとデメリット
メリット
- 柔軟性:条件に基づいて最適なデータ取得戦略を選択します。
- 簡便性:開発者が内部の最適化を意識せずに、必要に応じてデータを取得することができます。
デメリット
- 不透明性:
includes
が内部でどの戦略を選択しているかは常に明白ではなく、デバッグやパフォーマンスチューニング時に問題を特定しにくいことがあります。 - パフォーマンス問題:特に大量のデータと複数の関連を扱う場合、予期せぬ大量のデータの取得や不要なJOINが発生することがあります。
結局は適切な選択が必要
preload
、eager_load
、includes
の選択は、アプリケーションの特定の要件とパフォーマンス要件に基づいて行う必要があります。
単純なクエリや条件が関連オブジェクトに依存しない場合はpreload
を、関連オブジェクトに対する条件が存在する複雑なクエリではeager_load
が適切かもしれません。一方で、クエリの文脈に応じてRailsに選択を委ねたい場合はincludes
が最適です。
まとめ
Railsのpreload
、eager_load
、includes
を適切に使い分けることで、データベースのパフォーマンスを向上させることができます。それぞれのメソッドの特性を理解し、アプリケーションの要件に応じて最適な方法を選択しましょう。
Discussion