適用於 TypeScript 後端的 Prisma ORM 設計模式 — 涵蓋 Schema 設計、查詢最佳化、事務處理(Transaction)、分頁機制,以及關鍵陷阱解析(如 updateMany 僅返回計數而非記錄筆數、$transaction 超時、migrate dev 重置資料庫、批次寫入時跳過 @updatedAt,以及 Serverless 環境下的連線耗盡問題)。
Prisma Patterns
適用於 TypeScript 後端開發中 Prisma ORM 的正式生產環境模式與非直覺陷阱解析。
在套用模式前,請先確認您的 Prisma 版本。 Prisma 的 API 在各大主要版本間有所演進:
npx prisma --version各版本間顯著的 API 差異:
relationJoins可透過 JOIN 載入關聯資料,而非發送獨立查詢,但在大型 1:N 關聯或深層include時可能會引發資料列暴增 — 請對兩種方式進行效能基準測試- 新增了
omit欄位修飾符與prisma.$extendsClient Extensions API- 較新的安裝版本:套件名稱可能改為
prisma而非@prisma/client;PrismaClient可能需要驅動轉接器(例如@prisma/adapter-pg);datasource.url可能基於prisma.config.ts而非schema.prisma- CLI 命令(
migrate dev、migrate deploy、generate)在各版本間保持一致
何時啟用此 Skill
- 設計或修改 Prisma schema 模型(Model)與關聯(Relation)時
- 撰寫查詢、事務處理或分頁邏輯時
- 使用
updateMany、deleteMany或任何批次操作時 - 執行或規劃資料庫 Migration 時
- 部署至 Serverless 無伺服器環境(Vercel、AWS Lambda、Cloudflare Workers)時
- 實作軟刪除(Soft Delete)或多租戶列級過濾時
核心概念
ID 策略
| 策略 | 適用時機 | 避免時機 |
|---|---|---|
@default(cuid()) |
預設首選 — URL 安全、可排序、無碰撞風險 | 外部系統需要連續型 ID 時 |
@default(uuid()) |
需要與非 Prisma 系統相互整合時 | 高寫入量的資料表(隨機 UUID 會導致 B-tree 索引碎片化) |
@default(autoincrement()) |
內部 Join 表、稽核紀錄(Audit log) | 對外公開的 ID(會暴露總記錄筆數) |
Schema 預設設定
model User {
id String @id @default(cuid())
email String @unique // @unique 已會自動建立索引 — 無需再寫 @@index
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // 適用於軟刪除 + 排序查詢的複合索引
}
- 在每個外鍵(Foreign key)以及常用於
WHERE或ORDER BY的欄位上加上@@index。 - 若預期未來需要軟刪除,請預先宣告
deletedAt DateTime?— 日後才補加需要在線上資料庫執行 Migration。 updatedAt @updatedAt僅會在update與upsert時由 Prisma 自動更新(批次更新的陷阱請參閱反模式章節)。
include vs select
include |
select |
|
|---|---|---|
| 返回內容 | 所有純量欄位 + 指定的關聯 | 僅指定的欄位 |
| 適用時機 | 需要大部分欄位加上關聯資料時 | 高頻熱點路徑、大型資料表、避免過度擷取資料 |
| 效能影響 | 在寬表(欄位多)上可能過度擷取 | 最小化 Payload 傳輸量,在大型資料集上速度更快 |
| Prisma 5 說明 | 預設使用 JOIN (relationJoins) |
相同 |
// include — 所有欄位 + 關聯資料
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — 明確白名單
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
切勿直接在 API 回應中返回原始 Prisma Entity — 請映射至回應 DTO 以精確控管曝露欄位:
// 錯誤做法:洩漏了 passwordHash、deletedAt 及內部欄位
return await prisma.user.findUniqueOrThrow({ where: { id } });
// 正確做法:明確的 DTO 映射
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
事務(Transaction)形式選擇
| 情境 | 採用形式 |
|---|---|
| 彼此獨立、無相互依賴的操作 | 陣列形式 (Array form) |
| 後續步驟依賴前述結果 | 互動式形式 (Interactive form) |
| 涉及外部呼叫(寄信、HTTP 請求等) | 完全放在事務處理之外 |
// 陣列形式 — 在一次 Round trip 中批次執行
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// 互動式形式 — 僅能使用 tx client,絕不可使用外層的 prisma client
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Forbidden');
return tx.post.create({ data: { title, authorId: user.id } });
});
PrismaClient 單例模式(Singleton)
每個 PrismaClient 實例都會開啟自己的連線池。請確保全域僅實例化一次。
// lib/prisma.ts
// 選項 A — 基於轉接器(Adapter)的初始化(較新的 Prisma 安裝版本需要)
import { PrismaClient } from '@prisma/client'; // 或您專案產生的 client 路徑
import { PrismaPg } from '@prisma/adapter-pg';
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
}
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// 選項 B — 直接初始化(舊版安裝,無需轉接器)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
若您的 Prisma 安裝需要在 PrismaClient 建構函式中傳入 adapter 引數,請使用選項 A。
若 new PrismaClient() 無需參數即可運作,請使用選項 B。讓編譯器提示您何者正確。
使用 globalThis 模式可防止在熱重載(Hot reload,如 Next.js、nodemon、ts-node-dev)期間建立重複的實例。
N+1 效能問題
在迴圈中載入關聯資料會針對每一筆資料額外發送一次查詢。
// 錯誤做法:N+1 問題 — 每位使用者額外發送一次查詢
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// 正確做法:單一查詢
const users = await prisma.user.findMany({ include: { posts: true } });
在 Prisma 5+ 搭配 relationJoins 時,include 形式會使用單一 JOIN。在大型 1:N 資料集中這可能會增加結果集大小 — 若該關聯可能為每個父項返回許多列,請對兩種方式進行基準測試。
程式碼範例
游標分頁(Cursor Pagination,動態消息與大型資料集首選)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // 次要排序可防止相同的時間戳記導致分頁順序不穩定
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}
擷取 limit + 1 筆並執行 pop() — 這是無須額外發送 Count 查詢即可判斷 hasNextPage 的標準做法。排序時務必包含唯一欄位(如 id)作為次要 orderBy,以防多筆資料共享相同時間戳記時出現不穩定的分頁結果。僅在使用者需要跳轉至任意頁碼時(如後台管理表單)才使用 Offset 分頁。
軟刪除(Soft Delete)
// 務必明確進行過濾 — 不要依賴 Middleware(會隱藏行為且難以除錯)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // 復原
錯誤處理
import { Prisma } from '@prisma/client'; // 或您專案產生的 client 路徑
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('Email 已經存在');
if (e.code === 'P2025') throw new NotFoundError('找不到該筆記錄');
if (e.code === 'P2003') throw new BadRequestError('參照的記錄不存在');
}
throw e;
}
常見代碼:P2002 唯一性違規 · P2025 查無記錄 · P2003 外鍵違規。
請在服務邊界(Service boundary)捕捉並轉譯為領域錯誤(Domain error)。絕不要將原始 Prisma 錯誤訊息直接曝露給 API 消費者。
連線池 — Serverless 環境
直接將連線參數嵌入至 DATABASE_URL 中 — 若 URL 已包含查詢參數(例如 ?schema=public),直接用字串串接會破壞 URL 格式:
# .env — 首選:將參數嵌入於 URL 中
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# 搭配外部連線池代理(如 PgBouncer、Supabase pooler)
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel、AWS Lambda 及類似的 Serverless 執行環境:
// 將每個實例的連線池上限限制為 1;connection_limit 與 pool_timeout 透過 DATABASE_URL 控制
// 基於轉接器的設定(若您的 Prisma 安裝需要轉接器):
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// 直接設定(若您的 Prisma 安裝不需要轉接器):
// const prisma = new PrismaClient();
反模式(Anti-Patterns)
updateMany 返回的是計數,而非資料記錄
// 錯誤做法:結果為 { count: 2 } — users[0] 會是 undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// 正確做法:先取得 ID,再執行更新,最後僅擷取受影響的列
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
deleteMany 同理 — 僅會返回 { count: n },絕不會返回被刪除的資料記錄。
$transaction 互動式形式在 5 秒後超時
// 錯誤做法:在事務內部進行外部呼叫超過預設 5 秒 limit → 拋出 "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // 外部呼叫
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// 正確做法:將外部呼叫移至事務處理之外
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// 僅在批次處理確實有需要時調高超時時間
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev 可能會重置資料庫
migrate dev 會偵測 Schema 偏離(Drift),並可能提示重置資料庫,導致所有資料被清除。
# 絕對不可在共享的開發、Staging 或 Production 環境執行
npx prisma migrate dev --name add_column
# 除了本機個人開發外,其他環境皆可安全使用
npx prisma migrate deploy
# 僅檢查偏離而不套用
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
手動編輯 Migration 檔案會破壞未來的部署
Prisma 會對每個 Migration 檔案進行 Checksum 校驗。套用後若再修改,會導致已執行過舊檔的每個環境皆拋出 P3006 checksum mismatch。請改為建立新的 Migration。
破壞性的 Schema 變更需要多步驟 Migration
在現有欄位加上 NOT NULL 或重新命名欄位時……






