本章目标:掌握在 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、内容管理 | ✅ 良好 |
| PlanetScale | Serverless MySQL | Prisma / Drizzle | Serverless 优先 | ✅ 原生支持 |
| Neon | Serverless PostgreSQL | Prisma / Drizzle | Serverless PostgreSQL | ✅ 原生支持 |
| Turso | Edge SQLite | Drizzle / @libsql | Edge 应用 | ✅ 原生支持 |
| Supabase | PostgreSQL + 服务 | @supabase/supabase-js | 快速原型、BaaS | ✅ 良好 |
| Redis | KV 缓存 | ioredis / upstash | 缓存、会话、队列 | ✅(Upstash) |
本教程选择:Prisma + PostgreSQL
理由:
- Prisma 提供类型安全的查询 API,与 TypeScript 深度集成
- PostgreSQL 是最流行的开源关系型数据库,生态成熟
- Prisma 的迁移系统简化了 Schema 演进
- 同一套代码可轻松切换到 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 设计原则
- 使用
cuid()作为主键:避免自增 ID 暴露信息 - 所有表都有
createdAt/updatedAt:审计与排序 - 善用
@@index:为频繁查询的字段创建索引 @@map映射表名:Prisma 模型用 PascalCase,数据库表名用 snake_caseonDelete: 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
- Prisma 是 Next.js 数据库层的首选 ORM:类型安全、迁移系统成熟、与 TypeScript 深度集成
- 单例模式是必须的:防止 HMR 导致连接泄漏
- Schema 设计决定了应用的可扩展性:索引、关联、级联删除需要提前规划
- Serverless 环境需要连接池:PgBouncer / Prisma Accelerate / Neon 三选一
- 事务保证数据一致性:批量操作用
$transaction([]),条件操作用$transaction(async (tx) => {}) - 服务层封装隔离数据访问逻辑:Server Component 不直接调用 Prisma
下一步
下一章我们将构建完整的认证体系——从 NextAuth.js v5 的配置、OAuth 集成、Session 管理,到 Lucia 和 Clerk 的对比,最终实现一个支持邮箱登录 + GitHub OAuth + 角色权限的认证系统。
参考资料
- Prisma 官方文档
- Prisma + Next.js 集成指南
- PostgreSQL 官方文档
- Neon Serverless PostgreSQL
- PlanetScale 文档
- Drizzle ORM
- PgBouncer 连接池
- Prisma Accelerate
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。