第 10 章:数据库集成(Prisma + PostgreSQL)

从零搭建 Next.js 的数据库层——Prisma ORM 配置、Schema 设计、迁移管理、连接池优化、事务处理,以及 PostgreSQL / MongoDB / PlanetScale 的多数据库实战。

本章目标:掌握在 Next.js App Router 项目中集成数据库的完整流程——从 Prisma ORM 的安装配置、Schema 设计、迁移管理,到连接池优化、事务处理,最终构建一个生产可用的数据层。


10.1 数据库选型

Next.js 支持的数据库方案

数据库类型ORM / SDK适用场景Serverless 兼容
PostgreSQL关系型Prisma / Drizzle通用 Web 应用、SaaS需注意连接池
MySQL关系型Prisma / Drizzle传统 Web 应用需注意连接池
MongoDB文档型Mongoose / Prisma灵活 Schema、内容管理✅ 良好
PlanetScaleServerless MySQLPrisma / DrizzleServerless 优先✅ 原生支持
NeonServerless PostgreSQLPrisma / DrizzleServerless PostgreSQL✅ 原生支持
TursoEdge SQLiteDrizzle / @libsqlEdge 应用✅ 原生支持
SupabasePostgreSQL + 服务@supabase/supabase-js快速原型、BaaS✅ 良好
RedisKV 缓存ioredis / upstash缓存、会话、队列✅(Upstash)

本教程选择:Prisma + PostgreSQL

理由:

  1. Prisma 提供类型安全的查询 API,与 TypeScript 深度集成
  2. PostgreSQL 是最流行的开源关系型数据库,生态成熟
  3. Prisma 的迁移系统简化了 Schema 演进
  4. 同一套代码可轻松切换到 MySQL / MongoDB / PlanetScale

10.2 Prisma 安装与配置

初始化

# 安装 Prisma CLI(开发依赖)和客户端(运行时依赖)
npm install -D prisma
npm install @prisma/client

# 初始化 Prisma
npx prisma init

生成的文件结构:

project/
├── prisma/
│   ├── schema.prisma    # 数据模型定义
│   └── .env             # 数据库连接字符串
├── .env                 # 环境变量(根目录)
└── ...

数据库连接配置

# .env

# PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

# MySQL
# DATABASE_URL="mysql://user:password@localhost:3306/mydb"

# MongoDB
# DATABASE_URL="mongodb://user:password@localhost:27017/mydb?retryWrites=true"

# PlanetScale(需要 SSL)
# DATABASE_URL="mysql://user:password@aws.connect.psdb.cloud/mydb?sslaccept=strict"

# Neon(Serverless PostgreSQL)
# DATABASE_URL="postgresql://user:password@ep-xxx.us-east-2.aws.neon.tech/mydb?sslmode=require"

Prisma Client 单例模式

在开发环境中,Next.js 的 HMR 会导致 Prisma Client 被重复实例化,耗尽数据库连接。必须使用单例模式

// 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;
}

为什么需要单例?

开发环境 HMR 行为(没有单例时):

第 1 次保存 → 创建 PrismaClient #1 → 连接 1
第 2 次保存 → 创建 PrismaClient #2 → 连接 2
第 3 次保存 → 创建 PrismaClient #3 → 连接 3
...
第 N 次保存 → 连接耗尽 ❌

使用单例后:
第 1 次保存 → 创建 PrismaClient #1 → 连接 1
第 2 次保存 → 复用 PrismaClient #1 → 连接 1
第 3 次保存 → 复用 PrismaClient #1 → 连接 1 ✅

10.3 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?
  avatar        String?
  bio           String?   @db.Text
  password      String
  role          Role      @default(USER)
  emailVerified DateTime?
  isActive      Boolean   @default(true)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // 关联
  articles      Article[]
  comments      Comment[]
  likes         Like[]
  accounts      Account[]
  sessions      Session[]

  @@map("users")
}

