PrismaデータベースPostgreSQLパフォーマンスTypeScript

PrismaでN+1問題を解消するデータベース設計パターン

読了約 10

バックエンド開発において、N+1問題はパフォーマンス低下の最も一般的な原因の一つです。Prismaを使っていても知らずのうちにN+1問題を引き起こしている可能性があります。この記事では、N+1問題の本質から始め、Prismaの機能を使った解決策、インデックス設計のベストプラクティス、そしてパフォーマンスデバッグの方法まで体系的に解説します。

N+1問題とは何か

N+1問題は、リスト取得のクエリ(1回)の後、各要素に対して追加のクエリ(N回)が実行される問題です。例えば、100件のユーザーを取得してそれぞれのプロフィールを取得しようとすると、合計101回のクエリが実行されます。これは個々のクエリが軽量であっても、ネットワーク往復コストとデータベース接続コストが累積して大きなパフォーマンス問題になります。

typescript
// N+1問題の具体例
// ❌ 悪いコード:N+1問題を引き起こす

async function getUsersWithProfiles() {
  // クエリ1: ユーザー一覧を取得
  const users = await prisma.user.findMany()

  // クエリ2〜N+1: 各ユーザーのプロフィールを個別に取得
  const usersWithProfiles = await Promise.all(
    users.map(async (user) => {
      const profile = await prisma.profile.findUnique({
        where: { userId: user.id }
      })
      return { ...user, profile }
    })
  )

  return usersWithProfiles
}

// 100人のユーザーがいると、101回のクエリが実行される!
// SELECT * FROM users
// SELECT * FROM profiles WHERE user_id = '1'
// SELECT * FROM profiles WHERE user_id = '2'
// ... × 100回

// 実際のクエリ数を確認する方法
const result = await prisma.$transaction(async (tx) => {
  const users = await tx.user.findMany()
  console.log(`現在のクエリ数: ユーザー取得1回 + プロフィール取得${users.length}回`)
  return users
})

Prismaのincludeとselectの違い

Prismaには関連データを取得するためのincludeとselectという2つのオプションがあります。両者は似ていますが、挙動と用途が異なります。includeはベースのモデルのすべてのフィールドを取得した上で、リレーション先のデータを追加します。selectは取得するフィールドを明示的に指定し、不要なデータを取得しません。

typescript
// ✅ includeを使ったN+1問題の解消
// Prismaはこれを内部的にJOINまたはサブクエリで最適化する

async function getUsersWithProfiles() {
  const users = await prisma.user.findMany({
    include: {
      profile: true,          // 1対1リレーション
      posts: {                // 1対多リレーション
        where: { published: true },
        orderBy: { createdAt: 'desc' },
        take: 5,
      },
    },
  })
  return users
}
// 実行されるクエリ: 2〜3回(ユーザー + プロフィール + 投稿)

// ✅ selectで必要なフィールドのみ取得
async function getUsersBasicInfo() {
  const users = await prisma.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      // profile全体ではなく必要なフィールドだけ
      profile: {
        select: {
          bio: true,
          avatarUrl: true,
        }
      },
      // postsの件数だけ取得(全件取得より効率的)
      _count: {
        select: { posts: true }
      }
    },
  })
  return users
}

// includeとselectの比較
// include: User全フィールド + リレーション先のデータ
// select: 指定したフィールドのみ(バンドルサイズ削減、転送量削減)

relationLoadStrategyでN+1を解消する

Prisma 5.9以降ではrelationLoadStrategyオプションが追加され、リレーションのロード方法を明示的に制御できるようになりました。joinを指定するとSQLのJOINで取得、subqueryを指定するとサブクエリで取得します。データベースエンジンや状況によって最適な戦略が異なります。

typescript
// relationLoadStrategyの使用例(Prisma 5.9以降)

// JOINを使ったロード(SQLiteやMySQLで有効)
const usersWithJoin = await prisma.user.findMany({
  relationLoadStrategy: 'join',  // デフォルトはpostgresqlでjoin、他はquery
  include: {
    profile: true,
    posts: true,
  },
})

// サブクエリを使ったロード
const usersWithSubquery = await prisma.user.findMany({
  relationLoadStrategy: 'query',  // サブクエリで取得
  include: {
    profile: true,
  },
})

// パフォーマンス比較のポイント
// join: 1回のSQLで全データを取得(ネットワーク往復が少ない)
// query: 複数のSQLで取得(キャッシュが効きやすい、大量データに有効)

// スキーマでデフォルトを設定
// schema.prisma
// generator client {
//   provider = "prisma-client-js"
//   previewFeatures = ["relationJoins"]  // Prisma 5.9未満の場合に必要
// }

// クエリの実行計画を確認(PostgreSQL)
const result = await prisma.$queryRaw`
  EXPLAIN ANALYZE
  SELECT u.*, p.*
  FROM users u
  LEFT JOIN profiles p ON p.user_id = u.id
  WHERE u.created_at > NOW() - INTERVAL '30 days'
`

インデックス設計のベストプラクティス

適切なインデックス設計なしに、どれほど優れたORMを使っても高いパフォーマンスは実現できません。PrismaのスキーマでインデックスはSchemaファイルで定義できますが、それに加えてミグレーション後に実行計画を確認し、適切なインデックスが使われているかを検証することが重要です。

