SJ blog
database
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

ORMの罠:Prisma・Drizzle・SQLAlchemyの落とし穴

ORM は便利だが、知らないと性能問題・予期せぬクエリ・N+1・デッドロックを引き起こす。Prisma・Drizzle(TypeScript)・SQLAlchemy(Python)の実際の落とし穴と対策を解説します。

一言結論

ORMの性能問題の9割はN+1クエリ・全カラム取得・発行SQLを把握していないことに起因し、includeで明示的なJOINを指定しORMのクエリログを常に確認する習慣だけで大半を防ぐことができる。

ORM の共通の落とし穴

落とし穴1: N+1 問題

// ❌ Prisma で N+1 を発生させる
const users = await prisma.user.findMany();
for (const user of users) {
  const posts = await prisma.post.findMany({ where: { userId: user.id } });
}
// → 1(users取得) + N(userごとのposts取得)本のクエリ

// ✅ include でJOINに変換する
const users = await prisma.user.findMany({
  include: { posts: true },
});
// → 1〜2本のクエリで完了

落とし穴2: 必要以上のカラムを取得

// ❌ 全カラム取得(100MB のblobカラムも含まれる場合も)
const users = await prisma.user.findMany();

// ✅ 必要なカラムだけを select
const users = await prisma.user.findMany({
  select: { id: true, name: true, email: true },
});

落とし穴3: ORM が発行する SQL を把握していない

// Prisma でログを有効にする
const prisma = new PrismaClient({
  log: ["query", "info", "warn", "error"],
});

// または特定のクエリだけ確認
const users = await prisma.user.findMany().$queryRawUnsafe("..."); // ← 使い方間違い
// EXPLAIN ANALYZE で確認

Prisma の固有の落とし穴

大量データの取得

// ❌ 大量データを一度に取得するとメモリ枯渇
const allLogs = await prisma.log.findMany(); // 100万件を一気に取得

// ✅ カーソルベースのページング
const PAGE_SIZE = 1000;
let cursor: number | undefined;

while (true) {
  const logs = await prisma.log.findMany({
    take: PAGE_SIZE,
    skip: cursor ? 1 : 0,
    cursor: cursor ? { id: cursor } : undefined,
    orderBy: { id: "asc" },
  });

  if (logs.length === 0) break;

  await processLogs(logs);
  cursor = logs[logs.length - 1].id;
}

トランザクションとデッドロック

// ❌ ネストしたトランザクションで意図せずデッドロック
await prisma.$transaction(async (trx) => {
  await trx.order.update({ where: { id: 1 }, data: { status: "processing" } });
  await trx.inventory.update({ where: { id: 1 }, data: { count: { decrement: 1 } } });
});

// ✅ 更新順序を統一する(デッドロックを防ぐ)
// 常に「order → inventory」の順で更新するルールを決める

Prisma の connectcreate の使い分け

// ❌ 間違い: 存在しない ID で connect するとエラー
await prisma.post.create({
  data: {
    title: "記事",
    author: { connect: { id: 999 } }, // 存在しない ID
  },
});

// ✅ 事前に存在確認するか、upsert を使う

Drizzle ORM の落とし穴

型安全でも SQL を理解する必要がある

import { eq, and, inArray } from "drizzle-orm";

// ❌ 間違いやすいクエリ(WHERE なしで全件更新)
await db.update(users).set({ role: "admin" }); // ← WHERE を書き忘れた!

// ✅ WHERE を明示する
await db.update(users).set({ role: "admin" }).where(eq(users.id, userId));

// IN クエリ
await db.select().from(users).where(inArray(users.id, [1, 2, 3]));

Drizzle の JOIN

// ✅ Drizzle での JOIN
const result = await db
  .select({
    postId: posts.id,
    postTitle: posts.title,
    authorName: users.name,
  })
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.isPublished, true))
  .orderBy(desc(posts.createdAt))
  .limit(10);

SQLAlchemy の落とし穴

Lazy Loading が N+1 を引き起こす

# ❌ lazy loading (デフォルト) で N+1
users = session.query(User).all()
for user in users:
    print(user.posts)  # ここで user ごとにクエリが発行される

# ✅ joinedload でまとめて取得
from sqlalchemy.orm import joinedload

users = session.query(User).options(joinedload(User.posts)).all()

Session の使い方

# ❌ セッションを閉じ忘れる
session = Session()
user = session.query(User).first()
# session を閉じ忘れると接続プールが枯渇

# ✅ context manager を使う
with Session() as session:
    user = session.query(User).first()
# with ブロックを出ると自動でクローズ

まとめ

問題ORM対策
N+1全てinclude / joinedload / eager loading
過剰取得全てselect で必要カラムだけ指定
SQL 把握全てログを有効化・EXPLAIN で確認
大量データPrismaカーソルベースのページング
Lazy LoadingSQLAlchemyjoinedload・selectinload を使う

ORM は SQL を隠蔽しますが、発行される SQL を理解した上で使うことが性能問題を防ぐ最善策です。


参考: Prisma 公式 / Drizzle 公式 / SQLAlchemy 公式