第 21 章

数据库集成:Prisma + PostgreSQL 完整方案

第21章:数据库集成:Prisma + PostgreSQL 完整方案

Prisma 的核心主张是:数据库 schema 应该是真相的唯一来源,TypeScript 类型应该从 schema 自动生成。从 schema 到组件,类型一致,没有手动维护的环节。

本章核心问题:为什么选 Prisma?Singleton 模式如何避免 HMR 连接泄漏?事务和无服务器连接池如何处理?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么选择 Prisma

在 Node.js 生态里,操作数据库的工具从来不缺:pg、Knex、Sequelize、TypeORM……每一个都能用,但 Prisma 站在了一个不同的起点上。它的核心主张是:数据库 schema 应该是真相的唯一来源,TypeScript 类型应该从 schema 自动生成,而不是由开发者手动维护

传统 ORM 的痛苦你可能都经历过:模型定义在代码里,数据库表在迁移脚本里,TypeScript 类型在 interface 里——三个地方,三份真相,随时可能漂移。Prisma 把这三者合并成一份 schema.prisma 文件。你在 schema 里定义模型,prisma migrate 同步到数据库,prisma generate 生成完全类型安全的客户端。从 schema 到组件,类型一致,没有任何手动维护的环节。

对于 Next.js 应用来说,Prisma 还有一个关键优势:Server Components 和 Server Actions 可以直接 import Prisma Client,没有任何中间层。你不需要为数据访问单独创建 API 路由——这是 App Router 架构允许的,也是 Prisma 类型安全在全栈场景下真正发挥价值的地方。

项目初始化与 Singleton 模式

首先安装依赖:

npm install prisma @prisma/client
npx prisma init --datasource-provider postgresql

prisma init 会创建 prisma/schema.prisma.env 文件(包含 DATABASE_URL)。

开发环境的连接池陷阱

在 Next.js 开发模式下,next dev 启用了热模块替换(HMR)。每次你修改一个文件,模块会重新加载——包括 lib/prisma.ts。如果你直接写:

// ❌ 错误写法:每次热重载都创建新的 PrismaClient 实例
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()

HMR 会导致 PrismaClient 被反复实例化,每个实例都持有自己的数据库连接池。开发几小时后,你的 PostgreSQL 日志里会出现几十个甚至上百个空闲连接,最终触发 "too many connections" 错误。

正确做法是 Singleton 模式,利用 Node.js 的 global 对象在热重载中持久化:

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

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

这里的逻辑是:globalThis 在 Node.js 进程生命周期内始终存在,不受 HMR 影响。第一次加载时创建 PrismaClient 并挂到 global,后续热重载时发现 global 上已有实例,直接复用,不再创建新连接。生产环境不需要这个处理,因为没有 HMR,每个进程只有一个模块实例。

Schema 设计:关系与类型

下面设计一个博客应用的 schema,涵盖一对多和多对多关系:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  password  String?
  role      Role     @default(USER)
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

enum Role {
  USER
  ADMIN
}

model Post {
  id          String   @id @default(cuid())
  title       String
  slug        String   @unique
  content     String
  published   Boolean  @default(false)
  authorId    String
  author      User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  tags        Tag[]    @relation("PostTags")
  views       Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@index([authorId])
  @@index([slug])
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[] @relation("PostTags")
}

几个设计决策值得解释:

@id @default(cuid()):使用 CUID 而非自增整数。CUID 对分布式环境友好,URL-safe,且不暴露数据量信息(攻击者无法从 ID 推断你有多少用户)。

onDelete: Cascade:删除用户时自动删除其所有文章。这个行为需要显式声明,不要依赖应用层的级联删除——数据库层的约束更可靠。

@@index:在外键和常用查询字段上建立索引。Prisma 不会自动为外键创建索引(PostgreSQL 也不会),必须手动声明。遗漏索引是应用性能问题的高频原因。

