
僕が 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 のドキュメントを隅から隅まで読み返すことにした。特にリレーションの取得に関する部分を。そこで、光り輝くような単語を見つけた。