数据持久化

数据库与 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、MySQLMongoDB、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[]
永远不要拼接 SQL 字符串! 用参数化查询($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 核心开发循环 图 B:Prisma 生产部署与工具

定义模型

在 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 实体关系图

@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" } });
忘记写 where 会报错。 Prisma 的 update 和 delete 必须带 where 条件,这是防止误操作的安全机制。

关联查询

一次查询拿到关联数据——不需要手写 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
`;
识别 N+1 的方法: 在开发环境打开 Prisma 的查询日志(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-20Serverless 按需调高
大型应用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 错误都有清晰的原因描述。

问题一: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 设计与验证——让接口既好用又安全。

1 / X