多对多关系:Post 和 Tag 使用隐式多对多(@relation("PostTags")),Prisma 自动创建中间表 _PostTags。如果中间表需要额外字段(如关联时间),则需要显式定义中间模型。

Level 2 · 它是怎么运行的(3-5年经验)

迁移工作流

# 创建并应用迁移(开发环境)
npx prisma migrate dev --name init

# 生产环境应用迁移(不创建新迁移,只执行待处理的)
npx prisma migrate deploy

# 重新生成 Prisma Client(修改 schema 后必须执行)
npx prisma generate

# 打开 Prisma Studio(可视化数据库浏览器)
npx prisma studio

migrate dev 做了三件事:生成迁移 SQL 文件、执行迁移、重新运行 prisma generate。迁移文件存储在 prisma/migrations/ 目录,应该纳入版本控制——这些文件是数据库变更历史,CI/CD 依赖它们。

在 Server Components 中使用 Prisma

App Router 的 Server Components 是数据获取的天然场所。直接 import prisma 并调用:

// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
import { PostCard } from '@/components/PostCard'

export default async function BlogPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true, id: true },
      },
      tags: true,
    },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

  return (
    <div className="grid gap-6">
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

posts 的类型是 Prisma 根据 include 自动推断的:(Post & { author: { name: string; id: string }; tags: Tag[] })[]。你不需要手动定义这个类型,也不需要担心类型和实际查询不一致——它们是同一件事。

PostCard 组件的 props 类型可以用 Prisma 的工具类型:

// components/PostCard.tsx
import { Prisma } from '@prisma/client'

type PostWithRelations = Prisma.PostGetPayload<{
  include: {
    author: { select: { name: true; id: true } }
    tags: true
  }
}>

export function PostCard({ post }: { post: PostWithRelations }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>by {post.author.name}</p>
      <div>{post.tags.map(t => t.name).join(', ')}</div>
    </article>
  )
}

Prisma.PostGetPayload 是 Prisma 提供的条件类型工具,它接受 findMany/findUnique 的参数类型,返回对应的结果类型。这样 PostCard 的 props 类型与实际查询严格一致。

Level 3 · 规范怎么定义的(资深)

在 Server Actions 中使用 Prisma 与事务

复杂的写操作应该使用事务,确保原子性:

// app/blog/actions.ts
'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/auth'

export async function createPost(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const tagNames = (formData.get('tags') as string).split(',').map(t => t.trim())

  // 使用事务确保文章创建和标签关联的原子性
  const post = await prisma.$transaction(async (tx) => {
    // 1. 创建或获取所有标签
    const tags = await Promise.all(
      tagNames.map(name =>
        tx.tag.upsert({
          where: { name },
          create: { name },
          update: {},
        })
      )
    )

    // 2. 创建文章并关联标签
    return tx.post.create({
      data: {
        title,
        content,
        slug: generateSlug(title),
        authorId: session.user.id,
        tags: {
          connect: tags.map(t => ({ id: t.id })),
        },
      },
    })
  })

  revalidatePath('/blog')
  redirect(`/blog/${post.slug}`)
}

function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

prisma.$transaction 接受一个回调,回调内的所有操作在同一个数据库事务中执行。任何一步失败,整个事务回滚。注意回调接收 tx 参数而非使用外部的 prisma——这确保操作在事务上下文内。

无服务器环境的连接池问题

Prisma 默认使用连接池,在长期运行的服务器(Node.js 服务)里工作良好。但在 Vercel 这样的无服务器环境里,每个函数调用都可能启动一个新进程,每个进程创建自己的连接池。高并发时,你的 PostgreSQL 可能收到几百个并发连接请求,远超其默认限制(通常 100)。

方案一:PgBouncer

PgBouncer 是 PostgreSQL 的连接池代理,部署在数据库前面,对外暴露固定数量的连接。无论有多少应用进程,PgBouncer 都只维护有限的真实数据库连接。

