Portfolio
← 返回博客列表
 数据层补课:MongoDB / Mongoose / ORM 选型 / zod / RESTful 一次讲透

2026-09-16 · 阅读 6

数据层补课:MongoDB / Mongoose / ORM 选型 / zod / RESTful 一次讲透

MongoDB ,Mongoose ,TypeORM ,Prisma ,zod ,RESTful

D6 · 数据层补课:MongoDB / Mongoose / ORM 选型 / zod / RESTful 一次讲透

覆盖:MongoDB 文档模型、Mongoose 三层概念、TypeORM vs Prisma 选型、zod 运行时校验、RESTful 设计规范,附 60 秒口述稿。


一、MongoDB 基本概念

1. 一句话总结

MongoDB 就是一个存 JSON 的数据库——每条数据是一个「文档」,长得和前端天天用的 JSON 对象一模一样。

2. 它解决关系型数据库的两个老痛点

  1. 表结构死板:上线想加字段要 ALTER TABLE,大表还会锁表;互联网业务一周迭代三次扛不住
  2. 对象 ↔ 表的「翻译损耗」:程序里一个「用户」对象含收货地址数组 → 存 MySQL 得拆成两张表、两行,查出来再拼回去

MongoDB 的答案:一个对象存成一个文档,嵌套结构原样进库,程序和数据库之间不再需要"翻译"。

3. 概念映射表(MySQL → MongoDB)

MySQLMongoDB说明
DatabaseDatabase一样
Table 表Collection 集合不强制结构
Row 行Document 文档一个 BSON 文档 ≈ 一个 JSON 对象
Column 列Field 字段同一集合里字段可以不同
主键 id_id不传就自动生成 ObjectId
JOIN$lookup(能不用就不用)性能差,靠嵌套设计规避
GROUP BY聚合管道 $groupaggregate 一节节管道流过去

BSON = Binary JSON,二进制版 JSON,多了日期、数字类型等 JSON 没有的类型。

4. 核心设计思想(面试官想听的层面)

① 反范式嵌套——「数据跟着查询走」

MySQL 用范式消除冗余(地址单独一张表);MongoDB 反着来:宁可冗余,把一起读的数据塞进同一个文档,一次查询全拿走,避免 JOIN。

// 用户 + 收货地址:MySQL 要 2 张表 + LEFT JOIN;MongoDB 一个文档搞定
db.users.insertOne({
  name: "小明",
  tags: ["vip", "新用户"],
  address: [
    { type: "home", city: "深圳" },
    { type: "work",  city: "广州" }
  ]
})

db.users.find({ "address.city": "深圳" })   // 点号直达嵌套字段
db.users.find({ tags: "vip" })              // 数组"包含"也能查

② 单文档原子性 → 事务压力天然小

对一个文档的修改是原子的。所以设计上:需要强一致的数据放同一个文档里,根本不需要跨文档事务。4.0 之后 MongoDB 也支持多文档事务,但那是兜底,不是常规手段。

③ ObjectId 自己就够用

_id 默认是 12 字节的 ObjectId = 4 字节时间戳 + 5 字节随机值 + 3 字节计数器。三个好处:

  1. 自带创建时间:前 4 字节能反解出时间戳,不用单独存 created_at 也能知道先后
  2. 趋势递增:天然按时间有序,可直接按 _id 排序
  3. 分布式不冲突:各机器自己生成,不需要像 MySQL 自增主键那样由数据库统一发号

5. 为什么「评论盖楼」特别适合 MongoDB

怎么读就怎么存:帖子和评论总是一起读,那就存成一个文档,一次查询全拿到、不用 JOIN。反观 MySQL:posts 表 + comments 表 + LEFT JOIN,盖楼还要自连接查树形结构。评论字段还多变(图、at、表情回复),灵活 schema 正好接得住。

6. 常见误区(说出来就是加分项)

  • ❌「MongoDB 没事务」——过时。4.0 起支持多文档事务(副本集),但性能开销大,正确姿势是嵌套设计规避,而不是硬上事务
  • ❌「schema-less = 不用设计表结构」——错。只是约束从数据库层挪到了应用层(这就是 Mongoose 存在的意义),索引照样要建(也是 B-tree,思路和 MySQL 一样)

