
2026-09-16 · 阅读 6
数据层补课:MongoDB / Mongoose / ORM 选型 / zod / RESTful 一次讲透
D6 · 数据层补课:MongoDB / Mongoose / ORM 选型 / zod / RESTful 一次讲透
覆盖:MongoDB 文档模型、Mongoose 三层概念、TypeORM vs Prisma 选型、zod 运行时校验、RESTful 设计规范,附 60 秒口述稿。
一、MongoDB 基本概念
1. 一句话总结
MongoDB 就是一个存 JSON 的数据库——每条数据是一个「文档」,长得和前端天天用的 JSON 对象一模一样。
2. 它解决关系型数据库的两个老痛点
- 表结构死板:上线想加字段要
ALTER TABLE,大表还会锁表;互联网业务一周迭代三次扛不住 - 对象 ↔ 表的「翻译损耗」:程序里一个「用户」对象含收货地址数组 → 存 MySQL 得拆成两张表、两行,查出来再拼回去
MongoDB 的答案:一个对象存成一个文档,嵌套结构原样进库,程序和数据库之间不再需要"翻译"。
3. 概念映射表(MySQL → MongoDB)
| MySQL | MongoDB | 说明 |
|---|---|---|
| Database | Database | 一样 |
| Table 表 | Collection 集合 | 不强制结构 |
| Row 行 | Document 文档 | 一个 BSON 文档 ≈ 一个 JSON 对象 |
| Column 列 | Field 字段 | 同一集合里字段可以不同 |
| 主键 id | _id | 不传就自动生成 ObjectId |
| JOIN | $lookup(能不用就不用) | 性能差,靠嵌套设计规避 |
| GROUP BY | 聚合管道 $group | aggregate 一节节管道流过去 |
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 字节计数器。三个好处:
- 自带创建时间:前 4 字节能反解出时间戳,不用单独存 created_at 也能知道先后
- 趋势递增:天然按时间有序,可直接按
_id排序 - 分布式不冲突:各机器自己生成,不需要像 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 静态方法 | 工厂 |
| Document | Model 的实例,一个对象 = 一条文档,有 .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. 常见坑
mongoose.model('User')→ collection 是 users(复数小写),初学者查错集合名找不到数据的头号原因findByIdAndUpdate默认不触发 Schema 校验,要加{ runValidators: true }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. 对比总表(口述按这四个维度说)
| 维度 | TypeORM | Prisma |
|---|---|---|
| 类型安全 | 弱(装饰器魔法,类型推断经常断) | 最强(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 天然不幂等——属性永远不变 |
| 业务接口设计 | 程序员 | 唯一单号 + 状态机 + 唯一索引,让「重复调用一次和调用一次效果相同」 |
为什么支付回调必须自己做幂等:
- POST 不幂等 + 网络会重试:没应答 success,支付宝/微信就重发回调——同一个 POST 打过来多次
- 你控制不了第三方:方法用 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 不是——所以支付回调这类会被重试的接口,必须自己做幂等设计,比如唯一单号加状态机。
七、面试高频问法清单
- MongoDB vs MySQL 怎么选? → 三维度:数据形态 / 事务要求 / 混合架构口径
- 评论盖楼为什么适合 MongoDB? → 怎么读就怎么存,一次查询免 JOIN
- _id 的组成和好处? → 4 时间戳 + 5 随机 + 3 计数器;自带时间 / 趋势递增 / 分布式不冲突
- Schema / Model / Document? → 图纸 / 工厂 / 产品,编译与生产两级关系
- populate 和 JOIN 的区别? → 数据库一条 SQL vs 应用层两步查询拼装
- TypeORM vs Prisma 怎么选? → 四维 trade-off:类型安全 / 学习曲线 / 生态 / 短板
- Active Record vs Data Mapper? → 员工自己记账 vs 会计专门记账
- 有了 TS 为什么还要 zod? → TS 编译时、zod 运行时,类型擦掉后只有 zod 还在岗
- 参数校验在哪一层做? → 边界处 zod 先拦,数据层校验兜底;永远不信任何输入
- 哪些 HTTP 方法幂等?支付回调怎么办? → GET/PUT/DELETE 幂等、POST 不幂等;业务层唯一单号 + 状态机把接口设计成幂等
评论 (0)
还没有评论,来抢沙发吧~