enum Role {
  USER
  EDITOR
  ADMIN
}

// ============ 文章模块 ============

model Article {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String    @db.Text
  excerpt     String?   @db.VarChar(500)
  coverImage  String?
  category    String?
  tags        String[]
  published   Boolean   @default(false)
  featured    Boolean   @default(false)
  views       Int       @default(0)
  readTime    Int?
  authorId    String
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  publishedAt DateTime?

  // 关联
  author      User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  comments    Comment[]
  likes       Like[]

  // 索引
  @@index([authorId])
  @@index([category])
  @@index([published, createdAt(sort: Desc)])
  @@index([slug])
  @@map("articles")
}

// ============ 评论模块 ============

model Comment {
  id        String    @id @default(cuid())
  content   String    @db.Text
  authorId  String
  postId    String
  parentId  String?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  // 关联
  author    User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  post      Article   @relation(fields: [postId], references: [id], onDelete: Cascade)
  parent    Comment?  @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
  replies   Comment[] @relation("CommentReplies")
  likes     CommentLike[]

  @@index([postId])
  @@index([authorId])
  @@index([parentId])
  @@map("comments")
}

// ============ 点赞模块 ============

model Like {
  id        String   @id @default(cuid())
  userId    String
  articleId String
  createdAt DateTime @default(now())

  author  User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)

  @@unique([userId, articleId])
  @@index([articleId])
  @@map("likes")
}

model CommentLike {
  id        String   @id @default(cuid())
  userId    String
  commentId String
  createdAt DateTime @default(now())

  comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)

  @@unique([userId, commentId])
  @@map("comment_likes")
}

// ============ 认证模块(NextAuth) ============

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verification_tokens")
}

Schema 设计原则

  1. 使用 cuid() 作为主键:避免自增 ID 暴露信息
  2. 所有表都有 createdAt / updatedAt:审计与排序
  3. 善用 @@index:为频繁查询的字段创建索引
  4. @@map 映射表名:Prisma 模型用 PascalCase,数据库表名用 snake_case
  5. onDelete: Cascade:删除用户时级联删除其内容

10.4 数据库迁移

开发环境迁移

# 创建并应用迁移
npx prisma migrate dev --name init_schema

# 指定迁移名称
npx prisma migrate dev --name add_article_tags

# 只生成迁移 SQL(不执行)
npx prisma migrate dev --create-only --name add_index

# 重置数据库(危险!会删除所有数据)
npx prisma migrate reset

迁移文件结构

prisma/
├── migrations/
│   ├── 20250610_init_schema/
│   │   └── migration.sql
│   ├── 20250611_add_article_tags/
│   │   └── migration.sql
│   └── migration_lock.toml
└── schema.prisma

生产环境迁移

# 生产环境只部署迁移(不创建新迁移)
npx prisma migrate deploy

在 Docker 中自动迁移:

# Dockerfile

FROM node:20-alpine AS base

# 安装依赖
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 构建
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# 生成 Prisma Client
RUN npx prisma generate

RUN npm run build

# 运行
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma

USER nextjs

EXPOSE 3000

# 启动时执行迁移
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

Schema 演进最佳实践

// 步骤 1:添加新字段(可选)
model Article {
  // ... existing fields
  readingProgress Float?    // 新增:阅读进度
}

// 步骤 2:运行迁移
// npx prisma migrate dev --name add_reading_progress

// 步骤 3:确认迁移 SQL
// ALTER TABLE "articles" ADD COLUMN "readingProgress" DOUBLE PRECISION;

// 步骤 4:如果需要数据迁移,编写脚本

数据迁移脚本:

// prisma/seed-reading-progress.ts

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // 为已发布文章设置默认阅读进度
  const result = await prisma.article.updateMany({
    where: {
      published: true,
      readingProgress: null,
    },
    data: {
      readingProgress: 0,
    },
  });

  console.log(`Updated ${result.count} articles`);
}

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

