Prisma + PostgreSQL 实战:Schema 设计、Migration、类型安全查询与 Seed 数据

详解 Prisma ORM 与 PostgreSQL 的完整工作流:Schema 设计、Migration 版本控制、Seed 数据生成、关联查询与事务、Raw SQL 查询、Middleware 拦截器、连接池配置、Next.js/Nest.js 集成,以及 Prisma Studio 可视化调试。

Prisma 是当前 Node.js/TypeScript 生态中最现代化的 ORM,它以声明式 Schema 管理数据库结构,以类型安全的方式生成查询客户端。与 PostgreSQL 结合,Prisma 可以在保持 SQL 全部能力的同时,获得完整的 TypeScript 类型推导和智能补全。

一句话总结:Prisma 让你写 TypeScript 代码时享受 IDE 自动补全的 SQL。Schema 是数据库结构,Migration 是版本控制,Client 是类型安全的查询器。


一、Schema 设计

1.1 完整 Schema 示例

// schema.prisma
// 生成器:生成 TypeScript 客户端
generator client {
  provider = "prisma-client-js"
}

// 数据源:PostgreSQL 连接
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 用户模型
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
  profile   Profile?
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("users")
  @@index([email])
  @@index([createdAt])
}

enum Role {
  USER
  ADMIN
  MODERATOR
}

// 文章模型
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  slug      String   @unique
  content   String?
  published Boolean  @default(false)
  views     Int      @default(0)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  Int      @map("author_id")
  tags      Tag[]
  metadata  Json?    // PostgreSQL JSONB 列
  createdAt DateTime @default(now()) @map("created_at")

  @@map("posts")
  @@index([published, createdAt])
  @@index([slug])
}

// 标签模型
model Tag {
  id    Int    @id @default(autoincrement())
  name  String @unique
  posts Post[]

  @@map("tags")
}

// 用户资料(一对一关系)
model Profile {
  id     Int     @id @default(autoincrement())
  bio    String?
  avatar String?
  user   User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId Int     @unique @map("user_id")

  @@map("profiles")
}

1.2 Schema 设计原则

原则说明
使用 @@map 避免驼峰到蛇形转换问题明确指定数据库列/表名
DateTime@default(now())自动填充创建时间
为频繁查询字段加 @@indexSchema 级别声明索引
关系字段必须加 onDelete: CascadeRestrict明确级联行为,避免孤儿数据
JSONB 字段用 Json 类型Prisma 自动映射为 PostgreSQL JSONB
枚举类型优先于 String类型安全 + 数据一致性

二、Migration 工作流

2.1 初始化与迭代

# 初始化 Schema 并生成第一个 Migration
npx prisma migrate dev --name init

# Model 变更后生成新 Migration
npx prisma migrate dev --name add_user_role

# 部署到生产环境
npx prisma migrate deploy

# 生成客户端(类型安全)
npx prisma generate

# 可视化数据库
npx prisma studio

2.2 生产环境迁移策略

# CI/CD 步骤(Docker 容器内)
npx prisma generate               # 先编译出 Prisma Client
dotenv -e .env.production npx prisma migrate deploy  # 只 apply 待执行迁移
npm run build                     # 构建应用
npm start                         # 启动

注意:生产环境只用 prisma migrate deploy绝不prisma migrate dev(它会自动提示创建迁移,不适合无人值守的 CI)。

2.3 版本控制约定

prisma/
├── dev.db           # SQLite(测试用,可选)
├── migrations/
│   ├── 20251120000000_init/
│   │   └── migration.sql
│   └── 20251121000000_add_user_role/
│       └── migration.sql
├── schema.prisma
├── seed.ts
└── lib/
    └── prisma.ts    # 单例 Prisma Client

三、Seed 数据:初始化测试数据

3.1 Seed 脚本

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

async function main() {
  // 创建种子数据
  const alice = await prisma.user.upsert({
    where: { email: 'alice@example.com' },
    update: {},
    create: {
      email: 'alice@example.com',
      name: 'Alice',
      role: 'USER',
      posts: {
        create: [
          {
            title: 'Getting Started with Prisma',
            slug: 'getting-started-prisma',
            published: true,
            tags: { create: [{ name: 'prisma' }, { name: 'database' }] },
          },
        ],
      },
      profile: { create: { bio: 'Full-stack developer' } },
    },
  });

  console.log({ alice });
}

main()
  .catch((e) => { console.error(e); process.exit(1); })
  .finally(async () => await prisma.$disconnect());

3.2 配置 Seed 命令

// package.json
{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  },
  "scripts": {
    "db:seed": "prisma db seed"
  }
}

执行npx prisma db seed 会在 migrate reset(清空数据库重新创建)时自动运行。


四、类型安全查询

4.1 CRUD 完整示例

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'], // 开发时开启
});

// Create(含关联嵌套创建)
const user = await prisma.user.create({
  data: {
    email: 'bob@example.com',
    name: 'Bob',
    role: 'ADMIN',
    profile: { create: { bio: 'Administrator' } },
  },
  include: { profile: true }, // 返回关联数据
});

// Read(条件 + 关联 + 分页)
const posts = await prisma.post.findMany({
  where: {
    published: true,
    author: { email: { contains: '@example.com' } },
  },
  include: { author: { select: { name: true, email: true } }, tags: true },
  orderBy: [{ createdAt: 'desc' }],
  take: 10,
  skip: 0, // offset for pagination
});