使用 PgBouncer 时,Prisma 需要调整连接字符串:

# 标准连接(用于迁移)
DATABASE_URL="postgresql://user:pass@host:5432/db"

# PgBouncer 连接(用于应用,transaction mode)
DATABASE_URL_PGBOUNCER="postgresql://user:pass@pgbouncer-host:6432/db?pgbouncer=true&connection_limit=1"

pgbouncer=true 告诉 Prisma 禁用某些与 PgBouncer transaction mode 不兼容的功能(如 prepared statements)。connection_limit=1 让每个 Prisma 实例只持有一个连接,把连接管理完全交给 PgBouncer。

方案二:Prisma Accelerate

Prisma Accelerate 是 Prisma 官方的无服务器数据库代理服务,内置连接池和全球边缘缓存:

npm install @prisma/extension-accelerate
// lib/prisma.ts(使用 Accelerate)
import { PrismaClient } from '@prisma/client'
import { withAccelerate } from '@prisma/extension-accelerate'

export const prisma = new PrismaClient().$extends(withAccelerate())
// 查询时可以指定缓存策略
const posts = await prisma.post.findMany({
  cacheStrategy: {
    ttl: 60,          // 缓存 60 秒
    swr: 30,          // stale-while-revalidate 30 秒
  },
})

Accelerate 的连接字符串以 prisma:// 开头,流量通过 Prisma 的全球网络路由。对于读多写少的场景,边缘缓存可以显著降低数据库压力。

Seed 脚本

开发环境需要测试数据,seed 脚本提供可重复的初始化:

// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'

const prisma = new PrismaClient()

async function main() {
  // 清空现有数据(开发环境)
  await prisma.post.deleteMany()
  await prisma.user.deleteMany()
  await prisma.tag.deleteMany()

  const hashedPassword = await bcrypt.hash('password123', 10)

  const admin = await prisma.user.create({
    data: {
      email: '[email protected]',
      name: 'Admin User',
      password: hashedPassword,
      role: 'ADMIN',
    },
  })

  const tags = await Promise.all(
    ['Next.js', 'React', 'TypeScript', 'PostgreSQL'].map(name =>
      prisma.tag.create({ data: { name } })
    )
  )

  await prisma.post.create({
    data: {
      title: 'Getting Started with Next.js 15',
      slug: 'getting-started-nextjs-15',
      content: '# Introduction\n\nNext.js 15 brings exciting changes...',
      published: true,
      authorId: admin.id,
      tags: {
        connect: [{ id: tags[0].id }, { id: tags[1].id }],
      },
    },
  })

  console.log('Seed completed')
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())

package.json 中配置:

{
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  }
}

然后运行:

npx prisma db seed

类型安全的完整路径

Prisma 的价值在整条链路上都体现出来:schema 定义模型 → prisma generate 生成精确的 TypeScript 类型 → Server Components 查询时类型自动推断 → Prisma.ModelGetPayload 将查询结果类型传递给子组件 → 组件 props 在编译时验证。

如果你修改 schema(比如给 Post 加一个 excerpt 字段),重新运行 prisma generate,TypeScript 编译器会立刻告诉你哪些地方没有处理新字段。这不是运行时发现的问题,而是编译时。这种类型安全从数据库一路延伸到渲染层,是纯 SQL 或松散 ORM 无法提供的。

Level 4 · 边界与陷阱(所有人)

陷阱1:开发环境不使用 Singleton 模式会导致 HMR 反复创建 PrismaClient 实例,最终触发 too many connections 错误。

陷阱2:Prisma 不会自动为外键创建索引(PostgreSQL 也不会)——必须在 schema 中手动声明 @@index,遗漏索引是性能问题的高频原因。

陷阱3:prisma.$transaction 回调必须使用 tx 参数而非外部的 prisma——使用外部实例会绕过事务上下文。

本章评分
4.7  / 5  (8 评分)

💬 留言讨论