10.5 数据查询模式

CRUD 操作封装

// lib/services/article.ts

import { prisma } from '@/lib/prisma';
import { cache } from 'react';
import { revalidatePath, revalidateTag } from 'next/cache';
import type { Article, Prisma } from '@prisma/client';

// ============ 查询(Read) ============

// 使用 React cache 实现请求级去重
export const getArticleBySlug = cache(async (slug: string) => {
  return prisma.article.findUnique({
    where: { slug, published: true },
    include: {
      author: {
        select: { id: true, name: true, avatar: true, bio: true },
      },
      _count: {
        select: { comments: true, likes: true },
      },
    },
  });
});

export const getArticles = cache(async (options: {
  page?: number;
  limit?: number;
  category?: string;
  tag?: string;
  search?: string;
  authorId?: string;
  featured?: boolean;
} = {}) => {
  const {
    page = 1,
    limit = 10,
    category,
    tag,
    search,
    authorId,
    featured,
  } = options;

  const skip = (page - 1) * limit;

  const where: Prisma.ArticleWhereInput = {
    published: true,
    ...(category && { category }),
    ...(tag && { tags: { has: tag } }),
    ...(authorId && { authorId }),
    ...(featured !== undefined && { featured }),
    ...(search && {
      OR: [
        { title: { contains: search, mode: 'insensitive' } },
        { excerpt: { contains: search, mode: 'insensitive' } },
        { content: { contains: search, mode: 'insensitive' } },
      ],
    }),
  };

  const [articles, total] = await Promise.all([
    prisma.article.findMany({
      where,
      skip,
      take: limit,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        title: true,
        slug: true,
        excerpt: true,
        coverImage: true,
        category: true,
        tags: true,
        views: true,
        readTime: true,
        createdAt: true,
        publishedAt: true,
        author: {
          select: { id: true, name: true, avatar: true },
        },
        _count: {
          select: { comments: true, likes: true },
        },
      },
    }),
    prisma.article.count({ where }),
  ]);

  return {
    articles,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
      hasNext: page * limit < total,
      hasPrev: page > 1,
    },
  };
});

// ============ 创建(Create) ============

export async function createArticle(data: {
  title: string;
  slug: string;
  content: string;
  excerpt?: string;
  coverImage?: string;
  category?: string;
  tags?: string[];
  published?: boolean;
  authorId: string;
}) {
  const article = await prisma.article.create({
    data: {
      ...data,
      publishedAt: data.published ? new Date() : null,
      readTime: Math.ceil(data.content.length / 500), // 按 500 字/分钟估算
    },
  });

  revalidatePath('/articles');
  revalidateTag('articles');

  return article;
}

// ============ 更新(Update) ============

export async function updateArticle(
  id: string,
  data: Prisma.ArticleUpdateInput
) {
  // 如果从未发布变为发布,设置 publishedAt
  if (data.published === true) {
    const existing = await prisma.article.findUnique({
      where: { id },
      select: { published: true },
    });
    if (existing && !existing.published) {
      data.publishedAt = new Date();
    }
  }

  const article = await prisma.article.update({
    where: { id },
    data,
  });

  revalidatePath('/articles');
  revalidatePath(`/articles/${article.slug}`);
  revalidateTag('articles');

  return article;
}

// ============ 删除(Delete) ============

export async function deleteArticle(id: string) {
  const article = await prisma.article.findUnique({
    where: { id },
    select: { slug: true },
  });

  if (!article) {
    throw new Error('Article not found');
  }

  await prisma.article.delete({ where: { id } });

  revalidatePath('/articles');
  revalidatePath(`/articles/${article.slug}`);
  revalidateTag('articles');
}

// ============ 统计 ============

export async function incrementViews(slug: string) {
  await prisma.article.update({
    where: { slug },
    data: { views: { increment: 1 } },
  });
}

在 Server Component 中使用

// app/articles/page.tsx

