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仅在update和upsert时由 Prisma 自动设置(见反模式中的批量更新陷阱)。
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 };
事务形式选择
| 场景 | 使用 |
|---|---|
| 独立操作,无相互依赖 | 数组形式 |
| 后一步骤依赖于先前的结果 | 交互式形式 |
| 涉及外部调用(邮件、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 对每个迁移文件进行校验。应用后编辑会导致原始迁移已运行的每个环境出现 P3006 checksum mismatch。请改为创建新的迁移。
破坏性 schema 变更需要多步骤迁移
一次迁移中向现有列添加 NOT NULL 或重命名列会锁定表或丢弃数据。使用扩缩法(expand-and-contract):
# 步骤 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 | 防止泄露内部字段 |