数据持久化
数据库与 ORM——让数据真正"存下来"
🎯 学习目标
学完本章,你将能够:
- 理解 SQL 与 NoSQL 数据库的适用场景,能正确选型
- 使用 Prisma 定义数据模型和执行数据库迁移
- 实现基本的 CRUD 操作和关联查询
- 理解 N+1 查询问题,能用 Prisma 的 include/select 优化
- 掌握事务操作和数据库索引的基本概念
你有没有想过
你注册了一个账号,关掉浏览器再打开,为什么登录状态还在?
因为你的账号信息存在数据库里。数据持久化就是把程序运行时的数据,安全地保存到不会丢失的存储介质中。
这一章你将学会:选择数据库、用 Prisma ORM 操作数据、处理表之间的关系。
Part 1: 数据库选型
SQL 还是 NoSQL?这是个问题
数据库不是"哪个好用就用哪个",而是"你的数据适合哪种结构就用哪种"。关系型数据库像 Excel 表格,每列都有严格的类型;文档数据库像 JSON 文件,每条记录可以长得不一样。这一部分我们对比两大阵营,并完成 PostgreSQL 的安装与连接。
SQL vs NoSQL
两大阵营,核心区别在于数据怎么组织
| 特性 | 关系型(SQL) | 非关系型(NoSQL) |
|---|---|---|
| 代表 | PostgreSQL、MySQL | MongoDB、Redis |
| 数据结构 | 表、行、列 | 文档、键值对 |
| Schema | 严格预定义 | 灵活动态 |
| 事务 | ACID 原生支持 | 部分支持 |
| 关系 | JOIN 原生支持 | 需要手动处理 |
| 适用场景 | 结构化数据、复杂查询 | 灵活文档、高并发 |
本教程选择 PostgreSQL 作为主数据库。它是开源免费的关系型数据库,功能强大,社区活跃。
PostgreSQL 安装
三种方式,选最适合你的
- 直接安装:从
postgresql.org/download下载 - Docker:
docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=123 postgres - 云服务:Supabase / Neon / Railway(免费额度够学习用)
# 安装后验证
$ psql -U postgres
postgres=# CREATE DATABASE myapp;
postgres=# \c myapp
原生 SQL 连接
不推荐裸写 SQL,但了解底层有助于理解 ORM
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
const result = await pool.query("SELECT * FROM users WHERE id = $1", [userId]);
console.log(result.rows); // User[]
$1, $2, ...)来防止 SQL 注入攻击。
Part 2: Prisma ORM
用 TypeScript 的方式操作数据库
ORM(对象关系映射)让你用 TypeScript 代码操作数据库,而不是手写 SQL。Prisma 是目前 Node.js 生态中最流行的 ORM,它通过声明式 Schema 定义模型,自动生成类型安全的查询客户端。这一部分我们完成 Prisma 的初始化、模型定义与数据库迁移。
Prisma 初始化
三步开始:安装、初始化、定义模型
$ npm install prisma -D
$ npx prisma init
# .env 文件中自动添加:
DATABASE_URL="postgresql://user:pass@localhost:5432/myapp"
初始化后会生成 prisma/schema.prisma 文件——这是你定义数据模型的地方。
Prisma 工作流
从改模型到数据库生效的完整循环

图 A:Prisma 核心开发循环
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个环形循环流程图,展示 Prisma 开发的核心循环(从改模型到代码使用的闭环) - 循环步骤(顺时针排列,用弧形箭头连接): 1. 顶部中央:`schema.prisma` 文件图标,标注"① 定义/修改数据模型",内部显示关键字段:`model User { id, email, name, posts }` 2. 右上:终端命令 `npx prisma migrate dev`,标注"② 生成迁移文件 + 应用到数据库",命令用等宽字体 3. 右侧:`prisma/migrations/` 文件夹图标,标注"迁移文件(SQL)",内部展示 `CREATE TABLE "User"...` 4. 右下:数据库图标(PostgreSQL 大象 logo 风格),标注"③ 数据库 Schema 更新" 5. 底部中央:终端命令 `npx prisma generate`,标注"④ 生成 Prisma Client" 6. 左下:`node_modules/.prisma/client` 文件图标,标注"类型安全的查询客户端" 7. 左侧:代码编辑器图标,显示 TypeScript 代码 `const users = await prisma.user.findMany()`,标注"⑤ 在代码中使用 Prisma Client",旁边用绿色勾标注"自动补全 + 类型检查" 8. 左上:回到 schema.prisma,用虚线箭头标注"⑥ 模型变更 → 重复循环" - 中央区域放一个醒目的标签:"Prisma 开发循环" - 配色建议:浅灰背景,主循环用蓝色系,命令/代码用深色等宽字体,数据库用绿色,Prisma Client 用紫色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、箭头交叉混乱 - 标注文字用中文,命令和代码用等宽字体

