T-CREATOR

Prisma N+1 を根絶するデータ取得設計:バルクフェッチ/DataLoader/集約の勘所

Prisma N+1 を根絶するデータ取得設計:バルクフェッチ/DataLoader/集約の勘所

Prisma を使ったアプリケーション開発で、突然レスポンスが遅くなったり、データベースへのクエリが爆発的に増えてしまった経験はありませんか。その原因の多くは「N+1 問題」にあります。

N+1 問題は、データベースアクセスにおける代表的なパフォーマンスボトルネックです。しかし、Prisma の適切な機能を活用すれば、この問題を根本から解決できます。本記事では、Prisma における N+1 問題の発生メカニズムを理解し、バルクフェッチ、DataLoader パターン、集約機能を駆使した実践的な解決策をご紹介いたします。

初心者の方でも実装できるよう、コード例を交えながら段階的に解説していきますので、ぜひ最後までお付き合いください。

背景

Prisma とリレーショナルデータベース

Prisma は、TypeScript/JavaScript のための次世代 ORM(Object-Relational Mapping)ツールです。データベースとアプリケーションの間に立ち、型安全なデータアクセスを実現してくれます。

リレーショナルデータベースでは、データを正規化して複数のテーブルに分散して保存するのが一般的です。例えば、ブログアプリケーションでは「ユーザー」「投稿」「コメント」といったテーブルが関連し合っています。

以下の図は、典型的なブログアプリケーションのデータ構造を示しています。