二、Mongoose:给 MongoDB 加上约束

1. 一句话总结

Mongoose 是 MongoDB 的 ODM(Object Document Mapping,对象-文档映射)——给"自由散漫"的 MongoDB 在应用层加一层 Schema 约束和校验。

2. 为什么存在

原生驱动(mongodb 包)只是个"传话筒":JSON 进、JSON 出,字段名写错、类型传乱都不管。生产上必须有人管结构 → 数据库层不管的,Mongoose 在代码层管。记住这个等式:

TypeORM/Prisma 之于 MySQL = Mongoose 之于 MongoDB(一个映射表,一个映射文档)

3. 三层核心概念(生产流水线类比)

概念是什么类比
Schema定义字段、类型、校验规则的"模具"图纸
Model由 Schema 编译出的构造函数,对应一个 collection,身上挂着 CRUD 静态方法工厂
DocumentModel 的实例,一个对象 = 一条文档,有 .save() 等实例方法产品

关系链:mongoose.model('User', schema) 是编译(图纸 → 工厂),new User() 是生产(工厂 → 产品)。

4. 完整示例(重点看注释)

const mongoose = require('mongoose');

// 1. 图纸:字段 + 类型 + 校验 + 默认值
const userSchema = new mongoose.Schema({
  name:  { type: String, required: [true, '名字必填'] },
  age:   { type: Number, min: 0, default: 18 },
  email: { type: String, lowercase: true }
}, { timestamps: true });        // 自动维护 createdAt / updatedAt

// 2. pre 钩子 = 中间件思想(和 Express 中间件同源):保存前自动插一手
userSchema.pre('save', function () {
  console.log('即将保存:', this.name);   // 实际项目:这里加密密码
});

// 3. 工厂:注意 → collection 名自动是复数小写 users
const User = mongoose.model('User', userSchema);

// CRUD 四件套(全是 Model 上的静态方法)
await User.create({ name: '小明' });              // 增
await User.find({ age: { $gte: 18 } });           // 查  ($gte = 大于等于)
await User.findByIdAndUpdate(id, { age: 20 });    // 改
await User.findByIdAndDelete(id);                 // 删

反例(不用 Mongoose):直接 db.collection('users').insertOne({...}) ——字段名打错、age 传个字符串都能进库,脏数据没人拦。

5. 关联查询 populate(高频追问)

const postSchema = new Schema({
  title:  String,
  author: { type: Schema.Types.ObjectId, ref: 'User' }   // 存 User 的 _id
});

const posts = await Post.find().populate('author');
// author 字段从 "66f3..." 这个 id 被替换成完整的 user 文档

populate ≠ JOIN(必背):JOIN 是数据库一条 SQL 完成;populate 是应用层两步——先查出 posts 拿到 author 的 id 列表,再去 users 集合查一次拼装回来。数据量大时注意 N+1 问题。所以 MongoDB 的哲学还是:能嵌套就嵌套,关联是下策。

6. 常见坑

  1. mongoose.model('User') → collection 是 users(复数小写),初学者查错集合名找不到数据的头号原因
  2. findByIdAndUpdate 默认不触发 Schema 校验,要加 { runValidators: true }
  3. unique: true 不是校验器,是建唯一索引——重复时报的是 E11000 错误,不是 ValidationError

三、TypeORM vs Prisma:ORM 选型

1. 先定位:ORM 解决什么

ORM(对象-关系映射)= 让你用操作 JS 对象的方式操作数据库,换回三样东西:不手拼 SQL → 天然防 SQL 注入;实体类即表结构 → 单一事实来源;换数据库理论上改配置不改业务代码。一句话:ORM 是"程序员和 SQL 之间的翻译官"。

2. TypeORM:装饰器实体 + 两种经典模式

// 实体 = 一个类,装饰器描述表结构
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;
}

// 操作:save / find / findOne 都是现成方法
await userRepository.save(user);

设计模式考点:Active Record vs Data Mapper

模式做法类比适用
Active Record实体自己带增删改查:user.save()、User.find()员工自己又会干活又会记账小项目,快
Data Mapper实体是纯数据,操作交给独立的 Repository员工只干活,账本交给会计(Repository)大项目,职责分离

