メインコンテンツへスキップ
見出し画像

僕が Prisma で N+1 地獄にハマった話

    「おおとろさん、最近、管理画面が少し重い気がするのですが…」

    クライアントから届いた Slack のメッセージに、僕の心臓はドクンと嫌な音を立てた。フリーランスにとって、これほど背筋が凍る言葉はない。

    僕が担当していたのは、とある EC サイトの管理画面ダッシュボード。日々の売上やユーザー動向を一覧できる、プロジェクトの心臓部とも言える場所だ。

    開発は順調だった。Next.js と Prisma の組み合わせは、まるでバターのように滑らかで、僕はその生産性の高さに惚れ惚れしていた。schema.prisma ファイルにモデルを定義すれば、魔法のように型安全なクライアントが生成される。リレーションの定義なんて、モデル同士を @relation で繋ぐだけ。なんてエレガントなんだ、と。

    ローカル環境では、ダミーデータでサクサク動いていた。うん、完璧だ。僕は自信を持って納品した。

    ——あの「悪魔」が、データの影に潜んでいることにも気づかずに。

    これは、モダンな ORM の代表格である Prisma の甘い罠にハマり、「N+1 地獄」で燃え尽きかけた僕が、どうやってそこから生還したのかを記す、試行錯誤の全記録である。

    平穏なコードの裏に潜む、パフォーマンスの時限爆弾

    僕が実装していたのは、「ユーザー一覧」画面。各ユーザーの情報に加えて、「そのユーザーの直近の注文ステータス」をバッジとして表示する、よくある機能だ。

    当時の僕は、Prisma の魔法に魅せられていた。schema.prisma には、User モデルと Order モデルが、それは美しく定義されていた。

    model User {
      id     Int     @id @default(autoincrement())
      name   String
      email  String  @unique
      orders Order[]
    }
    
    model Order {
      id        Int      @id @default(autoincrement())
      userId    Int
      user      User     @relation(fields: [userId], references: [id])
      status    String
      createdAt DateTime @default(now())
    }

    そして、ユーザー一覧を取得するコードは、こんな感じだった。シンプルで、誰が見ても分かりやすい。

    import { PrismaClient } from '@prisma/client';
    const prisma = new PrismaClient();
    
    async function getUsersWithLatestOrderStatus() {
      const users = await prisma.user.findMany();
    
      const usersWithStatus = await Promise.all(
        users.map(async (user) => {
          const latestOrder = await prisma.order.findFirst({
            where: { userId: user.id },
            orderBy: { createdAt: 'desc' },
          });
          return {
            ...user,
            latestOrderStatus: latestOrder?.status ?? '注文なし',
          };
        })
      );
    
      return usersWithStatus;
    }

    完璧じゃないか。コードは DRY で、ロジックも明確だ。ローカルの 10 人程度のユーザーデータでは、何の遅延もなく結果が返ってくる。僕は胸をなでおろし、他のタスクに取り掛かった。

    クライアントからの連絡があったのは、それから数週間後。サービスのユーザー数が 500 人を超えた頃だった。

    「サイトが重い」

    その一言が、僕を地獄の調査へと突き落とした。

    N+1 という名の悪魔

    最初は、フロントエンドのレンダリングか、サーバーのスペック不足を疑った。だが、各種メトリクスに異常はない。原因はもっと深い場所、データベースとのやり取りにあると直感した。

    僕は、開発環境に本番相当のデータを投入し、Prisma のログ出力を有効にした。

    const prisma = new PrismaClient({
      log: ['query'],
    });

    そして、管理画面を開いた瞬間、コンソールに吐き出されるログを見て愕然とした。

    SELECT "public"."User"."id", "public"."User"."name", "public"."User"."email" FROM "public"."User"
    SELECT "public"."Order"."id", "public"."Order"."userId", "public"."Order"."status", "public"."Order"."createdAt" FROM "public"."Order" WHERE "public"."Order"."userId" = 1 ORDER BY "public"."Order"."createdAt" DESC LIMIT 1
    SELECT "public"."Order"."id", "public"."Order"."userId", "public"."Order"."status", "public"."Order"."createdAt" FROM "public"."Order" WHERE "public"."Order"."userId" = 2 ORDER BY "public"."Order"."createdAt" DESC LIMIT 1
    SELECT "public"."Order"."id", "public"."Order"."userId", "public"."Order"."status", "public"."Order"."createdAt" FROM "public"."Order" WHERE "public"."Order"."userId" = 3 ORDER BY "public"."Order"."createdAt" DESC LIMIT 1
    ...
    SELECT "public"."Order"."id", "public"."Order"."userId", "public"."Order"."status", "public"."Order"."createdAt" FROM "public"."Order" WHERE "public"."Order"."userId" = 500 ORDER BY "public"."Order"."createdAt" DESC LIMIT 1

    そうだ。これこそが、ORM 初心者が必ず通る道、「N+1 問題」だった。

    最初にユーザー一覧を取得するために 1 回、そして、各ユーザーの注文ステータスを取得するために N 回(ここでは 500 回)のクエリが、データベースに洪水のように押し寄せていたのだ。合計 501 回のクエリ。遅くなって当然だった。

    頭をガツンと殴られたような衝撃だった。エレガントだと思っていたコードが、実はパフォーマンスを最悪にする時限爆弾だったなんて。

    僕は、クライアントへの申し訳なさと、自分の技術的な未熟さへの不甲斐なさで、その場にうずくまりたくなった。

    Prisma を 「疑う」 ことから始まった

    地獄の底で、僕は思考を切り替えた。

    Prisma は悪くない。悪いのは、Prisma の挙動を理解せず、ただ「動くから」という理由でコードを書いてしまった僕自身だ。

    僕は一度、Prisma のドキュメントを隅から隅まで読み返すことにした。特にリレーションの取得に関する部分を。そこで、光り輝くような単語を見つけた。

     
     
    15年のフリーランス / ソフトウェアエンジニア経験を基に、仕事が劇的にラクになる思考法を発信し、あなたの生産性と心の余裕を生み出します。吃音で選んだ物語の力で、人生の深い葛藤を乗り越えるヒントと癒しを提供。フォローで専門知識と心の拠り所となる物語が手に入ります。

    あなたへのおすすめ