图 B:Prisma 生产部署与工具
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:展示 Prisma 在生产环境中的部署流程和辅助工具 - 左侧区域(标注"生产部署流程",浅橙色背景): - 步骤1:CI/CD 管道图标 → 终端命令 `npx prisma migrate deploy`,标注"只应用迁移,不生成新迁移文件" - 步骤2:箭头指向数据库图标,标注"数据库 Schema 更新" - 步骤3:`npx prisma generate` 命令,标注"生成 Prisma Client" - 步骤4:应用服务器图标,标注"应用启动,使用 Prisma Client" - 右侧区域(标注"辅助工具",浅蓝色背景): - 工具1:`npx prisma studio` 命令方块,标注"浏览器可视化管理工具",下方画一个浏览器窗口示意图,显示表格数据 - 工具2:`npx prisma db push` 命令方块,标注"原型开发快速同步(不生成迁移文件)" - 工具3:`npx prisma validate` 命令方块,标注"验证 Schema 语法" - 两个区域用虚线分隔 - 配色建议:浅灰背景,部署流程用橙色系,辅助工具用蓝色系,命令用等宽字体 - 画面比例:16:9 - 避免:过度装饰、3D 效果、文字过小 - 标注文字用中文,命令用等宽字体
定义模型
在 schema.prisma 中用声明式语法定义数据结构
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
@@map("posts")
}
enum Role {
USER
ADMIN
}
ER 关系图
User 和 Post 是一对多关系——一个用户可以有多篇文章