import { getArticles } from '@/lib/services/article';
import { ArticleCard } from '@/app/components/ArticleCard';
import { Pagination } from '@/app/components/Pagination';
import { Suspense } from 'react';

type Props = {
  searchParams: Promise<{
    page?: string;
    category?: string;
    tag?: string;
    q?: string;
  }>;
};

export default async function ArticlesPage({ searchParams }: Props) {
  const params = await searchParams;

  const { articles, pagination } = await getArticles({
    page: Number(params.page) || 1,
    category: params.category,
    tag: params.tag,
    search: params.q,
  });

  return (
    <div className="max-w-4xl mx-auto py-8 px-4">
      <h1 className="text-3xl font-bold mb-8">文章列表</h1>

      <Suspense fallback={<div className="animate-pulse space-y-4">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="h-32 bg-gray-200 rounded" />
        ))}
      </div>}>
        {articles.length === 0 ? (
          <p className="text-gray-500 text-center py-12">暂无文章</p>
        ) : (
          <div className="space-y-6">
            {articles.map((article) => (
              <ArticleCard key={article.id} article={article} />
            ))}
          </div>
        )}
      </Suspense>

      <Pagination
        currentPage={pagination.page}
        totalPages={pagination.totalPages}
        basePath="/articles"
      />
    </div>
  );
}

10.6 事务处理

为什么需要事务?

当一组数据库操作必须全部成功或全部回滚时,需要事务:

场景:用户删除账号
  1. 删除用户的所有评论 ✅
  2. 删除用户的所有文章 ✅
  3. 删除用户记录 ❌ 失败
  → 如果不用事务,评论和文章已被删除,但用户还在 → 数据不一致

Prisma 事务

// lib/services/user.ts

import { prisma } from '@/lib/prisma';

// 方式一:批量事务(推荐,性能更好)
export async function deleteAccount(userId: string) {
  try {
    const [comments, articles, likes, user] = await prisma.$transaction([
      prisma.comment.deleteMany({ where: { authorId: userId } }),
      prisma.article.deleteMany({ where: { authorId: userId } }),
      prisma.like.deleteMany({ where: { userId: userId } }),
      prisma.user.delete({ where: { id: userId } }),
    ]);

    return {
      deletedComments: comments.count,
      deletedArticles: articles.count,
      deletedLikes: likes.count,
    };
  } catch (error) {
    console.error('Failed to delete account:', error);
    throw new Error('Account deletion failed, all changes rolled back');
  }
}

// 方式二:交互式事务(需要条件判断时使用)
export async function transferOwnership(
  articleId: string,
  fromUserId: string,
  toUserId: string
) {
  return prisma.$transaction(async (tx) => {
    // 1. 验证文章属于 fromUser
    const article = await tx.article.findFirst({
      where: { id: articleId, authorId: fromUserId },
    });

    if (!article) {
      throw new Error('Article not found or does not belong to the specified user');
    }

    // 2. 验证 toUser 存在
    const toUser = await tx.user.findUnique({
      where: { id: toUserId },
    });

    if (!toUser) {
      throw new Error('Target user not found');
    }

    // 3. 转移所有权
    const updated = await tx.article.update({
      where: { id: articleId },
      data: { authorId: toUserId },
    });

    return updated;
  });
}

// 方式三:带选项的事务(超时、隔离级别)
export async function publishArticleWithNotifications(
  articleId: string,
  followerIds: string[]
) {
  return prisma.$transaction(
    async (tx) => {
      // 发布文章
      const article = await tx.article.update({
        where: { id: articleId },
        data: {
          published: true,
          publishedAt: new Date(),
        },
      });

      // 为所有关注者创建通知
      if (followerIds.length > 0) {
        await tx.notification.createMany({
          data: followerIds.map((followerId) => ({
            userId: followerId,
            type: 'NEW_ARTICLE',
            title: `${article.title} 已发布`,
            link: `/articles/${article.slug}`,
          })),
        });
      }

      return article;
    },
    {
      maxWait: 5000,      // 等待获取事务锁的最大时间(ms)
      timeout: 10000,     // 事务超时时间(ms)
      isolationLevel: 'ReadCommitted', // 隔离级别
    }
  );
}

