🍟

N+1問題って結局何?TypeScript + Prismaで再現してパフォーマンスの差を見てみた

に公開

はじめに

はじめまして。現在大学4年生の とうふ と申します。
今回は2回目のZenn投稿になります。

バックエンド開発をしているとよく耳にする N+1問題について、今回あらためて自分の中で整理してみることにしました。

加えて、「実際にどれくらいのパフォーマンス差が出るのか?」にも興味があったため、TypeScript + Prisma を使って検証してみた結果をこの記事にまとめています。

記事内にはコードの一部を紹介していますが、すべてのコードは以下のGitHubリポジトリで公開しています。
もし興味を持っていただけたら、ぜひ実際に動かして、データ量を変えたり、テーブルを追加して実験してみてください!

https://github.com/dem3860/n_plus_one_problem

N+1問題とは

N+1問題とは、リレーションを持つデータを取得する際に必要以上にクエリが発行されてしまう問題のことです。

問題点

これがなぜ問題になるかを解説します。

たとえば、「ある一覧データ」と「それに紐づく関連データ」をセットで取得したい場面を考えてみてください。

まず、一覧データ全体を取得するためのクエリが1回発行されます。

しかしその後、一覧の各要素に対して、関連データを1件ずつ取得するような処理を行うと、
要素の数だけ追加でクエリが発行されることになります。

結果として、全体で発行されるクエリの数は 1 + N に増加します。
この構造が、N+1問題と呼ばれるものです。

見えづらいけど怖い問題

N+1問題はテストの際やデータが少ない際には意識していないと気が付かないことも多いです。しかし、
データ量が増えるほど急激にパフォーマンスが悪化します。

では、次のセクションでは実際にN+1問題がどれくらいパフォーマンスに影響するのか実験をしてみたいと思います。

N+1問題を実際に再現して、パフォーマンス差を測ってみる

実際にN+1問題を起こすように書いたパターンと、includeを使用して一度で取ってくるパターンを比較してみましょう。今回用いるデータは以下のような簡単なものです。

model User {
  id    String  @id @default(uuid())
  name  String
  posts Post[]
}

model Post {
  id     String @id @default(uuid())
  title  String
  user   User   @relation(fields: [userId], references: [id])
  userId String
}

Userが記事をいくつか持つという1対多の関係になっています。
データは以下の通り、Userが2000件、それぞれのUserに対して5件ずつのPostがあるデータを用意しました。

  for (let i = 0; i < 2000; i++) {
    await prisma.user.create({
      data: {
        name: `User ${i + 1}`,
        posts: {
          create: Array.from({ length: 5 }, (_, j) => ({
            title: `Post ${j + 1} of User ${i + 1}`,
          })),
        },
      },
    });
  }

パターン1 : includeを使用して適切にデータを取得する

まずはincludeを使用してUserのリストを取得する際に紐づくPostも取得する方法を以下に示します。なお、データ取得部分のみ抜粋します。

    this.prisma.user.findMany({
     orderBy: { name: "asc" },
     include: { posts: true },
    }),

このように書くと、Userの取得の際にPostが一緒に取れます。発行されるSQLのクエリは以下のようになっています。

// User
SELECT "public"."User"."id", "public"."User"."name" FROM "public"."User" WHERE 1=1 ORDER BY "public"."User"."name" ASC OFFSET $1

//Post
SELECT "Post"."id", "Post"."title", "Post"."userId"
FROM "Post"
WHERE "Post"."userId" IN ($1, $2, ..., $2000)
OFFSET $2001;

PrismaのincludeではUserを全件取得したあと、PostについてINで取得していることがわかります。UserごとにPostを取得せず、Userの全件取得(1回のクエリ)、Postの取得(1回のクエリ)で計2回のクエリが発行されていることがわかりました。

データ取得の部分の実行にかかる時間は198.894msでした。

パターン2 : N+1問題を起こす処理の書き方でデータを取得する

次に、いわゆる N+1 問題が発生する処理の書き方です。
以下は、User 一覧を取得した後、map を使って各ユーザーに紐づく Post を1件ずつ個別に取得するコードです。

      this.prisma.user.findMany({
        where,
        orderBy: { name: "asc" },
      }),
      () => new DBError("Failed to list users")
    ).andThen((users) => {
      return fromPromise(
        Promise.all(
          users.map(async (user) => ({
            ...user,
            posts: await this.prisma.post.findMany({
              where: { userId: user.id },
            }),
          }))
        ),
        () => new DBError("Failed to fetch posts for users")
      )

ここでは users.map() 内で、各ユーザーに対して post.findMany() を実行しており、
ユーザーの数だけクエリが発行されてしまう 典型的な N+1 問題となっています。

実際に発行されたSQLは以下の形式のクエリが2000回繰り返されました。Userは同じなので省略します。

SELECT "public"."Post"."id", "public"."Post"."title", "public"."Post"."userId" FROM "public"."Post" WHERE "public"."Post"."userId" = $1 OFFSET $2

このように、ユーザーごとに1クエリずつ発行されており、大量のリクエストがDBに送られる非効率な構造になっていることが分かります。

実行時間は582.45msでした。

結果を比較してわかること

パターン1とパターン2を比較することで、N+1問題では明らかに無駄なSQLクエリが大量に発行され、パフォーマンスが低下していることが分かりました。

「たかが380msの差」と感じる方もいるかもしれませんが、今回はあくまで ユーザーが2000人 という小規模なデータ量での検証です。
実際のサービスでは、1万人、10万人とユーザー数が増えていくにつれて、クエリ数も爆発的に増え、処理時間やサーバー負荷が急激に悪化する可能性があります。

  • ✔️ 小規模なら問題が見えにくい

  • ⚠️ でも、規模が大きくなると “たった数百ms” がユーザー体験に直結

N+1問題は、気づきにくいけど確実に効いてくる隠れたパフォーマンス課題です。
その影響を理解し、早い段階で意識できるかどうかが、将来的なアプリの品質にも大きく関わってきます。

まとめ

ここまで読んでいただき、ありがとうございます。

今回の検証では、同じ「UserとそのPost一覧を取得する」という処理であっても、コードの書き方ひとつで実行時間に3倍近い差が出ることが結果から明らかになりました。

PrismaのようなORMはとても便利な一方で、findMany() などを無意識に使っていると、知らないうちに大量のクエリを発行してしまっていることがあります。

「ちゃんと動いているからOK」ではなく、その裏側で何が起きているのかまで意識することが、より良いコードにつながるのだと、今回の検証を通してあらためて感じました。

私自身もまだまだ学ぶことが多い身ですが、これからもパフォーマンスや設計に配慮したコードを書けるよう、日々勉強を重ねていこうと思います。

今後もいろいろな技術記事を投稿していく予定なので、もし興味を持っていただけたら、ぜひまた読んでいただけると嬉しいです!

Discussion