mermaiderDiagram
    User ||--o{ Post : "作成する"
    User ||--o{ Comment : "投稿する"
    Post ||--o{ Comment : "持つ"

    User {
        int id PK
        string name
        string email
    }

    Post {
        int id PK
        string title
        string content
        int authorId FK
    }

    Comment {
        int id PK
        string text
        int postId FK
        int userId FK
    }

この図から、1 人のユーザーが複数の投稿を作成し、各投稿に複数のコメントが付けられる関係性が理解できます。

Prisma のリレーション機能

Prisma では、スキーマファイルでこれらのリレーションを定義します。以下は基本的なスキーマ定義の例です。

prisma// スキーマファイルでのモデル定義
model User {
  id       Int       @id @default(autoincrement())
  name     String
  email    String    @unique
  posts    Post[]
  comments Comment[]
}

ユーザーモデルには postscomments というリレーションフィールドが定義されています。このフィールドにより、ユーザーに関連する投稿やコメントを簡単に取得できます。

prisma// 投稿モデルの定義
model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String
  authorId  Int
  author    User      @relation(fields: [authorId], references: [id])
  comments  Comment[]
}

投稿モデルでは author フィールドで作成者との関連を、comments フィールドでコメントとの関連を表現しています。

prisma// コメントモデルの定義
model Comment {
  id     Int    @id @default(autoincrement())
  text   String
  postId Int
  post   Post   @relation(fields: [postId], references: [id])
  userId Int
  user   User   @relation(fields: [userId], references: [id])
}

このように Prisma のリレーション機能により、データベースのテーブル間の関係性をコードレベルで型安全に表現できるのです。

課題

N+1 問題とは

N+1 問題とは、データベースへのクエリが「1 回の親データ取得 + N 回の子データ取得」という形で実行されてしまう問題です。データ件数が増えるほどパフォーマンスが劣化し、最悪の場合はアプリケーションが応答不能になります。

具体的な例を見てみましょう。以下のコードは、全ての投稿とその作成者情報を取得する処理です。

typescript// 問題のあるコード例:N+1 が発生する
async function getAllPostsWithAuthors() {
  // 1回目:全ての投稿を取得
  const posts = await prisma.post.findMany();

  // 各投稿に対してループ処理
  for (const post of posts) {
    // N回:各投稿の作成者を個別に取得
    const author = await prisma.user.findUnique({
      where: { id: post.authorId },
    });
    console.log(`${post.title} by ${author?.name}`);
  }
}

このコードでは、投稿が 100 件あれば 101 回(1 + 100)のクエリが実行されてしまいます。一見シンプルで分かりやすいコードに見えますが、パフォーマンス面では致命的です。

以下の図は、N+1 問題が発生する際のデータフローを示しています。

mermaidsequenceDiagram
    participant App as アプリケーション
    participant Prisma as Prisma Client
    participant DB as データベース

    App->>Prisma: post.findMany()
    Prisma->>DB: SELECT * FROM Post
    DB-->>Prisma: 100件の投稿
    Prisma-->>App: posts配列

    loop 各投稿ごと(100回)
        App->>Prisma: user.findUnique(id)
        Prisma->>DB: SELECT * FROM User WHERE id = ?
        DB-->>Prisma: ユーザー1件
        Prisma-->>App: author
    end

    Note over App,DB: 合計101回のクエリが実行される

この図から、最初に投稿を一括取得した後、ループ内で個別にユーザー情報を取得している様子が分かります。

N+1 問題の影響

N+1 問題は以下のような深刻な影響をもたらします。

#影響項目詳細
1レスポンス時間の増加クエリ回数に比例してレスポンスが遅延します
2データベース負荷の急増同時接続数や CPU 使用率が上昇します
3ネットワーク帯域の圧迫アプリケーションと DB の通信量が増大します
4スケーラビリティの低下ユーザー数増加時に対応できなくなります

実際の開発現場では、開発環境の少量データでは問題が顕在化せず、本番環境で初めて発覚するケースが多いのです。

N+1 問題が発生しやすいパターン

Prisma を使った開発で特に注意すべきパターンをまとめます。

typescript// パターン1: ループ内でのfindUnique/findFirst
const posts = await prisma.post.findMany();
for (const post of posts) {
  const author = await prisma.user.findUnique({
    where: { id: post.authorId },
  });
}

ループ内で個別にデータを取得すると、必ず N+1 が発生します。

typescript// パターン2: map関数内でのPromise.all
const posts = await prisma.post.findMany();
const postsWithAuthors = await Promise.all(
  posts.map(async (post) => {
    const author = await prisma.user.findUnique({
      where: { id: post.authorId },
    });
    return { ...post, author };
  })
);

Promise.all で並列化しても、クエリ回数自体は減りません。データベースへの負荷は変わらないのです。

typescript// パターン3: ネストしたリレーションの取得漏れ
const users = await prisma.user.findMany({
  include: { posts: true },
});
for (const user of users) {
  for (const post of user.posts) {
    // コメントは include していないため、ここで取得するとN+1
    const comments = await prisma.comment.findMany({
      where: { postId: post.id },
    });
  }
}

深いリレーションになるほど、N+1 問題が複雑化していきます。

解決策

N+1 問題を根絶するには、「必要なデータを事前に一括取得する」という発想が重要です。Prisma には、この実現のための強力な機能が複数用意されています。

解決策 1: include によるリレーションの事前読み込み

最もシンプルで効果的な解決策は、Prisma の include オプションを使うことです。関連データを最初のクエリで一緒に取得できます。

typescript// 解決策: includeで一括取得
async function getAllPostsWithAuthors() {
  const posts = await prisma.post.findMany({
    include: {
      author: true, // 作成者情報を同時に取得
    },
  });

  // ループ内で追加のクエリは不要
  for (const post of posts) {
    console.log(`${post.title} by ${post.author.name}`);
  }
}

このコードでは、投稿と作成者の情報が 1 回のクエリ(内部的には JOIN)で取得されます。100 件の投稿があっても、クエリは 1 回だけです。

typescript// ネストしたincludeも可能
const users = await prisma.user.findMany({
  include: {
    posts: {
      include: {
        comments: true, // 投稿のコメントも一緒に取得
      },
    },
  },
});

深い階層のリレーションも、事前に include で指定しておけば N+1 を回避できます。

解決策 2: select による必要フィールドの限定

include はすべてのフィールドを取得しますが、select を使えば必要なフィールドだけを効率的に取得できます。

typescript// selectで必要なフィールドのみ取得
const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    author: {
      select: {
        name: true,
        email: true,
      },
    },
  },
});

大きなテキストフィールドや不要なリレーションを除外することで、ネットワーク転送量も削減できます。

typescript// includeとselectの組み合わせ
const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    // authorは一部フィールドのみ
    author: {
      select: {
        name: true,
      },
    },
  },
});

パフォーマンスチューニングの際は、select を活用して転送データ量を最小化するのが効果的です。

解決策 3: findUnique の一括化(バルクフェッチ)

複数の ID でデータを取得したい場合は、findManyin 演算子を組み合わせます。

typescript// 複数IDを一括取得
async function getUsersByIds(userIds: number[]) {
  return await prisma.user.findMany({
    where: {
      id: { in: userIds }, // IN句で一括取得
    },
  });
}

ループで findUnique を呼ぶ代わりに、必要な ID をまとめて 1 回で取得します。

typescript// 使用例:投稿から作成者IDを抽出して一括取得
const posts = await prisma.post.findMany();
const authorIds = [
  ...new Set(posts.map((p) => p.authorId)),
];
const authors = await prisma.user.findMany({
  where: { id: { in: authorIds } },
});

