Prisma 模式
用於 TypeScript 後端中 Prisma ORM 的生產模式與非直觀陷阱。
在套用模式之前,請先檢查您的 Prisma 版本。 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僅在update和upsert時由 Prisma 自動設定(關於批量更新的陷阱,請參閱反模式)。
include 與 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 };
事務形式選擇
| 情況 | 使用 |
|---|---|
| 獨立操作,無互相依賴 | 陣列形式 |
| 後續步驟依賴於先前結果 | 互動形式 |
| 涉及外部呼叫(電子郵件、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('Forbidden');
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 already exists');
if (e.code === 'P2025') throw new NotFoundError('Record not found');
if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist');
}
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 會對每個遷移檔案進行 checksum 檢查。套用後編輯會導致原始遷移已執行的每個環境出現 P3006 checksum mismatch。請改為建立新的遷移。
破壞性 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 中使用唯一約束欄位——在 id 旁邊加上 deletedAt: null 會破壞型別,因為 { 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 } });
deleteMany 不加 where 會刪除所有行
// 不好:默默清空資料表
await prisma.post.deleteMany();
// 好
await prisma.post.deleteMany({ where: { authorId: userId } });
最佳實踐
| 規則 | 原因 |
|---|---|
在 CI/CD 中使用 migrate deploy,僅在本機使用 migrate dev | migrate dev 在漂移時可能重置資料庫 |
| 將實體對應到回應 DTO | 防止洩漏內部欄位 |
…
[此處省略完整內容以節省空間,但翻譯應涵蓋原始 SKILL.md 的所有部分]