图:User-Post-Role 实体关系图
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一张 ER(实体关系)图,展示 User、Post、Role 三个实体及其字段与关系 - 三个实体方框: - User 实体(蓝色方框):字段包括 id(PK,标注主键)、name、email(UK,标注唯一)、password、role、createdAt、updatedAt - Post 实体(绿色方框):字段包括 id(PK)、title、content、published(布尔类型)、authorId(FK,标注外键)、createdAt - Role 枚举(橙色小方框):枚举值 USER、ADMIN - 各元素关系: - User 到 Post:一对多关系(一个 User 写 多个 Post),连线标注 "writes",用 "1 对 N" 的鸦脚符号表示 - User 到 Role:多对一关系(多个 User 属于 一个 Role),连线标注 "has",用 "N 对 1" 表示 - 关键标注:主键用金色钥匙图标标注 PK,外键用蓝色箭头标注 FK,唯一约束用绿色下划线标注 UK - 配色建议:浅灰背景,User 方框浅蓝底色,Post 方框浅绿底色,Role 方框浅橙底色,关系连线用深灰色,关系标签用深色文字 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
@relation 定义了外键关系,@@map 把模型名映射到数据库表名。
数据库迁移
模型改了,用迁移命令同步到数据库
# 创建迁移并应用
$ npx prisma migrate dev --name init
# 生成 Prisma Client(TypeScript 类型安全的查询 API)
$ npx prisma generate
迁移就像 Git 的 commit——每次改了模型,执行一次 migrate dev,Prisma 会自动生成 SQL 并记录历史。出了问题可以回滚。
数据填充(Seeding)
一键生成测试数据,不用每次手动插
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// 清空旧数据
await prisma.post.deleteMany();
await prisma.user.deleteMany();
// 创建用户 + 文章(嵌套创建)
const alice = await prisma.user.create({
data: {
name: "Alice",
email: "alice@example.com",
password: "hashed",
posts: {
create: [
{ title: "第一篇", content: "Hello World", published: true },
{ title: "第二篇", content: "学习 Prisma", published: false },
],
},
},
});
const bob = await prisma.user.create({
data: {
name: "Bob",
email: "bob@example.com",
password: "hashed",
posts: {
create: [{ title: "Bob 的文章", content: "你好", published: true }],
},
},
});
console.log({ alice, bob });
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
// package.json 中注册 seed 脚本
{
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
}
# 执行 seed
$ npx prisma db seed
Part 3: CRUD 与高级查询
增删改查是基本功,关联查询和事务是进阶能力
有了模型和迁移,接下来用 Prisma Client 执行增删改查。这一部分从基础的 CRUD 开始,逐步扩展到关联查询、多对多关系、数据库索引和 N+1 优化,让你真正掌握 Prisma 的查询能力。
初始化 Client
创建一个共享的 Prisma 实例,整个项目复用
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
Create 创建
一行代码插入一条数据
import { prisma } from "./lib/prisma";
const user = await prisma.user.create({
data: {
name: "张三",
email: "zhangsan@example.com",
password: "hashed_password",
},
});
// user 已经是完整的类型化对象
console.log(user.id); // string (UUID)
console.log(user.name); // "张三"
Read 查询
findMany、findUnique、条件查询
// 查所有
const allUsers = await prisma.user.findMany();
// 按 ID 查
const userById = await prisma.user.findUnique({ where: { id: "uuid" } });
// 按唯一字段查
const byEmail = await prisma.user.findUnique({ where: { email: "a@b.c" } });
// 条件查询 + 排序 + 分页
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true, name: true, email: true },
orderBy: { createdAt: "desc" },
take: 10,
});
Update 与 Delete
修改和删除都需要 where 条件
// 更新
const updated = await prisma.user.update({
where: { id: "uuid" },
data: { name: "李四" },
});
// 删除
await prisma.user.delete({ where: { id: "uuid" } });
关联查询
一次查询拿到关联数据——不需要手写 JOIN
// 查询文章时带上作者信息
const postsWithAuthor = await prisma.post.findMany({
include: { author: true },
});
// postsWithAuthor[0].author.name —— 完整类型推断
// 创建带关联的数据
const post = await prisma.post.create({
data: {
title: "第一篇文章",
content: "内容...",
author: { connect: { id: userId } },
},
});
// 嵌套创建——同时创建用户和文章
const newUser = await prisma.user.create({
data: {
name: "王五",
email: "wangwu@example.com",
password: "hashed",
posts: {
create: [
{ title: "文章一", content: "内容一" },
{ title: "文章二", content: "内容二" },
],
},
},
include: { posts: true },
});
多对多关系
一篇文章可以有多个标签,一个标签也可以属于多篇文章
// prisma/schema.prisma — 隐式多对多(Prisma 自动建中间表)
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
tags Tag[] // 多对多关系
author User @relation(fields: [authorId], references: [id])
authorId String @map("author_id")
@@map("posts")
}
model Tag {
id String @id @default(uuid())
name String @unique
posts Post[] // 多对多关系
@@map("tags")
}
// 查询:文章带标签
const posts = await prisma.post.findMany({
include: { tags: true, author: true },
});
// 创建:给文章打标签
await prisma.post.update({
where: { id: postId },
data: {
tags: { connect: [{ id: tagId1 }, { id: tagId2 }] },
},
});
Prisma 的隐式多对多会自动创建中间表 _PostToTag,你不需要手动定义。如果中间表需要额外字段(如打标签的时间),则需要用显式多对多,自己定义中间模型。
数据库索引
索引让数据库不用全表扫描,查询速度从秒级降到毫秒级
// schema.prisma 中添加索引
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
authorId String @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
author User @relation(fields: [authorId], references: [id])
@@index([authorId]) // 单字段索引:按作者查文章
@@index([published, createdAt]) // 复合索引:查已发布+按时间排序
@@map("posts")
}
什么时候加索引? 看你的 where 和 orderBy 用了哪些字段。@unique 自动带索引,主键也是。复合索引遵循最左前缀原则:@@index([a, b]) 能加速 where: { a, b } 和 where: { a },但不能加速 where: { b }。
# 索引随迁移一起生效
$ npx prisma migrate dev --name add-post-indexes
N+1 查询问题
最常见的性能杀手:查 1 次列表 + N 次详情 = 数据库被炸
// ❌ N+1 问题:查 100 篇文章,就要再查 100 次作者
const posts = await prisma.post.findMany();
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId },
});
// 101 条 SQL!
}
// ✅ 修复:用 include 一次搞定
const posts = await prisma.post.findMany({
include: { author: true },
}); // 只需 1 条 SQL(Prisma 会优化为 JOIN 或批量查询)
// ✅ 用 select 只取需要的字段,减少数据传输
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
author: { select: { name: true } }, // 只取作者名字
},
});
// ✅ 复杂查询用 $queryRaw 直接写 SQL
const stats = await prisma.$queryRaw`
SELECT author_id, COUNT(*) as post_count
FROM posts
WHERE published = true
GROUP BY author_id
ORDER BY post_count DESC
LIMIT 10
`;
new PrismaClient({ log: ['query'] })),如果一个页面加载产生了大量 SQL,很可能就是 N+1 问题。
事务
多个操作要么全部成功,要么全部回滚
const result = await prisma.$transaction(async (tx) => {
// 操作1:创建用户
const user = await tx.user.create({
data: { name: "赵六", email: "zhaoliu@example.com", password: "hashed" },
});
// 操作2:创建文章
await tx.post.create({
data: { title: "欢迎", content: "你好世界", authorId: user.id },
});
return user;
});
// 如果操作2失败,操作1也会自动回滚
事务就像"打包寄快递"——要么整包送达,要么整包退回。不会出现"用户创建了但文章没创建"的半成品状态。
中间件与扩展
用 Prisma Extensions 拦截和增强数据库操作
// 查询日志中间件
const prisma = new PrismaClient().$extends({
name: "query-logger",
query: {
async $allOperations({ operation, model, args, query }) {
const start = Date.now();
const result = await query(args);
const duration = Date.now() - start;
console.log(`[${model}.${operation}] ${duration}ms`);
return result;
},
},
});
// 软删除扩展:把 delete 变成更新 deletedAt 字段
const softDelete = prisma.$extends({
name: "soft-delete",
model: {
user: {
async delete(args) {
return prisma.user.update({
where: args.where,
data: { deletedAt: new Date() },
});
},
},
},
});
Prisma Extensions 是 Prisma 4.16+ 引入的强大功能,可以实现查询日志、软删除、字段加密等横切关注点,而无需修改业务代码。
连接池与生产配置
从开发到上线,配置要做哪些调整?
# .env — 在 DATABASE_URL 中配置连接池
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10"
# 开发环境:创建迁移 + 应用到数据库
$ npx prisma migrate dev --name add-idx
# 生产环境:只应用迁移,不创建新迁移
$ npx prisma migrate deploy
| 场景 | connection_limit 建议 | 说明 |
|---|---|---|
| 开发 | 5 | 单人使用,够用就好 |
| 小型应用 | 10-20 | Serverless 按需调高 |
| 大型应用 | 20-50 | 配合外置连接池(PgBouncer) |
migrate dev! 它会提示你输入迁移名称,可能在自动化部署中卡住。始终使用 migrate deploy。
Part 4: MongoDB 深入
文档数据库的核心操作与进阶技巧
MongoDB 是最流行的 NoSQL 文档数据库,用 Mongoose 作为 ODM 可以获得与 Prisma 类似的开发体验。这一部分从 Mongoose 基础 CRUD 开始,扩展到虚拟字段、索引优化和聚合管道,帮你全面掌握 MongoDB 的核心能力。
Mongoose 入门
用 Schema 定义文档结构,用 Model 执行操作
$ npm install mongoose
import mongoose from "mongoose";
// 定义 Schema
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
role: { type: String, enum: ["user", "admin"], default: "user" },
}, { timestamps: true });
const User = mongoose.model("User", userSchema);
// CRUD
const user = new User({ name: "张三", email: "a@b.c", password: "hashed" });
await user.save();
const found = await User.findById("id");
const updated = await User.findByIdAndUpdate("id", { name: "李四" }, { new: true });
await User.findByIdAndDelete("id");
虚拟字段与实例方法
不存入数据库,但可以像真字段一样使用
// 虚拟字段:不存入 DB,由其他字段计算而来
userSchema.virtual("displayName").get(function () {
return `${this.name} <${this.email}>`;
});
// 实例方法:每个文档都可以调用的方法
userSchema.methods.toJSON = function () {
const obj = this.toObject();
delete obj.password; // 返回时自动隐藏密码
return obj;
};
// 静态方法:在 Model 上调用
userSchema.statics.findByRole = function (role) {
return this.find({ role });
};
// 使用
const user = await User.findById("id");
console.log(user.displayName); // "张三 "
console.log(user.toJSON()); // { name, email, role } 无 password
const admins = await User.findByRole("admin");
虚拟字段适合用于派生数据(如全名、格式化日期),不需要额外存储空间。实例方法适合封装业务逻辑(如密码隐藏、格式转换)。
MongoDB 索引基础
索引让查询从"翻整本书"变成"查目录"
// 在 Schema 中定义索引
const postSchema = new mongoose.Schema({
title: { type: String, required: true },
content: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
tags: [String],
}, { timestamps: true });
// 单字段索引
postSchema.index({ title: 1 });
// 复合索引(先按作者,再按创建时间倒序)
postSchema.index({ author: 1, createdAt: -1 });
// 文本索引(支持全文搜索)
postSchema.index({ title: "text", content: "text" });
// 唯一索引
userSchema.index({ email: 1 }, { unique: true });
聚合管道
像流水线一样,把数据一步步加工成你要的形状
// 需求:统计每个用户发了多少篇文章,只看已发布的
const result = await Post.aggregate([
// 第1步:筛选已发布的文章
{ $match: { published: true } },
// 第2步:按作者分组,计算每组的文章数
{ $group: {
_id: "$author",
postCount: { $sum: 1 },
latestPost: { $max: "$createdAt" },
}},
// 第3步:按文章数倒序排列
{ $sort: { postCount: -1 } },
// 第4步:只取前 10 名
{ $limit: 10 },
// 第5步:关联用户信息
{ $lookup: {
from: "users",
localField: "_id",
foreignField: "_id",
as: "author",
}},
{ $unwind: "$author" },
]);
聚合管道是 MongoDB 最强大的查询工具——$match 过滤、$group 分组统计、$lookup 关联、$sort/$limit 排序分页,串联起来就能完成复杂的分析任务。
常见问题
问题一:prisma migrate dev 报连接失败
原因:PostgreSQL 没启动,或 DATABASE_URL 配置错误。
修复:确认 PostgreSQL 在运行,检查 .env 中的连接字符串是否正确。
问题二:查询返回 null 但数据明明在
原因:where 条件写错了,或字段名的大小写不对。
修复:Prisma 的字段名用模型定义的名字(驼峰),不是数据库列名(下划线)。
问题三:prisma.user 没有类型提示
原因:忘记执行 npx prisma generate。
修复:每次改完 schema 后都要运行 npx prisma generate 来更新 Client 类型。
试一试
练习一
任务:在 Prisma schema 中定义一个 Comment 模型,包含 content、postId、authorId 字段,并运行迁移。
// 提示:Comment 和 Post 是多对一关系
// 你的代码:
参考答案
model Comment {
id String @id @default(uuid())
content String
post Post @relation(fields: [postId], references: [id])
postId String @map("post_id")
author User @relation(fields: [authorId], references: [id])
authorId String @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
@@map("comments")
}
练习二
任务:写一个查询,获取所有已发布的文章(published: true),按创建时间倒序排列,并包含作者信息。
// 提示:where + orderBy + include
// 你的代码:
参考答案
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
include: { author: { select: { name: true, email: true } } },
});
练习三:揪出 N+1
任务:下面这段代码有 N+1 问题。先读懂它错在哪,再用一次查询重写。
// 问题代码:获取 10 篇文章及每篇文章的作者名
const posts = await prisma.post.findMany({ take: 10 });
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId }
});
console.log(post.title, author.name);
}
// 问题:1 次查文章 + 10 次查作者 = 11 次数据库往返
提示:回顾 include 的用法——它能让你在查文章时"顺手"把关联的作者一起捞回来。
参考答案
// 一次查询搞定,只发 1 条 SQL
const posts = await prisma.post.findMany({
take: 10,
include: { author: { select: { name: true } } },
});
for (const post of posts) {
console.log(post.title, post.author.name);
}
怎么自查 N+1:打开 Prisma 的 query log(log: ['query']),或在数据库控制台看慢查询。如果一次业务操作触发的查询数随数据条数线性增长,几乎可以断定是 N+1。
练习四:转账事务
任务:实现一个 transfer 函数,从 A 账户扣款、给 B 账户加款,保证中途出错时两边都不变。
// 账户模型(假设已存在)
// model Account { id String @id; balance Int }
// 提示:用 prisma.$transaction 包住两步操作
async function transfer(fromId, toId, amount) {
// 你的代码:
}
关键点:扣款后、加款前如果程序崩了,钱就"消失"了。事务保证要么全成功、要么全回滚。
参考答案
async function transfer(fromId, toId, amount) {
await prisma.$transaction([
prisma.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
}),
prisma.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
}),
]);
// 任一语句失败 → 整个事务回滚,两边余额都不变
📝 要点总结
本章核心知识点
数据库选型
SQL 适合结构化数据和复杂查询,NoSQL 适合灵活 schema 和高写入场景
Prisma 模型
schema.prisma 定义数据结构,migrate 同步数据库,generate 生成类型
CRUD 与查询
Prisma Client 提供类型安全的数据库操作,include 解决关联查询
性能优化
索引加速查询,事务保证一致性,避免 N+1 用 include 或 batch
下一章
数据能存能查了。下一章学习API 设计与验证——让接口既好用又安全。