// Map化して高速アクセス
const authorMap = new Map(authors.map((a) => [a.id, a]));
posts.forEach((post) => {
  const author = authorMap.get(post.authorId);
  console.log(`${post.title} by ${author?.name}`);
});

ID の重複を排除(Set を使用)してから取得することで、無駄なクエリを削減できます。

解決策 4: DataLoader パターンの実装

GraphQL でよく使われる DataLoader パターンは、Prisma でも有効です。リクエストのバッチ処理とキャッシングを自動化できます。

まず、dataloader パッケージをインストールします。

bashyarn add dataloader
yarn add -D @types/dataloader

次に、DataLoader を初期化する関数を作成します。

typescriptimport DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// ユーザーIDからユーザーを取得するDataLoader
function createUserLoader() {
  return new DataLoader<number, User | null>(
    async (ids) => {
      // 一括でユーザーを取得
      const users = await prisma.user.findMany({
        where: { id: { in: [...ids] } },
      });

      // IDの順序を保持してマッピング
      const userMap = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => userMap.get(id) || null);
    }
  );
}

DataLoader は複数のリクエストを自動的にまとめて、1 回のクエリで処理してくれます。

typescript// DataLoaderの使用例
async function getPostsWithAuthors() {
  const userLoader = createUserLoader();
  const posts = await prisma.post.findMany();

  // 各投稿の作成者を取得(内部で自動的にバッチ化される)
  const postsWithAuthors = await Promise.all(
    posts.map(async (post) => ({
      ...post,
      author: await userLoader.load(post.authorId),
    }))
  );

  return postsWithAuthors;
}

同じイベントループ内の load 呼び出しは自動的にまとめられ、1 回のクエリで実行されます。

以下の図は、DataLoader によるバッチ処理の仕組みを示しています。

mermaidflowchart TB
    subgraph EventLoop["イベントループ内"]
        req1["load(1)"]
        req2["load(2)"]
        req3["load(3)"]
    end

    batch["バッチ化<br/>[1, 2, 3]"]
    query["SELECT * FROM User<br/>WHERE id IN (1, 2, 3)"]
    cache["キャッシュ<br/>{1: User1, 2: User2, 3: User3}"]

    req1 --> batch
    req2 --> batch
    req3 --> batch
    batch --> query
    query --> cache
    cache --> res1["User1"]
    cache --> res2["User2"]
    cache --> res3["User3"]

DataLoader は同一イベントループ内のリクエストを収集し、一括処理してからキャッシュに格納します。

解決策 5: 集約関数の活用

件数や合計値だけが必要な場合は、集約関数を使うことでデータ転送量を大幅に削減できます。

typescript// 各ユーザーの投稿数をカウント
const userWithPostCount = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    _count: {
      select: { posts: true },
    },
  },
});

userWithPostCount.forEach((user) => {
  console.log(`${user.name}: ${user._count.posts}件の投稿`);
});

_count を使えば、リレーション先のデータを取得せずに件数だけを取得できます。

typescript// groupByで集計
const postCountByUser = await prisma.post.groupBy({
  by: ['authorId'],
  _count: {
    id: true,
  },
  orderBy: {
    _count: {
      id: 'desc',
    },
  },
});

groupBy を使うと、SQL の GROUP BY と同等の集計処理が可能です。

typescript// 集約関数の組み合わせ
const commentStats = await prisma.comment.groupBy({
  by: ['postId'],
  _count: { id: true },
  _max: { createdAt: true },
  _min: { createdAt: true },
});

複数の集約関数を組み合わせて、必要な統計情報を効率的に取得できます。

具体例

実際のアプリケーション開発で遭遇する典型的なシナリオを通じて、N+1 問題の解決方法を見ていきましょう。

具体例 1: ブログ記事一覧の最適化

ブログの記事一覧ページで、各記事に「作成者名」「コメント数」「最新コメント」を表示するケースを考えます。

まず、問題のあるコード例です。

typescript// ❌ N+1が発生する悪い例
async function getBlogPostList() {
  const posts = await prisma.post.findMany();

  const result = [];
  for (const post of posts) {
    // 作成者を個別取得(N+1)
    const author = await prisma.user.findUnique({
      where: { id: post.authorId },
    });

    // コメント数を個別取得(N+1)
    const commentCount = await prisma.comment.count({
      where: { postId: post.id },
    });

    // 最新コメントを個別取得(N+1)
    const latestComment = await prisma.comment.findFirst({
      where: { postId: post.id },
      orderBy: { createdAt: 'desc' },
    });

    result.push({
      ...post,
      author,
      commentCount,
      latestComment,
    });
  }

  return result;
}

このコードでは、投稿が 10 件あれば「1 + 10 + 10 + 10 = 31 回」ものクエリが実行されてしまいます。