// Update(计数器原子性)
const updated = await prisma.post.update({
  where: { id: 1 },
  data: { views: { increment: 1 } }, // 原子自增
});

// Delete(级联删除由 Schema 中 onDelete: Cascade 控制)
await prisma.user.delete({ where: { id: 1 } });

4.2 复杂查询模式

// 1. Filter 多条件组合
const users = await prisma.user.findMany({
  where: {
    AND: [
      { role: 'USER' },
      { createdAt: { gte: new Date('2025-01-01') } },
      { posts: { some: { published: true } } }, // 至少有一篇已发布文章
    ],
  },
});

// 2. Aggregation(聚合)
const stats = await prisma.post.groupBy({
  by: ['published'],
  _count: { id: true },
  _avg: { views: true },
  _sum: { views: true },
  _max: { createdAt: true },
});

// 3. DISTINCT 查询
const distinctTags = await prisma.post.findMany({
  distinct: ['authorId'],
});

// 4. SELECT 指定字段(减少返回数据量)
const slimPosts = await prisma.post.findMany({
  select: { id: true, title: true, slug: true },
  take: 5,
});

4.3 事务处理

// 方式 1:交互式事务(推荐大多数场景)
const [user, post] = await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: { email: 'charlie@example.com', name: 'Charlie' },
  });
  const post = await tx.post.create({
    data: {
      title: "Charlie's Post",
      slug: 'charlies-post',
      authorId: user.id,
    },
  });
  return [user, post];
});

// 方式 2:批量事务(串行执行,一步失败全回滚)
const result = await prisma.$transaction([
  prisma.user.update({ where: { id: 1 }, data: { name: 'Updated' } }),
  prisma.post.update({ where: { id: 1 }, data: { views: { increment: 1 } } }),
]);

// 方式 3:事务选项(隔离级别 + 重试)
await prisma.$transaction(
  async (tx) => {
    const user = await tx.user.create({ data: { email: 'dave@example.com' } });
    return user;
  },
  {
    isolationLevel: 'Serializable',
    maxWait: 5000,
    timeout: 10000,
  }
);

五、Raw SQL 查询

当 Prisma 的 API 不能满足复杂查询时,Raw SQL 仍然可用:

// 简单 Raw Query(返回动态类型,不安全)
const result = await prisma.$queryRaw`
  SELECT * FROM posts WHERE published = true ORDER BY views DESC LIMIT 5
`;

// 参数化 Raw Query(防 SQL 注入)
const email = 'alice@example.com';
const user = await prisma.$queryRaw`
  SELECT * FROM users WHERE email = ${email}
`;

// Prisma 类型安全的 Raw Query(typed-sql 功能)
// 先运行 prisma db generate 生成类型,然后:
import { getActiveUsers } from '@prisma/client/sql';
const users = await prisma.$queryRawTyped(getActiveUsers());

// Raw Execute(INSERT/UPDATE/DELETE)
await prisma.$executeRaw`
  INSERT INTO tags (name) VALUES ('new-tag') ON CONFLICT DO NOTHING
`;

六、Middleware 与生命周期钩子

6.1 Middleware 拦截查询

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// 全局 Middleware:记录慢查询
prisma.$use(async (params, next) => {
  const start = performance.now();
  const result = await next(params);
  const end = performance.now();
  const duration = end - start;

  if (duration > 1000) {
    console.warn(`[SLOW QUERY] ${params.model}.${params.action} took ${duration.toFixed(2)}ms`);
  }

  return result;
});

// Middleware 2:自动注入用户上下文
prisma.$use(async (params, next) => {
  if (params.model === 'Post' && params.action === 'create') {
    params.args.data.slug = params.args.data.slug ||
      params.args.data.title.toLowerCase().replace(/\s+/g, '-');
  }
  return next(params);
});

export default prisma;

6.2 使用单例

// 避免 Next.js 开发热重载产生多个连接
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

七、连接池配置

const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL,
    },
  },
});

// 连接池池大小需在连接字符串中配置
// DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=5
// production 推荐 via PgBouncer:`DATABASE_URL=postgresql://user:pass@pgbouncer:6432/db?pgbouncer=true`

连接池最佳实践:别在 Prisma 层设太大,connection_limit=10 左右;后端数据库层用 PgBouncer 处理高并发小连接。Prisma 层 = 应用短连接,PgBouncer = 汇聚层,PostgreSQL = 长连接。


常见问题(FAQ)

Prisma 的 @db.Json@db.JsonB 有什么区别?

在 PostgreSQL 中 Prisma Json 类型默认映射到 JSONB。如果你想用纯文本 JSON(性能更差、无索引),可在 Schema 中写 @db.Json(注意 Prisma 5+ 已有变化)。

Migration 冲突怎么办(多个开发者同时创建)?

  1. 先执行 prisma migrate resolve 解决已应用的重复 Migration
  2. 将冲突的 migration.sql 合并为一个
  3. prisma migrate dev --create-only 新建而不自动应用

Seed 脚本在 CI 中如何运行?

npx prisma migrate reset --force  # 清空数据库 + 应用全部迁移 + 运行 seed
# CI 中 --force 跳过确认提示

Prisma 不支持的数据库特性怎么用 Raw SQL?

CTE(WITH 语句)、窗口函数、自定义函数 — 这些 Prisma 4/5 的 Client API 仍不支持,通过 $queryRaw 使用是标准做法。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章