TypeORM 两种都支持;NestJS 标配(@InjectRepository 依赖注入)。

3. Prisma:声明式 Schema + 代码生成

// schema.prisma —— 一份文件描述数据模型(单一事实来源)
model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  posts Post[]
}
// npx prisma generate 生成强类型 client
const user = await prisma.user.findUnique({ where: { email } });
// ↑ prisma.user. 之后全自动补全、参数类型全推导——这就是它最大的卖点

设计思想:不写实体类,从 schema 文件"生成"类型安全的 client(思路同 protobuf:单一事实来源 + 代码生成)。

4. 对比总表(口述按这四个维度说)

维度TypeORMPrisma
类型安全弱(装饰器魔法,类型推断经常断)最强(client 全生成)
学习曲线装饰器 + 概念多,文档坑不少上手快,migration 工具链顺滑
生态NestJS 深度集成、老项目多新项目主流,框架无关
短板维护口碑一般、复杂查询类型丢失复杂查询得写 raw SQL;查询引擎是独立二进制

选型口径:新项目、重类型安全选 Prisma;NestJS 存量项目或需要 DI + Repository 模式的大型分层架构,TypeORM 更顺。核心是 trade-off:Prisma 用代码生成换类型安全,TypeORM 用装饰器换灵活性。


四、zod:运行时校验,补 TS 管不到的那半边

1. 为什么有了 TS 还要 zod

TypeScript 的类型是编译时的——打包后类型注解全部被擦掉。用户 POST 过来的 req.body 在运行时就是一坨不可信的 JSON,TS 救不了你。zod 就是把校验搬到运行时的那道闸门(呼应面试教训:永远不要信前端传的 role 参数——zod 就是把"不信"写成代码的地方)。

2. 一份 schema,两个用途

import { z } from 'zod';

// 一份 schema,两个用途
const CreateUserDTO = z.object({
  name:  z.string().min(1, '名字不能为空'),
  email: z.string().email('邮箱格式不对'),
  age:   z.number().int().min(0).optional(),
});

// 用途 1:运行时校验(API 边界,第一个进门的人先安检)
const parsed = CreateUserDTO.safeParse(req.body);
if (!parsed.success) {
  return res.status(400).json(parsed.error.flatten());  // 带具体哪个字段错
}

// 用途 2:静态类型自动长出来,不用手写两遍
type CreateUser = z.infer<typeof CreateUserDTO>;
// 等价于 { name: string; email: string; age?: number }

反例(不用 zod 的世界):每个接口手写 if (!name || typeof name !== 'string') ...、正则验邮箱……字段一多必失控,而且类型和校验逻辑写两遍、迟早不同步。

3. 校验分层(说清职责边界)

用户输入 ──► zod(HTTP 边界:格式/必填/范围,第一道闸)
                │
                ▼
          Mongoose Schema / ORM(数据层:落库前最后一道,类型+唯一索引)

两者是双保险不是替代关系。zod 框架无关、TS-first,正在取代 NestJS 传统的 class-validator(装饰器那套)成为新项目标配。


五、RESTful 设计规范

1. 一句话总结

REST = URL 只表示资源(名词),动作全部交给 HTTP 方法(动词),通信无状态。

2. 四条核心规范

① URL 是名词复数,永远不出现动词

✅ GET  /api/v1/users        查列表
✅ GET  /api/v1/users/42     查单个
✅ POST /api/v1/users        新增
✅ PUT  /api/v1/users/42     全量更新
✅ PATCH /api/v1/users/42    部分更新(只改传了的字段)
✅ DELETE /api/v1/users/42   删除
❌ /getUser?id=1  /deleteUser —— 动词跑进 URL,一眼外行

嵌套资源:GET /users/42/orders = 查 42 号用户的订单。

② 状态码用准,别全返 200

码场景
200 / 201 / 204成功 / 创建成功 / 删除成功(无内容返回)
400 / 401 / 403 / 404 / 409参数错 / 未登录 / 已登录但没权限 / 不存在 / 冲突(如重复创建)
500 / 503服务器内部错误 / 服务不可用

③ 过滤、分页、排序放 query 参数,不放路径

/users?page=2&size=20&sort=createdAt&role=vip

