Prisma Patterns
TypeScript 後端中 Prisma ORM 的生產模式與非顯而易見的陷阱。
在套用模式之前,請先檢查您的版本。 Prisma API 表面在主要版本之間有所演變:
npx prisma --version各版本間值得注意的 API 差異:
relationJoins可以透過 JOIN 而非個別查詢來載入關聯,但可能會導致大型 1:N 關聯或深度include時出現資料列爆炸 — 請對兩種方法進行基準測試- 新增了
omit欄位修飾符和prisma.$extends客戶端擴展 API- 較新的安裝:套件名稱可能為
prisma而非@prisma/client;PrismaClient可能需要驅動適配器(例如@prisma/adapter-pg);datasource.url可能位於prisma.config.ts而非schema.prisma- CLI 指令(
migrate dev、migrate deploy、generate)在各版本中保持不變
何時啟用
- 設計或修改 Prisma schema 模型與關聯
- 編寫查詢、交易或分頁邏輯
- 使用
updateMany、deleteMany或任何批量操作 - 執行或規劃資料庫遷移
- 部署至無伺服器環境(Vercel、Lambda、Cloudflare Workers)
- 實作軟刪除或多租戶資料列過濾
核心概念
ID 策略
| 策略 | 使用時機 | 避免使用時機 |
|---|---|---|
@default(cuid()) | 預設選擇 — URL 安全、可排序、無衝突 | 外部系統需要循序 ID |
@default(uuid()) | 需要與非 Prisma 系統互通 | 大量寫入的資料表(隨機 UUID 會碎片化 B-tree 索引) |
@default(autoincrement()) | 內部關聯表、稽核日誌 | 公開 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]) // 用於軟刪除 + 排序查詢的複合索引
}
- 在每個外鍵以及用於
WHERE或ORDER BY的欄位上新增@@index。 - 若可預見需要軟刪除,請預先宣告
deletedAt DateTime?— 稍後新增將需要在線上資料表上執行遷移。 updatedAt @updatedAt僅會由 Prisma 在update和upsert時自動設定(請參閱反模式中的批量更新陷阱)。
include vs select
include | select | |
|---|---|---|
| 回傳 | 所有純量欄位 + 指定的關聯 | 僅指定的欄位 |
| 使用時機 | 您需要大多數字段加上一個關聯 | 熱路徑、大型資料表、避免過度擷取 |
| 效能 | 在寬資料表上可能過度擷取 | 最小有效負載,大型資料集上更快 |
| 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 實體 — 請對應到回應 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 };
交易形式選擇
| 情境 | 使用 |
|---|---|
| 獨立操作,無相互依賴 | 陣列形式 |
| 後續步驟依賴先前的結果 | 互動形式 |
| 涉及外部呼叫(Email、HTTP) | 完全在交易外部 |
// 陣列形式 — 在一個往返中批次處理
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// 互動形式 — 僅使用 tx 客戶端,切勿使用外部 prisma 客戶端
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('不允許');
return tx.post.create({ data: { title, authorId: user.id } });
});
PrismaClient 單例
每個 PrismaClient 實例會開啟自己的連線池。請僅實例化一次。
// lib/prisma.ts
// 選項 A — 基於適配器的初始化(較新的 Prisma 安裝需要)
import { PrismaClient } from '@prisma/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 模式可防止在熱載入期間(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 集合上,這可能會增加結果集大小 — 如果關聯可以為每個父項回傳許多資料列,請對兩種方法進行基準測試。
程式碼範例
游標分頁(推薦用於動態消息和大型資料集)
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 並彈出 — 這是無需額外計數查詢即可偵測 hasNextPage 的標準方式。始終包含一個唯一欄位(例如 id)作為次要 orderBy,以防止當多個資料列共用相同時間戳記時分頁不穩定。僅在使用者需要跳轉到任意頁面(管理員表格)時使用偏移分頁。
軟刪除
// 始終明確過濾 — 不要依賴中介軟體(會隱藏行為,難以除錯)
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'; // 或根據您的設定使用產生的客戶端路徑
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 外鍵違反。
在服務邊界捕獲並轉換為領域錯誤。切勿向 API 消費者暴露原始的 Prisma 訊息。
連線池 — 無伺服器
將連線參數直接嵌入 DATABASE_URL 中 — 字串串接會在 URL 已有查詢參數時中斷(例如 ?schema=public):
# .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 及類似的無伺服器執行環境:
// 將池限制為每個實例 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();
反模式
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 秒預設值 → "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 漂移,並可能提示重置資料庫,從而清除所有資料。
# 絕不在共用開發、暫存或生產環境中使用
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"
手動編輯遷移檔案會破壞未來部署
Prisma 會對每個遷移檔案進行校驗。在套用後進行編輯會導致在原始檔案已執行的每個環境中出現 P3006 校驗和不匹配。請改為建立一個新的遷移。
破壞性 Schema 變更需要多步驟遷移
在一個遷移中為現有欄位新增 NOT NULL 或重新命名欄位會鎖定資料表或刪除資料。請使用擴展和收縮模式:
# 步驟 1:在本機建立遷移,然後部署
npx prisma migrate dev --name add_new_column # 僅本機
npx prisma migrate deploy # 暫存 / 生產
// 步驟 2:回填資料(在腳本或遷移作業中執行,而非在 shell 中)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# 步驟 3:在本機建立 NOT NULL 限制式遷移,然後部署
npx prisma migrate dev --name make_new_column_required # 僅本機
npx prisma migrate deploy # 暫存 / 生產
@updatedAt 不會在 updateMany 上觸發
@updatedAt 僅會在 update 和 upsert 時自動設定。批量寫入會使其保持失效。
// 不好:updatedAt 保持其舊值
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// 好
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
軟刪除 + findUniqueOrThrow 會洩漏已刪除的記錄
findUniqueOrThrow 僅在資料庫中不存在該資料列時才會拋出 P2025。軟刪除的資料列仍然存在,並且會無錯誤地回傳。
findUniqueOrThrow 需要在 where 中使用唯一限制式欄位 — 將 deletedAt: null 與 id 一起加入會中斷類型,因為 { id, deletedAt } 不是複合唯一限制式。請改用 findFirstOrThrow。
// 不好:回傳軟刪除的使用者
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// 不好:Prisma 類型錯誤 — { id, deletedAt } 不是唯一限制式
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// 好:findFirstOrThrow 支援任意的 where 條件
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
沒有 where 的 deleteMany 會刪除每一行
// 不好:靜默清空資料表
await prisma.post.deleteMany();
// 好
await prisma.post.deleteMany({ where: { authorId: userId } });
最佳實踐
| 規則 | 原因 |
|---|---|
在 CI/CD 中使用 migrate deploy,僅在本機使用 migrate dev | migrate dev 可能會在發生漂移時重置資料庫 |
| 將實體對應到回應 DTO | 防止洩漏內部欄位 |
…
[截斷以利翻譯]