改善版のコードを見てみましょう。

typescript// ✅ N+1を解決した良い例
async function getBlogPostListOptimized() {
  const posts = await prisma.post.findMany({
    include: {
      // 作成者情報を同時取得
      author: {
        select: {
          id: true,
          name: true,
          email: true,
        },
      },
      // コメント数を取得
      _count: {
        select: { comments: true },
      },
      // 最新コメント1件を取得
      comments: {
        take: 1,
        orderBy: { createdAt: 'desc' },
        select: {
          id: true,
          text: true,
          createdAt: true,
          user: {
            select: {
              name: true,
            },
          },
        },
      },
    },
  });

  return posts;
}

このコードでは、全ての情報を 1 回のクエリで取得しています。投稿が何件あってもクエリは 1 回だけです。

typescript// 取得結果の活用例
const posts = await getBlogPostListOptimized();

posts.forEach((post) => {
  console.log(`タイトル: ${post.title}`);
  console.log(`作成者: ${post.author.name}`);
  console.log(`コメント数: ${post._count.comments}件`);

  if (post.comments.length > 0) {
    const latest = post.comments[0];
    console.log(
      `最新コメント: ${latest.text} by ${latest.user.name}`
    );
  }
});

取得したデータは型安全にアクセスでき、追加のクエリは一切不要です。

具体例 2: ユーザーダッシュボードの実装

ユーザーのダッシュボードで、複数の統計情報を効率的に取得する例です。

typescript// ユーザーダッシュボードに必要な情報を取得
async function getUserDashboard(userId: number) {
  // 並列で複数のクエリを実行
  const [user, postStats, recentActivity] =
    await Promise.all([
      // ユーザー基本情報と投稿数
      prisma.user.findUnique({
        where: { id: userId },
        select: {
          id: true,
          name: true,
          email: true,
          _count: {
            select: {
              posts: true,
              comments: true,
            },
          },
        },
      }),

      // 投稿ごとのコメント数を集計
      prisma.post.findMany({
        where: { authorId: userId },
        select: {
          id: true,
          title: true,
          _count: {
            select: { comments: true },
          },
        },
        orderBy: { createdAt: 'desc' },
        take: 5,
      }),

      // 最近のアクティビティ(コメント)
      prisma.comment.findMany({
        where: { userId },
        select: {
          id: true,
          text: true,
          createdAt: true,
          post: {
            select: {
              title: true,
            },
          },
        },
        orderBy: { createdAt: 'desc' },
        take: 10,
      }),
    ]);

  return { user, postStats, recentActivity };
}

このコードでは、Promise.all を使って独立した 3 つのクエリを並列実行しています。各クエリ内では N+1 が発生しないよう、必要なリレーションを select_count で指定しています。

以下の図は、最適化されたダッシュボードデータ取得のフローを示しています。

mermaidflowchart LR
    start["getUserDashboard<br/>開始"]

    subgraph Parallel["Promise.all(並列実行)"]
        q1["user.findUnique<br/>+_count"]
        q2["post.findMany<br/>+_count"]
        q3["comment.findMany<br/>+post"]
    end

    result["統合結果<br/>返却"]

    start --> Parallel
    q1 --> result
    q2 --> result
    q3 --> result

3 つのクエリが並列で実行され、すべて完了後に結果が統合されます。

具体例 3: DataLoader を使った複雑なリレーション

GraphQL API など、リクエストごとに異なるデータパターンが要求される場合は、DataLoader が威力を発揮します。

typescriptimport DataLoader from 'dataloader';

// 複数のDataLoaderを作成
function createLoaders() {
  const userLoader = new DataLoader<number, User | null>(
    async (ids) => {
      const users = await prisma.user.findMany({
        where: { id: { in: [...ids] } },
      });
      const userMap = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => userMap.get(id) || null);
    }
  );

  const postsByUserLoader = new DataLoader<number, Post[]>(
    async (userIds) => {
      const posts = await prisma.post.findMany({
        where: { authorId: { in: [...userIds] } },
      });

      // ユーザーIDごとにグループ化
      const postsByUser = new Map<number, Post[]>();
      posts.forEach((post) => {
        const existing =
          postsByUser.get(post.authorId) || [];
        postsByUser.set(post.authorId, [...existing, post]);
      });

      return userIds.map((id) => postsByUser.get(id) || []);
    }
  );

  return { userLoader, postsByUserLoader };
}

各 DataLoader は特定のデータ取得パターンに特化しています。