typescript
// schema.prisma でのインデックス定義
// model Post {
//   id        String   @id @default(uuid())
//   title     String
//   content   String
//   published Boolean  @default(false)
//   authorId  String
//   createdAt DateTime @default(now())
//   tags      String[]
//
//   author    User     @relation(fields: [authorId], references: [id])
//
//   // 単一フィールドインデックス
//   @@index([authorId])
//   @@index([createdAt])
//
//   // 複合インデックス(よく使うフィルタリングの組み合わせ)
//   @@index([published, createdAt])
//   @@index([authorId, published])
//
//   // ユニーク制約
//   @@unique([authorId, title])
// }

// インデックスが効いているか確認するクエリ
async function debugQuery() {
  const explanation = await prisma.$queryRaw`
    EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
    SELECT *
    FROM posts
    WHERE author_id = ${'user-id'}
      AND published = true
    ORDER BY created_at DESC
    LIMIT 10
  `

  console.log(JSON.stringify(explanation, null, 2))
  // "Index Scan" が表示されればインデックスが有効
  // "Seq Scan" が表示されればフルスキャン(インデックスが効いていない)
}

// 非効率なクエリのパターンと改善
// ❌ インデックスが効かないパターン
await prisma.user.findMany({
  where: {
    // OR条件はインデックスが効きにくい
    OR: [
      { name: { contains: 'alice' } },
      { email: { contains: 'alice' } },
    ]
  }
})

// ✅ 改善:PostgreSQLのfull-text searchを使う
await prisma.user.findMany({
  where: {
    name: {
      search: 'alice',  // @db.TsVector が必要
    }
  }
})

トランザクション処理の書き方

複数のデータベース操作をアトミックに実行する必要がある場合、トランザクションを使います。Prismaには$transactionメソッドがあり、配列渡しのバッチトランザクションと、コールバック関数の対話的トランザクションの2種類があります。複雑なビジネスロジックには後者を使うのが適切です。

typescript
// バッチトランザクション(シンプルな複数操作)
const [user, profile] = await prisma.$transaction([
  prisma.user.create({ data: { name: 'Alice', email: 'alice@example.com' } }),
  prisma.profile.create({ data: { bio: 'Software Engineer', userId: 'temp' } }),
])

// 対話的トランザクション(複雑なビジネスロジック)
async function transferBalance(
  fromUserId: string,
  toUserId: string,
  amount: number
): Promise<void> {
  await prisma.$transaction(async (tx) => {
    // 送金元の残高を確認
    const sender = await tx.wallet.findUnique({
      where: { userId: fromUserId },
    })

    if (!sender || sender.balance < amount) {
      throw new Error('残高不足です')
    }

    // 送金元から差し引く
    await tx.wallet.update({
      where: { userId: fromUserId },
      data: { balance: { decrement: amount } },
    })

    // 送金先に追加
    await tx.wallet.update({
      where: { userId: toUserId },
      data: { balance: { increment: amount } },
    })

    // 取引履歴を記録
    await tx.transaction.create({
      data: {
        fromUserId,
        toUserId,
        amount,
        type: 'transfer',
      },
    })
  }, {
    maxWait: 5000,  // 接続待ち最大5秒
    timeout: 10000, // トランザクション最大10秒
    isolationLevel: 'Serializable',  // 最高の分離レベル
  })
}

パフォーマンスのデバッグ方法(クエリログ)

Prismaにはクエリログ機能があり、実行されたSQLを詳細に確認できます。開発環境では常に有効にしておくことで、意図しないN+1問題を早期発見できます。また、Prismaのmetrics APIを使うことで、本番環境でのクエリパフォーマンスをモニタリングすることも可能です。

typescript
// lib/db.ts - クエリログを有効にしたPrismaクライアント
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log:
      process.env.NODE_ENV === 'development'
        ? [
            { level: 'query', emit: 'event' },
            { level: 'error', emit: 'stdout' },
            { level: 'warn', emit: 'stdout' },
          ]
        : ['error'],
  })

// クエリイベントのリスニング(開発環境のみ)
if (process.env.NODE_ENV === 'development') {
  let queryCount = 0

  prisma.$on('query', (e) => {
    queryCount++
    console.log(`[Query ${queryCount}] ${e.duration}ms`)
    console.log(e.query)
    if (e.params) console.log('Params:', e.params)
    console.log('---')
  })

  // リクエスト毎にカウントリセット(Next.jsの場合)
  // middleware.tsなどで呼び出す
}

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

// クエリのパフォーマンス計測
async function measureQuery<T>(
  label: string,
  query: () => Promise<T>
): Promise<T> {
  const start = performance.now()
  const result = await query()
  const duration = performance.now() - start
  console.log(`[${label}] ${duration.toFixed(2)}ms`)
  return result
}

// 使用例
const users = await measureQuery('getUsersWithPosts', () =>
  prisma.user.findMany({
    include: { posts: true },
    take: 10,
  })
)

💡 ヒント

Prismaを使う場合、データ量が増えてから最適化するのではなく、設計段階からinclude/selectの適切な使い分けとインデックス設計を意識することが重要です。特に外部キーには必ずインデックスを付け、頻繁に使うフィルタリング条件には複合インデックスを検討してください。

PrismaデータベースPostgreSQLパフォーマンスTypeScript

AI協働開発のご相談はこちら

この記事の内容を実際のプロジェクトに活用したい方、 開発のご依頼・ご質問はお気軽にどうぞ。

無料相談を申し込む