事务隔离级别

级别说明适用场景
ReadUncommitted最低隔离,可能读到脏数据极少使用
ReadCommitted默认级别,只读已提交数据大多数场景
RepeatableRead同一事务内多次读取结果一致金融、库存
Serializable最高隔离,串行执行严格一致性要求

10.7 连接池优化

问题:Serverless 环境下的连接爆炸

传统服务器(1 个进程):
  App → 10 个连接 → Database ✅

Serverless(100 个函数实例):
  Function #1 → 10 个连接 ─┐
  Function #2 → 10 个连接 ─┤
  Function #3 → 10 个连接 ─┼→ Database(1000 连接)❌ 爆炸
  ...                       │
  Function #100 → 10 个连接 ─┘

方案一:Prisma Accelerate(推荐)

npm install @prisma/extension-accelerate
// lib/prisma.ts

import { PrismaClient } from '@prisma/client';
import { withAccelerate } from '@prisma/extension-accelerate';

const globalForPrisma = globalThis as unknown as {
  prisma: ReturnType<typeof createPrismaClient> | undefined;
};

function createPrismaClient() {
  return new PrismaClient()
    .$extends(withAccelerate());
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

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

方案二:PgBouncer(自托管)

# docker-compose.yml

services:
  pgbouncer:
    image: edoburu/pgbouncer:latest
    environment:
      DATABASE_URL: "postgresql://user:pass@postgres:5432/mydb"
      POOL_MODE: "transaction"
      MAX_CLIENT_CONN: 1000
      DEFAULT_POOL_SIZE: 20
    ports:
      - "6432:6432"

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"
# .env — 连接 PgBouncer 而非直连 PostgreSQL
DATABASE_URL="postgresql://user:pass@localhost:6432/mydb?pgbouncer=true&connection_limit=1"

方案三:Neon / Supabase(Serverless 数据库)

// Neon 原生支持 Serverless,无需额外配置连接池
// .env
// DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/mydb?sslmode=require"

// lib/prisma.ts 保持标准写法即可

连接池配置对比

方案最大连接数延迟增加配置复杂度成本
Prisma Accelerate无限~50ms按用量付费
PgBouncer自定义~5ms自托管免费
Neon自动伸缩~10ms免费层可用
Supabase自动管理~10ms免费层可用
直连受限最低-

10.8 数据库 Seed(种子数据)

编写 Seed 脚本

// prisma/seed.ts

import { PrismaClient } from '@prisma/client';
import { hash } from 'bcryptjs';

const prisma = new PrismaClient();

async function main() {
  console.log('🌱 Starting seed...');

  // 1. 创建管理员用户
  const adminPassword = await hash('admin123', 12);
  const admin = await prisma.user.upsert({
    where: { email: 'admin@example.com' },
    update: {},
    create: {
      email: 'admin@example.com',
      name: '管理员',
      password: adminPassword,
      role: 'ADMIN',
      emailVerified: new Date(),
    },
  });

  console.log('✅ Admin created:', admin.email);

  // 2. 创建测试用户
  const userPassword = await hash('user123', 12);
  const user = await prisma.user.upsert({
    where: { email: 'user@example.com' },
    update: {},
    create: {
      email: 'user@example.com',
      name: '张三',
      password: userPassword,
      role: 'USER',
      emailVerified: new Date(),
      bio: '一个热爱编程的开发者',
    },
  });

  console.log('✅ User created:', user.email);

  // 3. 创建示例文章
  const articles = [
    {
      title: 'Next.js App Router 完全入门',
      slug: 'nextjs-app-router-getting-started',
      content: '# Next.js App Router\n\n这是一篇关于 Next.js App Router 的入门教程...',
      excerpt: '从零开始学习 Next.js App Router,掌握最新的路由系统和渲染策略。',
      category: 'frontend',
      tags: ['nextjs', 'react', 'app-router'],
      published: true,
      publishedAt: new Date(),
      authorId: admin.id,
    },
    {
      title: 'TypeScript 高级类型技巧',
      slug: 'typescript-advanced-types',
      content: '# TypeScript 高级类型\n\n本文介绍 TypeScript 中的高级类型用法...',
      excerpt: '深入理解 TypeScript 的条件类型、映射类型和模板字面量类型。',
      category: 'frontend',
      tags: ['typescript', 'javascript'],
      published: true,
      publishedAt: new Date(),
      authorId: admin.id,
    },
    {
      title: 'PostgreSQL 性能优化指南',
      slug: 'postgresql-performance-optimization',
      content: '# PostgreSQL 性能优化\n\n数据库性能是 Web 应用的关键...',
      excerpt: '从索引设计到查询优化,全面提升 PostgreSQL 性能。',
      category: 'backend',
      tags: ['postgresql', 'database', 'performance'],
      published: true,
      publishedAt: new Date(),
      authorId: user.id,
    },
  ];

  for (const article of articles) {
    await prisma.article.upsert({
      where: { slug: article.slug },
      update: {},
      create: article,
    });
  }

  console.log(`✅ ${articles.length} articles created`);

  // 4. 创建示例评论
  const firstArticle = await prisma.article.findFirst();
  if (firstArticle) {
    await prisma.comment.createMany({
      data: [
        {
          content: '写得很清楚,学到了很多!',
          authorId: user.id,
          postId: firstArticle.id,
        },
        {
          content: '期待后续章节的内容。',
          authorId: admin.id,
          postId: firstArticle.id,
        },
      ],
      skipDuplicates: true,
    });
    console.log('✅ Comments created');
  }

  console.log('🎉 Seed completed!');
}

main()
  .catch((e) => {
    console.error('❌ Seed failed:', e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

配置 package.json

{
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  }
}
# 运行 seed
npx prisma db seed

# 重置数据库并 seed
npx prisma migrate reset

10.9 Prisma Studio 与调试

Prisma Studio

# 启动可视化数据浏览器
npx prisma studio

Prisma Studio 在 http://localhost:5555 提供一个 Web UI,可以:

  • 浏览和编辑数据
  • 查看表结构和关系
  • 创建和删除记录
  • 筛选和排序数据

查询日志

// lib/prisma.ts

export const prisma = new PrismaClient({
  log: [
    { level: 'query', emit: 'event' },
    { level: 'error', emit: 'stdout' },
    { level: 'warn', emit: 'stdout' },
  ],
});

// 监听查询事件(开发环境)
if (process.env.NODE_ENV === 'development') {
  prisma.$on('query', (e) => {
    console.log(`Query: ${e.query}`);
    console.log(`Duration: ${e.duration}ms`);
    console.log('---');
  });
}

N+1 查询问题

// ❌ N+1 问题:每个文章单独查询作者
const articles = await prisma.article.findMany();
for (const article of articles) {
  const author = await prisma.user.findUnique({
    where: { id: article.authorId },
  });
  console.log(`${article.title} by ${author?.name}`);
}
// 生成 N+1 条 SQL 查询

// ✅ 使用 include 一次性加载
const articles = await prisma.article.findMany({
  include: {
    author: {
      select: { id: true, name: true, avatar: true },
    },
  },
});
// 只生成 2 条 SQL 查询(SELECT articles + SELECT users)

10.10 多数据库支持

MongoDB

// prisma/schema.prisma(MongoDB 版本)

generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  email     String   @unique
  name      String?
  articles  Article[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Article {
  id       String @id @default(auto()) @map("_id") @db.ObjectId
  title    String
  content  String
  authorId String @db.ObjectId
  author   User   @relation(fields: [authorId], references: [id])
  tags     String[]
}

Drizzle ORM(Prisma 替代方案)

// 如果你更偏好 SQL 风格,Drizzle 是另一个优秀选择

// drizzle/schema.ts
import { pgTable, text, boolean, timestamp, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  email: text('email').notNull().unique(),
  name: text('name'),
  role: text('role').default('USER').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const articles = pgTable('articles', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content').notNull(),
  published: boolean('published').default(false),
  views: integer('views').default(0),
  authorId: text('author_id').references(() => users.id),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

// 查询示例
import { eq, desc, like, and } from 'drizzle-orm';

const articles = await db
  .select()
  .from(articles)
  .where(and(
    eq(articles.published, true),
    like(articles.title, '%Next.js%')
  ))
  .orderBy(desc(articles.createdAt))
  .limit(10);

10.11 实战:博客系统数据层

完整的服务层架构

lib/
├── prisma.ts              # Prisma Client 单例
├── services/
│   ├── article.ts         # 文章 CRUD
│   ├── comment.ts         # 评论 CRUD
│   ├── user.ts            # 用户管理
│   ├── auth.ts            # 认证相关
│   └── analytics.ts       # 统计相关
└── validators/
    ├── article.ts         # 文章验证 Schema
    ├── comment.ts         # 评论验证 Schema
    └── user.ts            # 用户验证 Schema

统计服务

// lib/services/analytics.ts

import { prisma } from '@/lib/prisma';
import { cache } from 'react';

// 仪表盘统计数据
export const getDashboardStats = cache(async () => {
  const [
    totalArticles,
    publishedArticles,
    totalComments,
    totalUsers,
    totalViews,
    recentArticles,
    topArticles,
    categoryStats,
  ] = await Promise.all([
    prisma.article.count(),
    prisma.article.count({ where: { published: true } }),
    prisma.comment.count(),
    prisma.user.count(),
    prisma.article.aggregate({ _sum: { views: true } }),
    prisma.article.findMany({
      take: 5,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        title: true,
        published: true,
        views: true,
        createdAt: true,
      },
    }),
    prisma.article.findMany({
      where: { published: true },
      take: 5,
      orderBy: { views: 'desc' },
      select: {
        id: true,
        title: true,
        slug: true,
        views: true,
      },
    }),
    prisma.article.groupBy({
      by: ['category'],
      _count: { id: true },
      where: { published: true },
    }),
  ]);

  return {
    overview: {
      totalArticles,
      publishedArticles,
      draftArticles: totalArticles - publishedArticles,
      totalComments,
      totalUsers,
      totalViews: totalViews._sum.views ?? 0,
    },
    recentArticles,
    topArticles,
    categoryStats,
  };
});

// 文章阅读趋势(最近 30 天)
export const getArticleViewsTrend = cache(async (slug: string) => {
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

  // 注意:这需要 ArticleView 模型来记录每日浏览量
  // 简化版本:返回总浏览量
  const article = await prisma.article.findUnique({
    where: { slug },
    select: { views: true },
  });

  return {
    totalViews: article?.views ?? 0,
    slug,
  };
});

搜索服务

// lib/services/search.ts

import { prisma } from '@/lib/prisma';
import { cache } from 'react';

export const searchContent = cache(async (query: string, options: {
  type?: 'article' | 'comment' | 'user' | 'all';
  page?: number;
  limit?: number;
} = {}) => {
  const { type = 'all', page = 1, limit = 10 } = options;
  const skip = (page - 1) * limit;

  const results: {
    articles?: any[];
    comments?: any[];
    users?: any[];
  } = {};

  const searchCondition = {
    contains: query,
    mode: 'insensitive' as const,
  };

  if (type === 'article' || type === 'all') {
    results.articles = await prisma.article.findMany({
      where: {
        published: true,
        OR: [
          { title: searchCondition },
          { content: searchCondition },
          { excerpt: searchCondition },
        ],
      },
      skip: type === 'article' ? skip : 0,
      take: type === 'article' ? limit : 5,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        title: true,
        slug: true,
        excerpt: true,
        createdAt: true,
        author: { select: { name: true } },
      },
    });
  }

  if (type === 'user' || type === 'all') {
    results.users = await prisma.user.findMany({
      where: {
        OR: [
          { name: searchCondition },
          { email: searchCondition },
        ],
      },
      skip: type === 'user' ? skip : 0,
      take: type === 'user' ? limit : 5,
      select: {
        id: true,
        name: true,
        avatar: true,
        bio: true,
      },
    });
  }

  return results;
});

10.12 生产环境数据库最佳实践

检查清单

## 数据库生产环境检查清单

### Schema
- [ ] 所有表都有主键(推荐 cuid/uuid)
- [ ] 所有表都有 createdAt / updatedAt
- [ ] 频繁查询的字段已添加索引
- [ ] 外键约束和级联删除已正确配置
- [ ] 敏感字段(密码)已加密存储

### 连接
- [ ] 使用连接池(PgBouncer / Accelerate)
- [ ] Prisma Client 使用单例模式
- [ ] 生产环境日志级别设为 error
- [ ] 数据库连接使用环境变量

### 迁移
- [ ] 迁移文件已提交到版本控制
- [ ] 生产环境使用 `prisma migrate deploy`
- [ ] 大表变更考虑零停机迁移策略
- [ ] 数据迁移脚本已测试

### 安全
- [ ] 数据库用户权限最小化
- [ ] 连接使用 SSL
- [ ] 敏感数据不记录到日志
- [ ] 输入验证在应用层完成

### 性能
- [ ] N+1 查询已消除
- [ ] 大数据集使用分页
- [ ] 批量操作使用 createMany / updateMany
- [ ] 只 select 需要的字段
- [ ] 热路径使用 React cache 去重

### 监控
- [ ] 慢查询日志已开启
- [ ] 连接数监控已配置
- [ ] 磁盘空间告警已设置
- [ ] 定期备份已配置

零停机迁移策略

# 场景:将 articles.content 从 Text 改为 VarChar(50000)

# 步骤 1:添加新列(不删除旧列)
# migration.sql
ALTER TABLE "articles" ADD COLUMN "content_new" VARCHAR(50000);

# 步骤 2:数据迁移
UPDATE "articles" SET "content_new" = "content"::VARCHAR(50000);

# 步骤 3:部署新代码(同时读写两列)

# 步骤 4:确认所有数据迁移完成后
# migration.sql
ALTER TABLE "articles" DROP COLUMN "content";
ALTER TABLE "articles" RENAME COLUMN "content_new" TO "content";
ALTER TABLE "articles" ALTER COLUMN "content" SET NOT NULL;

本章小结

Key Takeaways

  1. Prisma 是 Next.js 数据库层的首选 ORM:类型安全、迁移系统成熟、与 TypeScript 深度集成
  2. 单例模式是必须的:防止 HMR 导致连接泄漏
  3. Schema 设计决定了应用的可扩展性:索引、关联、级联删除需要提前规划
  4. Serverless 环境需要连接池:PgBouncer / Prisma Accelerate / Neon 三选一
  5. 事务保证数据一致性:批量操作用 $transaction([]),条件操作用 $transaction(async (tx) => {})
  6. 服务层封装隔离数据访问逻辑:Server Component 不直接调用 Prisma

下一步

下一章我们将构建完整的认证体系——从 NextAuth.js v5 的配置、OAuth 集成、Session 管理,到 Lucia 和 Clerk 的对比,最终实现一个支持邮箱登录 + GitHub OAuth + 角色权限的认证系统。


参考资料

继续阅读

探索更多技术文章

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

全部文章 返回首页