Railsにおけるn+1問題とその対策についてまとめた

2024/05/12に公開

n+1問題とは?

n+1問題は、プログラミングにおいてデータベースを使ったアプリケーション開発でよく見られるパフォーマンスの問題です。この問題は、データの取得方法が非効率的であるために発生し、アプリケーションの速度を大幅に低下させることがあります。

具体的にn+1が発生する例

例えば、ブログの投稿(Post)とそれに紐づく投稿者(User)がいるとします。以下のようなシンプルな操作でn+1問題が発生します。

  1. すべての投稿を取得します:Post.all
  2. 各投稿に対して、関連する投稿者の情報を取得します: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"

このコードは、postsusersテーブルを結合するクエリを一度に実行し、必要なデータを全て取得します。

includes

includesメソッドはpreloadeager_loadをよしなに切り替えてくれるメソッドです。

Railsはクエリの内容に基づいて、内部的にpreloadeager_loadのどちらを使用するかを決定します。この判断は、クエリに含まれるその他の条件(例えば、ソートやフィルタリング)に基づいています。

  • フィルターやソートなどの条件が関連オブジェクトに対して適用される場合includeseager_loadを使用し、LEFT OUTER JOINを利用して一つのクエリでデータを取得します。
  • 特に条件がない場合includespreloadのように動作し、関連オブジェクトを別々のクエリで取得します。

使用例:

条件なしの場合

@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は非常に便利で柔軟なメソッドで、多くの場合においてpreloadeager_loadを直接使うよりも良さそうに見えますね。

ただし、includesが常にpreloadeager_loadの代わりになるわけではありません。その理由は、includesの動作がクエリの文脈に依存するため、特定の状況で予期しないパフォーマンス問題を引き起こす可能性があるからです。

includesのメリットとデメリット

メリット

  • 柔軟性:条件に基づいて最適なデータ取得戦略を選択します。
  • 簡便性:開発者が内部の最適化を意識せずに、必要に応じてデータを取得することができます。

デメリット

  • 不透明性:includesが内部でどの戦略を選択しているかは常に明白ではなく、デバッグやパフォーマンスチューニング時に問題を特定しにくいことがあります。
  • パフォーマンス問題:特に大量のデータと複数の関連を扱う場合、予期せぬ大量のデータの取得や不要なJOINが発生することがあります。

結局は適切な選択が必要

preloadeager_loadincludesの選択は、アプリケーションの特定の要件とパフォーマンス要件に基づいて行う必要があります。

単純なクエリや条件が関連オブジェクトに依存しない場合はpreloadを、関連オブジェクトに対する条件が存在する複雑なクエリではeager_loadが適切かもしれません。一方で、クエリの文脈に応じてRailsに選択を委ねたい場合はincludesが最適です。

まとめ

Railsのpreloadeager_loadincludesを適切に使い分けることで、データベースのパフォーマンスを向上させることができます。それぞれのメソッドの特性を理解し、アプリケーションの要件に応じて最適な方法を選択しましょう。

Discussion