Loading post
Jun 29, 2026
.png)
アプリが遅いとき、原因はいつも複雑なアルゴリズムやサーバー負荷とは限りません。よくある原因の一つが、何気ないループです。
N+1問題とは、最初に一覧データを1回取得し、その後で一覧の各要素ごとに追加のDBクエリを実行してしまう問題です。
簡単に言うと、こういうことです。
| 処理 | クエリ数 |
|---|---|
| メインの一覧を取得する | 1 |
| 各要素の関連データを取得する | N |
| 合計 | N+1 |
だからN+1問題と呼ばれます。
ブログの一覧ページで、記事タイトルと著者名を表示するとします。
例えば、次のようなコードを書いたとします。
const posts = await db.post.findMany();
for (const post of posts) {
const author = await db.user.findUnique({
where: { id: post.authorId },
});
console.log(post.title, author?.name);
}
読みやすいコードに見えますが、ここにパフォーマンス問題が隠れています。
記事が10件なら、クエリは11回です。
1回 -> 記事一覧を取得
10回 -> 各記事の著者を取得
合計11回
記事が100件なら、クエリは101回になります。
1回 -> 記事一覧を取得
100回 -> 各記事の著者を取得
合計101回
ページは正しく動きます。しかし、DBには不要な負荷がかかっています。
アプリからDBには、次のようなSQLが送られているかもしれません。
SELECT * FROM posts LIMIT 100;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- 記事の数だけ繰り返される
多くの場合、関連データはまとめて取得できます。
SELECT
posts.id,
posts.title,
users.name AS author_name
FROM posts
JOIN users ON users.id = posts.author_id
LIMIT 100;
これなら、記事と著者名を1回のクエリで取得できます。
N+1問題が厄介なのは、開発環境では問題に見えにくいことです。
ローカルDBに5件しかデータがなければ、ページは一瞬で表示されるかもしれません。しかし本番では、500件のデータ、ネットワーク遅延、混雑したDBが相手になります。
| 記事数 | N+1の場合のクエリ数 | eager loading / join / batchの場合 |
|---|---|---|
| 5 | 6 | 1または2 |
| 50 | 51 | 1または2 |
| 500 | 501 | 1または2 |
| 5,000 | 5,001 | 1または2 |
1回ごとのクエリが速くても、数が増えると積み上がります。DB接続数、DBのCPU、ネットワーク往復、レスポンスの遅いリクエストにも影響します。
ここで大事なのは、N+1問題は普通のループ処理そのものの話ではない、ということです。
例えば、すでにメモリ上にある配列を map するだけなら、N+1問題ではありません。
const posts = [
{ title: "Post A", authorName: "Yudai" },
{ title: "Post B", authorName: "Alex" },
];
const result = posts.map((post) => ({
title: post.title,
authorName: post.authorName,
}));
これはただの配列処理です。DBにもAPIにもアクセスしていません。
N+1が問題になるのは、ループの中で外部のデータソースにアクセスするときです。
名前としては「N+1 query problem」と呼ばれるのでDBの文脈が多いですが、構造としてはAPIでも起きます。
const posts = await fetchPosts();
for (const post of posts) {
// 記事の数だけHTTPリクエストが増える
const author = await fetchAuthor(post.authorId);
console.log(post.title, author.name);
}
つまり、悪いのは for や map ではありません。ループの中でDBやAPIを呼ぶことが問題です。
N+1問題の対策は、難しいアルゴリズムではありません。
基本はこれです。
関連データを、ループの中で1件ずつ取りに行かない。
悪い形はこうです。
const posts = await db.post.findMany();
const result = [];
for (const post of posts) {
const author = await db.user.findUnique({
where: { id: post.authorId },
});
result.push({
title: post.title,
authorName: author?.name,
});
}
問題は for の中でDBを呼んでいることです。記事が100件なら、著者取得も100回走ります。
良い形は、関連データを先にまとめて取ることです。
const posts = await db.post.findMany({
include: {
author: true,
},
});
const result = posts.map((post) => ({
title: post.title,
authorName: post.author.name,
}));
このコードでは、ループや map の中でDBを呼んでいません。すでに posts の中に author が入っているからです。
イメージとしては、返ってくるデータの形が変わります。
include なしだと、記事には authorId しかありません。
[
{
id: "post_1",
title: "N+1 Problem",
authorId: "user_1",
},
];
include: { author: true } を付けると、著者オブジェクトも一緒に入ってきます。
[
{
id: "post_1",
title: "N+1 Problem",
authorId: "user_1",
author: {
id: "user_1",
name: "Yudai",
},
},
];
だから、次の post.author.name はDBアクセスではありません。
const result = posts.map((post) => ({
title: post.title,
authorName: post.author.name,
}));
これは、すでにメモリ上にあるオブジェクトを読んでいるだけです。DBアクセスは、その前の findMany({ include: ... }) でまとめて終わっています。
ただし、フレームワークによっては post.author を読んだ瞬間に裏でDBを取りに行くものもあります。RailsやDjangoのように遅延読み込みがあるORMでは、そこでN+1が起きやすいです。だから includes や select_related で「先に取っておく」必要があります。
author: true の true は何を意味するのかPrismaのこの書き方は、最初は少し分かりにくいです。
const posts = await db.post.findMany({
include: {
author: true,
},
});
ここでの true は「authorが真である」という意味ではありません。
意味はこうです。
include: {
author: true, // author relation を結果に含める
}
つまり、author という関連データを一緒に返してほしい、という指定です。
include を使うと、基本的にはPost本体のフィールドに加えて、指定したrelationが追加されます。
{
id: "post_1",
title: "N+1 Problem",
authorId: "user_1",
author: {
id: "user_1",
name: "Yudai",
email: "yudai@example.com"
}
}
もし著者の全フィールドがいらないなら、true ではなく select で必要なフィールドだけ選べます。
const posts = await db.post.findMany({
include: {
author: {
select: {
id: true,
name: true,
},
},
},
});
この場合、返ってくる author は id と name だけです。
{
title: "N+1 Problem",
author: {
id: "user_1",
name: "Yudai"
}
}
Post本体も含めて必要なフィールドだけに絞りたいなら、トップレベルで select を使います。
const posts = await db.post.findMany({
select: {
title: true,
author: {
select: {
name: true,
},
},
},
});
このコードは、「記事タイトルと著者名だけほしい」という意味です。
関連データが複数ある場合も、考え方は同じです。
例えば、記事一覧で次の情報を出したいとします。
単純に書くと、こういうN+1になりがちです。
const posts = await db.post.findMany();
for (const post of posts) {
const author = await db.user.findUnique({ where: { id: post.authorId } });
const category = await db.category.findUnique({ where: { id: post.categoryId } });
const comments = await db.comment.findMany({ where: { postId: post.id } });
const tags = await db.tag.findMany({ where: { posts: { some: { id: post.id } } } });
console.log(post.title, author?.name, category?.name, comments.length, tags);
}
記事が100件なら、関連データごとにクエリが増えます。最悪の場合、1 + 100 * 4 = 401 回のような形になります。
Prismaなら、必要なrelationをまとめて指定できます。
const posts = await db.post.findMany({
include: {
author: {
select: { id: true, name: true },
},
category: {
select: { id: true, name: true },
},
comments: {
select: { id: true },
},
tags: {
select: { id: true, name: true },
},
},
});
const result = posts.map((post) => ({
title: post.title,
authorName: post.author.name,
categoryName: post.category.name,
commentCount: post.comments.length,
tagNames: post.tags.map((tag) => tag.name),
}));
ここでも map の中でDBを呼んでいません。すでに必要なデータが揃っているので、メモリ上で整形しているだけです。
ただし、何でも include すればよいわけではありません。コメント本文、コメント投稿者、いいね、添付ファイルのような「1件の記事に大量にぶら下がるデータ」を全部含めると、返すデータ量が大きくなりすぎます。
例えば一覧ページでは、コメント本文を全部返すより、コメント数だけ返す方が自然です。
const posts = await db.post.findMany({
select: {
title: true,
author: {
select: { name: true },
},
_count: {
select: { comments: true },
},
},
});
一覧ページでは軽いデータだけ、詳細ページでは必要なデータを深く読む、という分け方が実務ではよく使われます。
N+1は、CPUの計算量だけでなく「外部I/Oの回数」で考えると分かりやすいです。
| 実装 | DB/API呼び出し回数 | メモリ上の処理 | コメント |
|---|---|---|---|
| ループ内で1件ずつ取得 | 1 + N | O(N) | N+1。件数に比例して外部I/Oが増える |
Promise.all で並列取得 | 1 + N | O(N) | 待ち時間は減るかもしれないが、呼び出し回数は同じ |
include / join | だいたい 1 | O(N) | relationをまとめて取得する |
WHERE id IN (...) でbatch | だいたい 2 | O(N + U) | U は重複を除いた関連ID数 |
| relationが複数ある | 1 から 1 + R 程度 | O(N + 関連行数) | R はrelation数。ORMの実装による |
ここで重要なのは、map 自体の O(N) は普通だということです。問題は、map や for の中でDB/API呼び出しが走り、外部I/Oまで O(N) になることです。
また、JOINで何でも1回にすれば常に速いわけではありません。1対多のrelationを深くJOINしすぎると、同じPost情報が何度も重複して返り、データ量が膨らみます。
だから実務では、次のように考えます。
include やJOINで一緒に取る。WHERE id IN (...) でbatchする。ORMという言葉が分かりにくければ、まずは「DBアクセスを楽に書くためのフレームワーク機能」くらいに考えて大丈夫です。
例えば、Prisma、Rails Active Record、Django ORM、Laravel Eloquent は、コードで書いた post.author や include: { author: true } をSQLに変換します。
N+1が起きるとき、フレームワークはだいたい次のように動いています。
対策したコードでは、フレームワークに「最初からauthorも必要」と伝えます。
フレームワークごとの代表的な書き方は次の通りです。
| フレームワーク | N+1を防ぐ書き方 | 何をしているか |
|---|---|---|
| Prisma | include: { author: true } | 関連するauthorも一緒に取得する |
| Rails Active Record | Post.includes(:author) | authorを事前に読み込む |
| Django | Post.objects.select_related("author") | JOINでauthorを一緒に取る |
| Laravel Eloquent | Post::with('author')->get() | authorを事前に読み込む |
細かいSQLはフレームワークによって違います。JOINを使うこともあれば、2回のクエリに分けて WHERE id IN (...) でまとめて取ることもあります。
大事なのは、100件の親データに対して100回の子データ取得をしないことです。
なお、Promise.all で並列化しても根本解決にはなりません。
const posts = await db.post.findMany();
const result = await Promise.all(
posts.map(async (post) => {
const author = await db.user.findUnique({
where: { id: post.authorId },
});
return {
title: post.title,
authorName: author?.name,
};
}),
);
これは待ち時間を短くする可能性はありますが、クエリ数は減っていません。100件なら100回著者を取りに行くままです。N+1を防ぐには、並列化ではなく「まとめて取得」に変える必要があります。
「ループの中でDB/APIを呼ばない」は、主に一覧表示や読み取りAPIでの原則です。
現実には、ループ内の外部I/Oが必要なケースもあります。
例えば次のような場合です。
この場合でも、無制限に Promise.all するのは危険です。
// 危険: 1000件なら1000リクエストを一気に投げる
await Promise.all(
users.map((user) => sendWelcomeEmail(user.email)),
);
外部APIのrate limitに当たったり、自分のDB接続を使い切ったりします。
必要な場合は、次の順番で考えます。
await db.user.updateMany({
where: { plan: "trial" },
data: { plan: "expired" },
});
1件ずつ update するより、DBにまとめて命令する方が自然です。
const users = await db.user.findMany({
where: {
id: { in: userIds },
},
});
読み取りなら、まず WHERE id IN (...) を検討します。
const chunkSize = 50;
for (let i = 0; i < users.length; i += chunkSize) {
const chunk = users.slice(i, i + chunkSize);
await Promise.all(
chunk.map((user) => sendWelcomeEmail(user.email)),
);
}
これはN+1を完全に消す方法ではありません。ただし、一気に1000件投げるよりは、負荷とrate limitを制御できます。
ユーザーへのメール送信、画像変換、外部サービス同期のような処理は、リクエスト中に全部終わらせない方がよいことがあります。
for (const user of users) {
await jobQueue.enqueue("sendWelcomeEmail", {
userId: user.id,
});
}
APIレスポンスを早く返し、重い処理はワーカーで処理します。
まとめると、ループ内I/Oが必要なときの判断はこうです。
| 状況 | 対応 |
|---|---|
| 一覧表示で関連データを読む | include、JOIN、batch loading |
| 同じ種類のデータをIDで読む | WHERE id IN (...) |
| 外部APIにbatch endpointがある | batch endpointを使う |
| 外部APIにbatch endpointがない | チャンク、並列数制限、retry、rate limit対応 |
| メールや決済などの副作用 | queueやbackground job |
| 大量データ処理 | pagination、streaming、chunk processing |
つまり、「ループの中でDB/APIを呼んではいけない」というより、正確には 一覧の件数に比例して外部I/Oが無制限に増える設計を避ける ということです。
多くのORMには、関連データを事前に読み込む仕組みがあります。
Prismaなら include を使えます。
const posts = await db.post.findMany({
include: {
author: true,
},
});
for (const post of posts) {
console.log(post.title, post.author.name);
}
Rails Active Recordなら includes を使えます。
posts = Post.includes(:author).limit(100)
posts.each do |post|
puts "#{post.title} - #{post.author.name}"
end
Djangoなら、外部キーには select_related が使えます。
posts = Post.objects.select_related("author")[:100]
for post in posts:
print(post.title, post.author.name)
Laravel Eloquentなら with を使えます。
$posts = Post::with('author')->limit(100)->get();
foreach ($posts as $post) {
echo "{$post->title} - {$post->author->name}";
}
書き方はフレームワークごとに違いますが、考え方は同じです。ループに入る前に、必要な関連データをORMへ伝えます。
必ずしもjoinが最適とは限りません。まず記事を取得し、その記事で必要な著者だけを2回目のクエリでまとめて取る方法もあります。
const posts = await db.post.findMany();
const authorIds = [...new Set(posts.map((post) => post.authorId))];
const authors = await db.user.findMany({
where: {
id: { in: authorIds },
},
});
const authorById = new Map(authors.map((author) => [author.id, author]));
for (const post of posts) {
const author = authorById.get(post.authorId);
console.log(post.title, author?.name);
}
この場合、クエリは2回です。
1回 -> 記事一覧を取得
1回 -> 必要な著者をまとめて取得
合計2回
このパターンは、GraphQL resolver、API合成レイヤー、サービス間通信でもよく使われます。
GraphQLでは、各フィールドのresolverが個別にデータを取りに行くため、N+1が起きやすいです。
問題になりやすいresolverの例です。
const resolvers = {
Post: {
author: async (post, _args, context) => {
return context.db.user.findUnique({
where: { id: post.authorId },
});
},
},
};
100件の記事に対して著者情報を要求すると、author resolverが100回DBを呼ぶ可能性があります。
よく使われる対策がDataLoaderです。
import DataLoader from "dataloader";
function createAuthorLoader(db) {
return new DataLoader(async (authorIds: readonly string[]) => {
const authors = await db.user.findMany({
where: {
id: { in: [...authorIds] },
},
});
const authorById = new Map(authors.map((author) => [author.id, author]));
return authorIds.map((id) => authorById.get(id) ?? null);
});
}
const resolvers = {
Post: {
author: (post, _args, context) => {
return context.loaders.author.load(post.authorId);
},
},
};
DataLoaderは、小さな読み込みをまとめて、少ない回数の読み込みに変換してくれます。
N+1は、クエリログ、トレーシング、パフォーマンスプロファイルを見ると見つけやすいです。
見るべきサインは次の通りです。
| サイン | よくある意味 |
|---|---|
| 同じSQLがIDだけ変えて何度も実行されている | 関連レコードを1件ずつ読んでいる |
| 行数が増えるほどページが遅くなる | クエリ数が件数に比例して増えている |
| 1つのAPIリクエストで大量のDB呼び出しがある | ループやresolverの中でDBを呼んでいる |
| GraphQLのfield resolverが直接DBを呼ぶ | batch loadingが不足しているかもしれない |
コードレビューでは、次の形を見たら注意した方がいいです。
const items = await loadItems();
for (const item of items) {
await loadSomethingRelated(item.id);
}
これが常に悪いわけではありません。ただし、join、include、batch loadingで置き換えられないかは確認するべきです。
対策はいつも「全部joinすればいい」ではありません。
巨大なjoinは、データの重複、不要なカラムの取得、ページネーションの複雑化を招くことがあります。複雑な1クエリより、目的がはっきりした2クエリの方がよい場合もあります。
実務では、次のように考えると判断しやすいです。
| 状況 | 向いている選択肢 |
|---|---|
| シンプルな1対1、または多対1の関連 | joinまたはeager loading |
| 親1件に対して子が大量にある | preload、batch、pagination |
| GraphQLのfield resolver | DataLoaderのようなbatching |
| 安定したデータを繰り返し読む | クエリ形状を直した後にcache |
N+1は「気をつける」だけでは防ぎにくいです。クエリ数を見えるようにするのが一番確実です。
Prismaなら、開発時にクエリログを出せます。
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: ["query", "warn", "error"],
});
一覧ページを開いたときに、同じような SELECT ... WHERE id = ... が何十回も出ていたらN+1を疑います。
Railsなら開発ログにSQLが出ます。さらに実務では、N+1を検知するために bullet gem がよく使われます。
Djangoなら開発時にDjango Debug Toolbarを使うと、1リクエストで実行されたSQL一覧と件数を確認できます。
重要な一覧APIやページでは、クエリ数が増えすぎないことをテストできます。
Djangoの例です。
def test_post_list_does_not_have_n_plus_one(self):
with self.assertNumQueries(2):
posts = list(Post.objects.select_related("author")[:100])
for post in posts:
_ = post.author.name
Railsでも、プロジェクトによってはクエリ数を検証するテストを書けます。
assert_queries(2) do
posts = Post.includes(:author).limit(100).to_a
posts.each { |post| post.author.name }
end
ポイントは、データが10件でも100件でもクエリ数がほぼ増えない状態にすることです。
次のようなコードを見たら、まずN+1を疑います。
for (const item of items) {
await db.someModel.findUnique({
where: { id: item.someId },
});
}
レビューでは、次のどれかにできないか確認します。
include、with、includes、select_related で最初から読み込む。WHERE id IN (...) でまとめて取る。一覧ページやAPIを出す前に、次を確認するとよいです。
include、preload、select_related、prefetch_related のような機能があるか。WHERE id IN (...) にできないか。N+1問題はシンプルです。
一覧を1回取得した後、関連データを1件ずつ追加取得してしまう問題。
問題なのは、1つのリクエストが大量のDB往復に変わってしまうことです。対策は多くの場合、eager loading、join、batch loading、paginationです。
大事なのは、コードを読むだけでなく、実際にクエリ数を数えることです。データ件数に応じてDB呼び出し回数が増えているなら、N+1問題を疑うべきです。