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[]
}
ユーザーモデルには posts と comments というリレーションフィールドが定義されています。このフィールドにより、ユーザーに関連する投稿やコメントを簡単に取得できます。
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 でデータを取得したい場合は、findMany と in 演算子を組み合わせます。
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 に追加 |
| 3 | map 内で 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 件) |
|---|---|---|---|---|
| 1 | N+1 あり(未最適化) | 101 回 | 850ms | 8500ms |
| 2 | include 使用 | 1 回 | 45ms | 120ms |
| 3 | DataLoader 使用 | 2-3 回 | 60ms | 150ms |
| 4 | select+_count | 1 回 | 25ms | 80ms |
データ件数が増えるほど、最適化の効果が顕著になることが分かります。
まとめ
本記事では、Prisma における N+1 問題の根本的な解決方法をご紹介しました。重要なポイントを振り返りましょう。
まず、N+1 問題は「1 回の親データ取得 + N 回の子データ取得」というパターンで発生し、パフォーマンスに深刻な影響を与えます。特にループ内での findUnique や findFirst の呼び出しは要注意です。
解決策として、以下の 5 つの手法を状況に応じて使い分けることが重要です。include を使えば関連データを事前に一括取得でき、最もシンプルな解決策となります。select で必要なフィールドだけを取得すれば、ネットワーク転送量も削減できます。
複数の ID からデータを取得する場合は、バルクフェッチ(findMany + in 演算子)が効果的です。GraphQL API などの複雑なシナリオでは、DataLoader パターンにより自動的なバッチ処理とキャッシングが実現できます。
統計情報だけが必要なら、集約関数(_count、groupBy)を活用することで、不要なデータ転送を避けられます。
開発時は必ず Prisma のクエリログを有効化し、実行されるクエリの回数を監視してください。予期せぬ回数のクエリが発生していたら、それは N+1 問題のサインかもしれません。
N+1 問題は、適切な知識と対策があれば必ず解決できます。本記事でご紹介した手法を実践し、高速で効率的な Prisma アプリケーションを構築していきましょう。
関連リンク
articlePrisma N+1 を根絶するデータ取得設計:バルクフェッチ/DataLoader/集約の勘所
articlePrisma リレーション設計早見表:1-N/N-N/自己参照/循環参照の書き方と注意点
articlePrisma を Monorepo で使い倒す:パス解決・generate の共有・依存戦略
articlePrisma Accelerate と PgBouncer を比較:サーバレス時代の接続戦略ベンチ
articlePrisma で「Cannot find module '@prisma/client'」が出る時の復旧チェックリスト
articlePrisma 読み書き分離設計:読み取りレプリカ/プロキシ/整合性モデルを整理
articleSolidJS コンポーネント間通信チート:Context・イベント・store の選択早見
articleWebLLM 中心のクライアントサイド RAG 設計:IndexedDB とベクトル検索の組み立て
articleShell Script の set -e が招く事故を回避:pipefail・サブシェル・条件分岐の落とし穴
articleRuby の本番運用ガイド:ログ設計・メトリクス・トレースのベストプラクティス
articleVitest テストデータ設計技術:Factory / Builder / Fixture の責務分離と再利用
articleRedis キーネーミング規約チートシート:階層・区切り・TTL ルール
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来