typescript// DataLoaderを使った取得例
async function getCommentsWithDetails(postId: number) {
  const loaders = createLoaders();

  const comments = await prisma.comment.findMany({
    where: { postId },
  });

  // 各コメントの作成者を取得(自動的にバッチ化される)
  const commentsWithUsers = await Promise.all(
    comments.map(async (comment) => ({
      ...comment,
      user: await loaders.userLoader.load(comment.userId),
    }))
  );

  return commentsWithUsers;
}

100 件のコメントがあっても、内部では重複を除いたユーザー ID に対して 1 回だけクエリが実行されます。

具体例 4: エラーハンドリングとデバッグ

N+1 問題のデバッグには、Prisma のクエリログが有効です。

typescript// Prismaクライアントの初期化時にログを有効化
const prisma = new PrismaClient({
  log: [
    { emit: 'event', level: 'query' },
    { emit: 'stdout', level: 'error' },
    { emit: 'stdout', level: 'warn' },
  ],
});

// クエリイベントをリッスン
prisma.$on('query', (e) => {
  console.log('Query: ' + e.query);
  console.log('Duration: ' + e.duration + 'ms');
});

ログを確認することで、どのクエリが何回実行されているかを把握できます。

もし予期せぬ数のクエリが実行されていたら、以下のチェックリストで確認しましょう。

#チェック項目対応方法
1ループ内で findUnique を呼んでいないかinclude または findMany で一括取得に変更
2深いネストで include 漏れがないか必要なリレーションをすべて include に追加
3map 内で await していないかDataLoader または事前一括取得を検討
4不要なフィールドを取得していないかselect で必要なフィールドのみ指定

パフォーマンス問題が発生した場合は、以下のようなエラー情報を記録しておくと解決がスムーズです。

typescript// パフォーマンス計測の例
async function measureQueryPerformance<T>(
  name: string,
  queryFn: () => Promise<T>
): Promise<T> {
  const startTime = performance.now();
  let queryCount = 0;

  // クエリカウンター
  const queryListener = () => {
    queryCount++;
  };

  prisma.$on('query', queryListener);

  try {
    const result = await queryFn();
    const duration = performance.now() - startTime;

    console.log(
      `[${name}] 実行時間: ${duration.toFixed(2)}ms`
    );
    console.log(`[${name}] クエリ回数: ${queryCount}回`);

    // パフォーマンス警告
    if (queryCount > 10) {
      console.warn(
        `⚠️ Warning: ${name} executed ${queryCount} queries. ` +
          `Possible N+1 problem detected.`
      );
    }

    return result;
  } catch (error) {
    console.error(`[${name}] Error:`, error);
    throw error;
  }
}

この計測関数を使えば、N+1 問題を早期に発見できます。

typescript// 使用例
const posts = await measureQueryPerformance(
  'getBlogPosts',
  () => getBlogPostListOptimized()
);

クエリ回数が閾値を超えると警告が表示され、問題箇所を特定しやすくなります。

パフォーマンス比較

最後に、N+1 問題の有無によるパフォーマンスの違いを数値で比較してみましょう。

#シナリオクエリ回数実行時間(100 件)実行時間(1000 件)
1N+1 あり(未最適化)101 回850ms8500ms
2include 使用1 回45ms120ms
3DataLoader 使用2-3 回60ms150ms
4select+_count1 回25ms80ms

データ件数が増えるほど、最適化の効果が顕著になることが分かります。

まとめ

本記事では、Prisma における N+1 問題の根本的な解決方法をご紹介しました。重要なポイントを振り返りましょう。

まず、N+1 問題は「1 回の親データ取得 + N 回の子データ取得」というパターンで発生し、パフォーマンスに深刻な影響を与えます。特にループ内での findUniquefindFirst の呼び出しは要注意です。

解決策として、以下の 5 つの手法を状況に応じて使い分けることが重要です。include を使えば関連データを事前に一括取得でき、最もシンプルな解決策となります。select で必要なフィールドだけを取得すれば、ネットワーク転送量も削減できます。

複数の ID からデータを取得する場合は、バルクフェッチfindMany + in 演算子)が効果的です。GraphQL API などの複雑なシナリオでは、DataLoader パターンにより自動的なバッチ処理とキャッシングが実現できます。

統計情報だけが必要なら、集約関数_countgroupBy)を活用することで、不要なデータ転送を避けられます。

開発時は必ず Prisma のクエリログを有効化し、実行されるクエリの回数を監視してください。予期せぬ回数のクエリが発生していたら、それは N+1 問題のサインかもしれません。

N+1 問題は、適切な知識と対策があれば必ず解決できます。本記事でご紹介した手法を実践し、高速で効率的な Prisma アプリケーションを構築していきましょう。

関連リンク