Prisma 模式
用于 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 树索引) |
@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 与 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;通过 DATABASE_URL 控制 connection_limit 和 pool_timeout
// 基于适配器的设置(如果您的 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 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 中包含一个唯一约束字段——将 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 | 防止泄露内部字段 |