prisma-patterns

prisma-patterns

熱門

適用於 TypeScript 後端的 Prisma ORM 設計模式 — 涵蓋 Schema 設計、查詢最佳化、事務處理(Transaction)、分頁機制,以及關鍵陷阱解析(如 updateMany 僅返回計數而非記錄筆數、$transaction 超時、migrate dev 重置資料庫、批次寫入時跳過 @updatedAt,以及 Serverless 環境下的連線耗盡問題)。

24萬星標
3.6萬分支
更新於 2026/8/6
SKILL.md
唯讀
名稱
prisma-patterns
描述

適用於 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.$extends Client Extensions API
  • 較新的安裝版本:套件名稱可能改為 prisma 而非 @prisma/clientPrismaClient 可能需要驅動轉接器(例如 @prisma/adapter-pg);datasource.url 可能基於 prisma.config.ts 而非 schema.prisma
  • CLI 命令(migrate devmigrate deploygenerate)在各版本間保持一致

何時啟用此 Skill

  • 設計或修改 Prisma schema 模型(Model)與關聯(Relation)時
  • 撰寫查詢、事務處理或分頁邏輯時
  • 使用 updateManydeleteMany 或任何批次操作時
  • 執行或規劃資料庫 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)以及常用於 WHEREORDER BY 的欄位上加上 @@index
  • 若預期未來需要軟刪除,請預先宣告 deletedAt DateTime? — 日後才補加需要在線上資料庫執行 Migration。
  • updatedAt @updatedAt 僅會在 updateupsert 時由 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 或重新命名欄位時……