④ 无状态:每个请求自带全部信息(token),服务器不记忆上次请求——这是 JWT 能横行的理论基础。

3. 幂等性:两层要分清(大杀器)

层面谁负责内容
HTTP 方法语义协议天生GET/PUT/DELETE 天然幂等,POST 天然不幂等——属性永远不变
业务接口设计程序员唯一单号 + 状态机 + 唯一索引,让「重复调用一次和调用一次效果相同」

为什么支付回调必须自己做幂等:

  1. POST 不幂等 + 网络会重试:没应答 success,支付宝/微信就重发回调——同一个 POST 打过来多次
  2. 你控制不了第三方:方法用 POST 还是 PUT 人家说了算,业务层幂等是唯一兜底

类比:POST 是会重复投递的快递员(网络不可靠,天性改不了);你给每个包裹贴唯一条码(幂等键),仓库见重复条码直接拒收——快递员还是那个快递员,是你的登记制度让重复投递变得无害。

口述版: POST 方法本身天然不幂等,这个属性不会变。支付回调恰恰因为是 POST、又会被第三方重试,才必须在业务层做幂等设计——唯一单号加状态机,重复回调只生效一次。所以不是"支付的 POST 幂等",而是我们把支付回调这个接口设计成了幂等。


六、60 秒口述稿(录音用)

稿 A:MongoDB vs 关系型怎么选

MongoDB 是文档型数据库,一条数据就是一个 JSON 文档,支持嵌套,天然契合 Node 全栈。选型我按三个维度:第一看数据形态——强关系、多对多、复杂报表用 MySQL;字段多变、嵌套深的场景比如评论盖楼、商品 SKU、日志,用 MongoDB,一个文档一次查询全拿到,不用 JOIN。第二看事务——转账级强事务用 MySQL;MongoDB 单文档修改天然原子,4.0 之后也支持多文档事务,但设计上优先嵌套规避。第三是工程现实——很少二选一,常见 MySQL 存钱、MongoDB 存内容和日志的混合架构。另外 MongoDB 的 _id 是 ObjectId,自带时间戳、趋势递增、分布式生成不冲突。

稿 B:RESTful 设计规范

REST 的核心是:URL 只表示资源,动作交给 HTTP 方法,通信无状态。具体四条:一,URL 用名词复数,不出现动词,GET/POST/PUT/PATCH/DELETE 对应查、增、全量改、部分改、删;二,状态码语义化——201 创建成功、401 未认证、403 无权限、409 冲突,不能全返 200 把错误藏 body 里;三,分页排序过滤放 query 参数;四,无状态,每个请求自带 token,服务器不存会话,这也是 JWT 的理论基础。再进一步的话,GET、PUT、DELETE 是幂等的,POST 不是——所以支付回调这类会被重试的接口,必须自己做幂等设计,比如唯一单号加状态机。


七、面试高频问法清单

  1. MongoDB vs MySQL 怎么选? → 三维度:数据形态 / 事务要求 / 混合架构口径
  2. 评论盖楼为什么适合 MongoDB? → 怎么读就怎么存,一次查询免 JOIN
  3. _id 的组成和好处? → 4 时间戳 + 5 随机 + 3 计数器;自带时间 / 趋势递增 / 分布式不冲突
  4. Schema / Model / Document? → 图纸 / 工厂 / 产品,编译与生产两级关系
  5. populate 和 JOIN 的区别? → 数据库一条 SQL vs 应用层两步查询拼装
  6. TypeORM vs Prisma 怎么选? → 四维 trade-off:类型安全 / 学习曲线 / 生态 / 短板
  7. Active Record vs Data Mapper? → 员工自己记账 vs 会计专门记账
  8. 有了 TS 为什么还要 zod? → TS 编译时、zod 运行时,类型擦掉后只有 zod 还在岗
  9. 参数校验在哪一层做? → 边界处 zod 先拦,数据层校验兜底;永远不信任何输入
  10. 哪些 HTTP 方法幂等?支付回调怎么办? → GET/PUT/DELETE 幂等、POST 不幂等;业务层唯一单号 + 状态机把接口设计成幂等

评论 (0)

0 / 1000

还没有评论,来抢沙发吧~