服务端5月29日 01:09
Mongoose 聚合管道有哪些常用阶段?$match 为什么放最前面?Mongoose 聚合通过 `Model.aggregate([...stages])` 执行管道,常用阶段:`$match` 过滤文档、`$group` 分组统计(`$sum` 计数/求和、`$avg` 均值、`$push` 收集数组)、`$project` 投影和计算新字段、`$sort` 排序、`$limit/$skip` 分页、`$lookup` 关联其他集合(类似 SQL JOIN)、`$unwind` 展开数组为多条记录、`$facet` 并行执行多个子管道。`$match` 必须放最前面,因为它能利用索引减少进入后续阶段的数据量,管道是顺序执行的,越早过滤性能越好。`$lookup` 是左外连接,`localField`/`foreignField` 匹配或 `pipeline` 子查询方式,结果以数组形式放入 `as` 指定字段。
## 追问
**$lookup 的 pipeline 子查询方式和 localField/foreignField 有什么区别?** pipeline 方式支持在关联时加 `$match`、`$project` 等阶段过滤和塑形关联数据,更灵活且减少返回量;localField/foreignField 只做等值连接,无法过滤。pipeline 还支持 `let` 变量引用主文档字段。
**$unwind 会导致数据膨胀怎么办?** 对大数组 unwind 再 group 会产生大量中间文档。替代方案:用 `$reduce` 或 `$map` 在数组上直接聚合,或在 unwind 前用 `$project` 只保留需要的字段减少每条文档体积。
**$facet 有什么限制?** 各子管道不能引用其他子管道的结果,也不能包含 `$out` 或 `$merge` 阶段。facet 内每个子管道独立处理同一份输入数据,内存消耗是各子管道之和。
**聚合结果超过 100MB 内存限制怎么办?** 加 `allowDiskUse: true` 选项让中间结果写入临时文件,或优化管道尽早 `$match` + `$project` 缩减数据量。
## 写段代码
```javascript
const stats = await Order.aggregate([
{ $match: { createdAt: { $gte: new Date('2025-01-01') } } },
{ $group: { _id: '$productId', total: { $sum: '$amount' },
count: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 10 }
]);
```标签
Mongoose
Mongoose 是一个面向 MongoDB 数据库的对象数据模型(ODM)库,用于在 Node.js 环境下建模和操作 MongoDB 文档结构。它提供了一些方便的特性,如数据验证、查询构建、业务逻辑钩子(hooks)和中间件,使得处理 MongoDB 文档更加直观和安全。

服务端5月29日 01:09
Mongoose 鉴别器 Discriminator 怎么用?Discriminator 是 Mongoose 的单集合继承机制,通过 Event.discriminator('Conference', confSchema) 在同一集合中存储不同结构的文档,用 discriminatorKey 字段区分类型。所有鉴别器共享基础 Schema 的字段和索引,各自扩展独有字段,适合多态数据模型。
## 追问
**discriminatorKey 的作用是什么?**
它是 MongoDB 文档中用于标识类型的字段,默认叫 __t,可在基础 Schema 选项中自定义(如 kind、role)。创建鉴别器文档时自动写入,查询时 Mongoose 据此实例化正确的模型。
**基础 Schema 的中间件会作用于鉴别器吗?**
会。基础 Schema 的 pre/post 中间件对所有鉴别器生效,鉴别器自身的中间件只作用于该类型。利用这点可在基础 Schema 统一处理公共逻辑(如日志),鉴别器处理特有校验。
**嵌套鉴别器怎么实现?**
在子文档数组上调用 .discriminator():batchSchema.discriminator('ProductBatch', productBatchSchema),数组中的不同元素可以有不同的 Schema 结构,discriminatorKey 自动区分。
**鉴别器有什么局限?**
所有类型存在同一集合,无法对鉴别器特有字段建独立索引(索引会覆盖全集合);文档大小差异大时浪费存储;删除某类型文档需按 discriminatorKey 过滤;单集合数据量膨胀后影响所有类型的查询性能。
**鉴别器适合哪些场景?**
事件日志系统(Click/View/Purchase 共享时间戳基类)、内容管理(Article/Video/Image 共享标题和作者)、多角色用户(Admin/Customer 共享认证字段)。共同点:字段大量重叠、需要跨类型统一查询。
## 写段代码
```javascript
const eventSchema = new Schema({
name: String, date: Date
}, { discriminatorKey: 'kind' });
const Event = mongoose.model('Event', eventSchema);
const Conf = Event.discriminator('Conf',
new Schema({ speakers: [String] }));
await Conf.create({
name: 'JSConf', date: new Date(), speakers: ['Tom']
});
// MongoDB: { kind: 'Conf', name: 'JSConf', ... }
```服务端5月29日 01:09
Mongoose Model 的 CRUD 方法有哪些,查询性能怎么优化?Mongoose Model 常用 CRUD 方法:创建用 `create()` 和 `insertMany()`;查询用 `find()`、`findOne()`、`findById()`;更新用 `updateOne()`、`findByIdAndUpdate(id, update, {new: true})`;删除用 `deleteOne()`、`findByIdAndDelete()`。链式查询支持 `.select()` 投影、`.sort()` 排序、`.limit()` 分页、`.populate()` 联查。`findByIdAndUpdate` 默认返回修改前文档,需传 `{new: true}` 获取更新后数据。
## 追问
**find() 返回的结果和 lean() 有什么区别?**
find() 返回 Mongoose Document 实例,支持修改后 save()、虚拟字段、中间件;lean() 返回纯 JS 对象,内存占用低、查询速度快约 1.5-3 倍。只读展示场景务必用 lean(),需要修改并保存时才用 Document 实例。
**findByIdAndUpdate 能触发 save 中间件吗?**
不能。pre('save') 和 post('save') 只在 `doc.save()` 时触发。updateOne/findByIdAndUpdate 触发的是 `pre('updateOne')` 和 `pre('findOneAndUpdate')` 钩子。这是面试高频陷阱。
**批量操作用 insertMany 还是 bulkWrite?**
insertMany 只支持批量插入;bulkWrite 支持 insertOne/updateOne/deleteOne 混合操作,且底层走 MongoDB 的 bulk 协议,单次网络往返完成,性能更优。混合写操作场景优先 bulkWrite。
**countDocuments 和 estimatedDocumentCount 有什么区别?**
countDocuments 执行真实 COUNT 查询,支持过滤条件,结果精确;estimatedDocumentCount 读集合元数据估算,不支持条件,速度极快。无过滤条件的计数场景用 estimatedDocumentCount 更高效。
## 写段代码
```js
// 批量混合操作
await User.bulkWrite([
{ updateOne: { filter: { name: 'Tom' },
update: { $inc: { age: 1 } } } },
{ insertOne: { document: { name: 'Jerry', age: 20 } } },
{ deleteOne: { filter: { name: 'Old' } } }
]);
// 只读查询用 lean
const users = await User.find({ age: { $gte: 18 } })
.select('name age').sort({ age: -1 }).lean();
```服务端5月29日 01:09
Mongoose 插件怎么写?schema.plugin() 的原理是什么?Mongoose 插件是一个接收 `schema` 和 `options` 参数的函数,通过 `schema.plugin(pluginFn, options)` 注册。插件内部可以调用 `schema.add()` 添加字段、`schema.pre()/post()` 注册中间件、`schema.method()` 添加实例方法、`schema.static()` 添加静态方法,本质就是对 Schema 的封装扩展。常用社区插件:`mongoose-paginate-v2` 分页、`mongoose-delete` 软删除、`mongoose-unique-validator` 唯一校验。全局注册用 `mongoose.plugin(fn)` 对之后创建的所有 Schema 生效。写自定义插件时注意:接受 options 参数提供可配置字段名;通过 `schema.pre('find')` 过滤而非覆盖查询;在插件中检查依赖字段是否存在避免隐性报错。
## 追问
**插件执行顺序有影响吗?** 有。`plugin()` 按调用顺序执行,后注册的中间件排后面。如果插件 A 在 pre save 中设置字段、插件 B 依赖该字段,A 必须先注册。
**全局插件对已有模型生效吗?** 不生效。`mongoose.plugin()` 只对调用之后新创建的 Schema 生效,已编译的 Model 不会回溯应用。
**插件里能给 Schema 加虚拟属性吗?** 可以,用 `schema.virtual('name').get(fn)`。但虚拟属性不持久化到数据库,且在 lean() 查询和 JSON 序列化时需显式配置 `toJSON: { virtuals: true }` 才能输出。
**mongoose-delete 插件的 overrideMethods 选项有什么用?** 设为 true 后,`find()`、`findOne()` 等方法自动过滤已删除文档,无需手动加条件。本质是注册了查询中间件 `pre('find')` 添加 `deleted: { $ne: true }` 条件。
## 写段代码
```javascript
function paginatePlugin(schema) {
schema.statics.paginate = async function(query, { page = 1, limit = 10 }) {
const docs = await this.find(query)
.skip((page - 1) * limit).limit(limit);
const total = await this.countDocuments(query);
return { docs, total, pages: Math.ceil(total / limit) };
};
}
schema.plugin(paginatePlugin);
```服务端5月29日 01:09
Mongoose 实例方法和静态方法有什么区别?实例方法定义在 schema.methods 上,通过文档实例调用,this 指向当前文档,适合操作单条记录(如密码比对、计算总价)。静态方法定义在 schema.statics 上,通过模型类调用,this 指向模型本身,适合查询和批量操作(如按邮箱查找、统计数量)。核心区别:需不需要访问文档实例数据。
## 追问
**this 上下文在两种方法中分别是什么?**
实例方法中 this 是当前 Document 实例,可访问 this.name、this.save() 等。静态方法中 this 是 Model 构造函数,等价于 User,可直接调用 this.find()、this.countDocuments()。
**什么时候该用实例方法?**
方法逻辑依赖文档自身数据时,比如 comparePassword 需要 this.password、getTotalPrice 需要 this.items。不依赖实例数据的方法放静态方法更合理。
**静态方法能替代普通查询吗?**
可以,静态方法本质是对查询的封装,让调用方不用关心查询细节。如 User.findByEmail('x') 比 User.findOne({ email: 'x' }) 语义更清晰,也方便统一修改查询逻辑。
**两种方法能用箭头函数定义吗?**
不能。箭头函数没有自己的 this,会捕获外层作用域的 this,导致无法访问文档实例或模型。必须用 function 关键字定义。
**静态方法返回值有什么讲究?**
返回 Query 对象可支持链式调用(.sort().limit()),返回 Promise 则终止链式。推荐静态方法返回 this.find() 的 Query,让调用方决定是否加排序或分页。
## 写段代码
```javascript
// 实例方法:依赖文档数据
userSchema.methods.comparePwd = function(pwd) {
return bcrypt.compare(pwd, this.password);
};
// 静态方法:封装查询逻辑
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email });
};
// 使用
const user = await User.findByEmail('a@b.com');
await user.comparePwd('123456');
```服务端5月29日 01:08
Mongoose 查询性能怎么优化?Mongoose 性能优化核心三板斧:.lean() 返回纯 JS 对象跳过文档 hydrated 过程提速 5-10 倍;.select() 投影只取需要的字段减少传输;合理建索引覆盖高频查询。三者组合使用效果最显著,读多写少的场景优先考虑 lean。
## 追问
**.lean() 具体省掉了什么?**
Mongoose 默认将查询结果包装为 Document 实例,附带 change tracking、虚拟字段、方法等。lean() 跳过这些直接返回 POJO,丧失 save()、modify 等实例能力,但只读场景下性能提升显著。
**复合索引的 ESR 规则是什么?**
Equality → Sort → Range,索引字段按等值查询、排序、范围查询的顺序排列,让 MongoDB 最大化利用索引。如 { status: 1, createdAt: -1, age: 1 } 覆盖 WHERE status=? ORDER BY createdAt DESC WHERE age>?
**批量操作用什么 API?**
insertMany 批量插入、updateMany 批量更新、deleteMany 批量删除。单条循环操作每次都有网络往返,批量 API 一次请求完成,数量大时差距可达 10 倍以上。
**连接池怎么配?**
mongoose.connect uri 中设置 maxPoolSize(默认 100)和 minPoolSize。高并发短请求场景适当增大池,长连接场景注意 socketTimeoutMS 避免连接假死。连接应在应用启动时建立并复用,禁止请求内创建。
**为什么不建议滥用 populate?**
populate 每个引用字段都是一次额外查询,列表页查 20 条记录 populate 一个字段就变成 21 次查询。替代方案:$lookup 聚合在数据库端完成关联,或冗余存储常用字段减少关联需求。
## 写段代码
```javascript
// lean + select + 索引 三板斧
userSchema.index({ status: 1, createdAt: -1 });
const users = await User
.find({ status: 'active' })
.select('name email')
.lean();
// 批量插入替代循环
await User.insertMany(usersToInsert);
```服务端5月29日 01:08
Mongoose 和原生 MongoDB 驱动有什么区别,各适合什么场景?核心区别在于抽象层级:Mongoose 是 ODM,通过 Schema 强制文档结构、内置验证、中间件钩子和类型转换;原生驱动是薄封装,直接操作集合,无结构约束,灵活但需手动处理验证和安全。Mongoose 适合需要数据一致性保障的业务系统,原生驱动适合高性能场景和非结构化数据处理。
## 追问
**Mongoose 的性能开销主要来自哪里?**
三个来源:文档实例化(每条查询结果包装为 Mongoose Document)、Schema 验证和类型转换(save 前的校验链)、查询结果钩子(post hook 执行)。高读场景用 `.lean()` 跳过文档实例化,性能接近原生驱动。
**项目中能同时用两者吗?**
可以。Mongoose 底层连接可通过 `mongoose.connection.db` 获取原生 Db 实例。典型做法:需要验证的写操作走 Mongoose Model,批量聚合查询走原生 `collection.aggregate()`,各取所长。
**Mongoose 的 populate 和原生驱动的 $lookup 有什么区别?**
populate 是应用层联查,发两次查询再内存拼接;$lookup 是数据库层联查,一次聚合完成。大数据量时 $lookup 性能远优于 populate。populate 优势是自动按 Schema 解析和类型转换。
**什么情况下原生驱动反而更安全?**
当 Schema 定义错误导致类型转换异常时(如字符串 "123" 被静默转为数字 123),原生驱动不做隐式转换,数据原样存入,反而不会掩盖问题。对数据格式零容忍的场景,原生驱动更可控。
## 写段代码
```js
// Mongoose: Schema 约束 + 验证
const User = mongoose.model('User', new Schema({
name: { type: String, required: true },
age: { type: Number, min: 0 }
}));
await User.create({ name: 'Tom', age: 25 });
// 原生驱动: 直接操作,无约束
await db.collection('users').insertOne({ name: 'Tom', age: 25 });
```服务端5月29日 01:08
Mongoose 事务怎么用?乐观锁和悲观锁有什么区别?Mongoose 事务依赖 MongoDB 副本集(单机不支持),通过 `session` 实现多文档原子操作。核心流程:`startSession()` 创建会话,`session.startTransaction()` 开启事务,操作时每条 CRUD 必须传 `{ session }` 参数,最后 `commitTransaction()` 或 `abortTransaction()`,`endSession()` 释放资源。推荐用 `withTransaction()` 辅助方法自动提交/回滚。常见坑:忘记在操作中传 session 参数导致操作不在事务内、事务超时默认 60 秒需控制时长。并发控制方面,Mongoose 内置乐观锁:Schema 的 `__v` 字段在 `save()` 时自动递增,若文档已被修改则抛 `VersionError`;悲观锁需自行实现,通常用字段锁标记 + `findOneAndUpdate` 原子设置。
## 追问
**withTransaction() 和手动管理事务有什么区别?** `withTransaction(fn)` 自动处理 commit/abort,fn 返回时自动提交,fn 抛异常自动回滚,遇到 `TransientTransactionError` 还会自动重试。手动管理则需自己 try/catch 并调 commit/abort。
**哪些操作不支持事务?** 创建集合、创建索引等 DDL 操作在 4.4 之前不能在事务中执行。事务内也不能操作 `config`、`admin` 等系统数据库的集合。
**乐观锁的 __v 在什么场景下会失效?** 使用 `updateOne`/`findOneAndUpdate` 等不经过 `save()` 的操作时 `__v` 不会自动检查。需要手动在条件中加入版本号:`{ _id, __v: currentVersion }`。
**事务中读写同一个文档会死锁吗?** 可能。两个事务同时修改同一文档会产生写冲突,后提交的事务会抛 `WriteConflict` 错误。解决:控制事务粒度、按固定顺序访问文档、加重试逻辑。
## 写段代码
```javascript
const session = await mongoose.startSession();
await session.withTransaction(async () => {
await Account.updateOne(
{ _id: fromId, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session }
);
await Account.updateOne(
{ _id: toId },
{ $inc: { balance: amount } },
{ session }
);
});
session.endSession();
```服务端5月29日 01:08
Mongoose 子文档和引用怎么选?子文档是嵌套在父文档中的 Schema,分单个子文档和子文档数组两种形式。子文档与父文档同生同灭、原子更新,适合总是一起访问且数量有限的数据。当数据需要独立查询、数量可能很大时,改用 ObjectId 引用。
## 追问
**子文档的验证是何时触发的?**
父文档保存时,子文档的验证会自动级联触发。如果子文档校验失败,整个保存操作回滚,无需手动调用子文档验证。
**子文档怎么访问父文档?**
通过子文档的 $parent 属性:comment.$parent 可拿到所属的 post 文档,便于在子文档方法中读取父级上下文。
**向子文档数组添加元素有哪些方式?**
push/unshift 追加到数组,Mongoose 会自动标记数组为 modified。也可用 $push + $each 原子操作批量添加,避免先查再改的两步操作。
**删除子文档用什么方法?**
pull 按条件删除:post.comments.pull({ author: 'Alice' });splice 按索引删除;也可用 $pull 原子操作。pull 更安全,不会因索引偏移误删。
**子文档数组的单文档大小限制是什么?**
MongoDB 单文档上限 16MB,子文档数组过大不仅逼近限制,还导致查询和更新性能下降。经验值:数组元素超过几百条就该考虑拆分为独立集合。
## 写段代码
```javascript
const commentSchema = new Schema({
text: String, author: String
});
const postSchema = new Schema({
title: String,
comments: [commentSchema]
});
// 添加 + 删除子文档
const post = await Post.findById(id);
post.comments.push({ text: 'nice', author: 'Tom' });
post.comments.pull({ author: 'Tom' });
await post.save();
```服务端5月29日 01:08
Mongoose 中间件的 pre 和 post 钩子怎么用?Mongoose 中间件分两类:文档中间件(作用于 `save`/`validate`/`remove`,`this` 指向文档实例)和查询中间件(作用于 `find`/`findOne`/`updateOne`/`deleteOne`,`this` 指向 Query 对象)。`pre` 钩子在操作前执行,可修改数据或通过 `next(err)` 中止操作;`post` 钩子在操作后执行,接收结果参数,不能中止但适合日志和通知。异步中间件用 `async function` 即可,不需要手动调 `next()`,抛异常自动中止。经典场景:pre save 哈希密码、post save 写审计日志、pre find 过滤软删除记录。注意 `findOneAndUpdate` 不触发 `save` 钩子,需要单独注册 `findOneAndUpdate` 的 pre/post。
## 追问
**pre 钩子中 next() 和 async/await 能混用吗?** 不建议。选一种即可:传统写法调 `next(err)`,async 写法直接抛异常或正常返回。混用可能导致钩子执行两次或跳过后续中间件。
**查询中间件里怎么修改查询条件?** 通过 `this`(Query 对象)调用 `.where()`、`.skip()` 等方法,例如 `this.where({ deleted: false })`。不要直接修改 `this._conditions`,应使用 Query API。
**post 钩子能拿到修改前的值吗?** `post save` 中 `this` 是保存后的文档,`doc` 参数也是。如需旧值,在 `pre save` 中用 `this.modifiedPaths()` 记录变更字段,或用 `this.$__original()` 在 Mongoose 7+ 获取原始值。
**remove 中间件在 Mongoose 7+ 有什么变化?** `remove` 钩子已废弃,改为 `deleteOne`。文档级的 `this.deleteOne()` 触发文档中间件,`Model.deleteOne()` 触发查询中间件,两者 `this` 含义不同。
## 写段代码
```javascript
userSchema.pre('save', async function() {
if (!this.isModified('password')) return;
this.password = await bcrypt.hash(this.password, 10);
});
userSchema.post('save', function(doc) {
AuditLog.create({ action: 'save', docId: doc._id });
});
```服务端5月29日 01:08
Mongoose 数据验证有哪些类型,自定义验证器怎么写?Mongoose 验证分内置和自定义两类。内置验证器包括:required(必填)、min/max(数值范围)、minlength/maxlength(字符串长度)、enum(枚举值)、match(正则匹配)。自定义验证通过字段的 `validate` 选项实现,支持同步和异步两种方式。验证默认在 `save()` 时触发,也可显式调用 `validate()` 或 `validateSync()`。
## 追问
**unique 是验证器吗?它和 required 有什么本质区别?**
unique 不是验证器,是索引声明器。它在数据库层创建唯一索引,Mongoose 验证阶段不检查唯一性,只有 MongoDB 写入时才报错。required 才是真正的应用层验证器,在保存前就拦截。所以 unique 的错误是 MongoDB E11000,不是 Mongoose ValidationError。
**异步验证器有什么使用场景和注意事项?**
典型场景:验证邮箱/用户名是否已存在(需查库)。注意异步验证器中 `this` 指向当前文档实例,用 `this.constructor.findOne()` 避免硬编码 Model 名;更新操作默认不触发验证,需设置 `runValidators: true`。
**update 操作的验证和 save 有什么区别?**
save 触发完整验证链(内置+自定义+pre validate钩子);updateOne/updateById 默认跳过验证,需手动传 `{ runValidators: true }`,且更新验证只能校验即将修改的字段,无法访问完整文档上下文。
**validateBeforeSave 什么时候该设为 false?**
仅在数据已通过其他可信渠道验证时使用(如内部迁移脚本、测试数据填充)。生产业务代码不建议跳过,否则脏数据会直接写入数据库。
## 写段代码
```js
const userSchema = new Schema({
email: {
type: String, required: true,
validate: {
validator: async function(v) {
const doc = await this.constructor.findOne({ email: v });
return !doc || doc._id.equals(this._id);
},
message: '邮箱已被注册'
}
},
role: { type: String, enum: ['admin', 'user'], default: 'user' }
});
```服务端5月29日 01:08
Mongoose Populate 如何实现文档关联查询?Mongoose 通过 ObjectId 引用(ref)建立文档关联,再用 .populate() 将引用自动替换为完整文档。基本流程:Schema 中用 ref 声明引用,查询时链式调用 .populate('field') 即可获取关联数据,支持多字段、嵌套和条件填充。
## 追问
**populate 的深层嵌套怎么写?**
使用对象参数嵌套 populate:.populate({ path: 'comments', populate: { path: 'user' } }),可无限层级嵌套,但超过 3 层说明数据模型需要重新设计。
**如何只填充关联文档的部分字段?**
在 populate 选项中加 select:.populate({ path: 'author', select: 'name email' }),减少数据传输量。
**Virtual Populate 和普通 populate 有什么区别?**
Virtual Populate 通过 schema.virtual() 定义,关联字段不存入数据库,查询时动态填充,适合反向关联(如通过 author 查其所有 books),不占用存储空间。
**populate 的 N+1 问题怎么解决?**
populate 本质是额外查询,列表场景下每条记录都会触发一次查询。替代方案:用 aggregation 的 $lookup 在数据库端完成关联,一条聚合语句搞定,性能更优。
**条件 populate 怎么用?**
.match 过滤关联文档:.populate({ path: 'author', match: { status: 'active' } }),不匹配时该字段返回 null,需在业务层处理。
## 写段代码
```javascript
const bookSchema = new Schema({
title: String,
author: { type: Schema.Types.ObjectId, ref: 'Author' }
});
// 基本 populate + 选择字段
const book = await Book.findById(id)
.populate({ path: 'author', select: 'name email' });
// 嵌套 populate
const post = await Post.findById(id)
.populate({ path: 'comments', populate: { path: 'user' } });
```服务端5月29日 01:08
Mongoose 查询构建器的链式方法和性能优化有哪些?Mongoose 查询构建器支持两种写法:对象语法 `User.find({ age: { $gte: 18 } })` 和链式语法 `User.find().where('age').gte(18)`。链式方法包括 `.where()` 设置字段、`.gt()/.lt()/.gte()/.lte()` 范围筛选、`.in()` 匹配数组、`.sort()` 排序、`.limit().skip()` 分页、`.select()` 投影字段、`.populate()` 关联填充。性能优化关键三点:`.lean()` 跳过文档封装返回纯 JS 对象,查询速度提升约 30%;`.select()` 只取必要字段减少传输量;确保查询字段有索引。链式调用时方法顺序不影响查询结果,但可读性上建议先筛选再排序分页。
## 追问
**链式查询和对象查询能混用吗?** 可以,两者等价,Mongoose 内部会将链式调用合并为同一个查询条件对象。但混用时不建议对同一字段既用对象又用链式,容易产生冲突。
**lean() 返回的对象为什么不能调用 save()?** lean() 跳过了 Mongoose 文档实例化,返回的是普通对象,没有 `$__` 内部状态和实例方法。需要修改再保存时应去掉 lean()。
**populate() 的性能问题怎么解决?** populate 本质是额外查询,N 个文档关联会产生 N+1 次查询。替代方案:用 `$lookup` 聚合在数据库端完成关联,或手动批量查询减少往返次数。
**如何调试慢查询?** 开启 `mongoose.set('debug', true)` 打印实际查询语句,或对查询链调用 `.explain('executionStats')` 查看索引命中和扫描行数。
## 写段代码
```javascript
const users = await User.find({ status: 'active' })
.where('age').gte(18).lte(60)
.sort({ createdAt: -1 })
.select('name email age')
.populate('department', 'name')
.lean()
.limit(20);
```服务端5月29日 01:07
Mongoose Schema 怎么定义,有哪些常用配置项?Schema 是 Mongoose 的文档结构蓝图,定义字段类型、验证规则、默认值和索引。它本身不操作数据库,需通过 `mongoose.model()` 编译为 Model 后使用。核心配置项包括:类型声明(String/Number/Date/ObjectId/Array)、验证器(required/min/max/enum/match)、修饰符(trim/lowercase/uppercase)、默认值(default)和索引(unique/index/sparse)。
## 追问
**Schema 的 `strict` 选项有什么作用?**
strict 默认为 true,未在 Schema 中声明的字段会被自动忽略,不会写入数据库。设为 false 允许存储任意字段,但不推荐,会丧失结构约束的意义。
**虚拟字段怎么定义,和普通字段有什么区别?**
通过 `schema.virtual('name').get()` 定义,不存入数据库、不占存储、不能用于查询筛选。适合拼接展示字段(如 fullName),不适合业务排序或过滤。
**Schema 怎么添加实例方法和静态方法?**
实例方法用 `schema.methods.xxx = function()`,通过文档实例调用,`this` 指向文档;静态方法用 `schema.statics.xxx = function()`,通过 Model 类调用,`this` 指向 Model。注意不要用箭头函数,否则 `this` 绑定错误。
**嵌套文档和引用(ref)该选哪个?**
嵌套文档适合一对一或少量子文档,查询简单但文档体积大;引用(ref + ObjectId)适合多对多或大数据集,需 populate 联查但文档精简。频繁一起读取用嵌套,独立生命周期用引用。
## 写段代码
```js
const postSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
author: { type: Schema.Types.ObjectId, ref: 'User', required: true },
tags: [{ type: String, enum: ['js', 'node', 'db'] }],
views: { type: Number, default: 0, min: 0 }
}, { timestamps: true });
```服务端5月29日 01:07
Mongoose 是什么,为什么要用它而不是原生 MongoDB 驱动?Mongoose 是 MongoDB 的 ODM(对象文档建模)库,在 Node.js 和 MongoDB 之间提供结构化抽象层。核心价值:通过 Schema 强制文档结构、内置数据验证、支持中间件钩子(pre/post)、自动类型转换和虚拟字段,让开发者以面向对象方式操作 MongoDB,显著降低数据不一致风险。
## 追问
**Mongoose 的 Schema 和 Model 是什么关系?**
Schema 定义文档结构(字段类型、默认值、验证规则),Model 是 Schema 的编译产物,即操作数据库的构造函数。一个 Schema 可编译出多个 Model。
**pre 和 post 中间件分别适合做什么?**
pre 常用于密码加密(save 前 hash)、数据格式化、权限校验;post 常用于日志记录、缓存清理、触发异步通知。pre 可通过 `next()` 或 `async/await` 控制流程中断。
**Mongoose 的类型转换(casting)会带来什么隐患?**
查询时 Mongoose 自动将字符串 `"123"` 转为数字 123,可能掩盖数据错误。生产环境建议配合严格验证,或使用 `strictQuery` 选项控制行为。
**虚拟字段和真实字段有什么区别?**
虚拟字段不存入数据库,由 `get`/`set` 计算得出,无法用于查询和排序。适合组合展示(如 fullName = firstName + lastName),不适合业务筛选。
## 写段代码
```js
const userSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
email: { type: String, required: true, lowercase: true },
age: { type: Number, min: 0, default: 0 }
});
userSchema.virtual('info').get(() => `${this.name}, ${this.age}岁`);
const User = mongoose.model('User', userSchema);
```服务端5月28日 00:22
Mongoose 如何管理连接和处理错误?Mongoose 连接管理和错误处理涉及两个核心问题:如何建立和维护稳定的数据库连接,以及如何对不同类型的错误进行分类处理。面试中常从连接生命周期、错误分类、重连策略三个角度考察。
## 连接建立与生命周期
Mongoose 通过 `mongoose.connect()` 建立连接,返回 Promise。连接建立后,Mongoose 内部会缓冲所有模型操作,因此即使连接尚未完成也可以定义模型和执行查询。
```javascript
// 基本连接
await mongoose.connect("mongodb://127.0.0.1:27017/mydb");
// 生产环境推荐配置
await mongoose.connect("mongodb://127.0.0.1:27017/mydb", {
maxPoolSize: 50, // 连接池最大连接数
minPoolSize: 5, // 连接池最小连接数
serverSelectionTimeoutMS: 5000, // 服务器选择超时
socketTimeoutMS: 45000, // Socket 超时
heartbeatFrequencyMS: 10000, // 心跳频率
retryWrites: true // 重试写入
});
```
Mongoose 连接有四种状态,通过 `mongoose.connection.readyState` 获取:
- `0`(disconnected):已断开
- `1`(connected):已连接
- `2`(connecting):正在连接
- `3`(disconnecting):正在断开
连接生命周期会触发以下事件,需要分别监听处理:
```javascript
const db = mongoose.connection;
db.on("connected", () => console.log("Mongoose connected"));
db.on("error", (err) => console.error("Connection error:", err.message));
db.on("disconnected", () => console.warn("Mongoose disconnected"));
db.on("reconnected", () => console.log("Mongoose reconnected"));
db.on("close", () => console.log("Connection closed"));
```
关键点:`disconnected` 事件不一定伴随 `error` 事件触发。Mongoose 失去连接时可能只是断开而不报错,所以必须同时监听 `disconnected` 才能可靠检测连接丢失。
## 两类连接错误的区别
这是面试高频考点。Mongoose 将连接错误分为两类,处理方式完全不同:
**初始连接错误**:`mongoose.connect()` 首次连接失败时,Promise 被 reject,Mongoose 不会自动重连。必须手动处理:
```javascript
// 方式一:try-catch
async function connectDB() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log("Connected to MongoDB");
} catch (error) {
console.error("Initial connection failed:", error.message);
process.exit(1); // 初始连接失败通常应终止进程
}
}
// 方式二:Promise.catch
mongoose.connect(process.env.MONGODB_URI)
.catch(err => {
console.error("Initial connection failed:", err);
process.exit(1);
});
```
**已建立连接后的错误**:连接建立成功后如果发生中断,MongoDB 驱动会自动尝试重连,同时触发 `error` 事件。此时不应退出进程,而是记录日志并等待重连:
```javascript
mongoose.connection.on("error", (err) => {
// 不退出进程,只记录日志
console.error("Post-connection error:", err.message);
});
```
## 查询与写入的错误分类
### CastError — 类型转换错误
当传入的值无法转换为 Schema 定义的类型时触发,最常见于 ObjectId 格式错误:
```javascript
async function findUser(id) {
try {
if (!mongoose.Types.ObjectId.isValid(id)) {
throw new Error("Invalid ID format");
}
return await User.findById(id);
} catch (error) {
if (error.name === "CastError") {
throw { status: 400, message: `Invalid ${error.path}: ${error.value}` };
}
throw error;
}
}
```
### ValidationError — 验证错误
Schema 校验失败时触发,包含每个字段的详细错误信息:
```javascript
async function createUser(data) {
try {
return await User.create(data);
} catch (error) {
if (error.name === "ValidationError") {
const fields = Object.keys(error.errors);
const messages = fields.map(f => `${f}: ${error.errors[f].message}`);
throw { status: 422, message: "Validation failed", details: messages };
}
throw error;
}
}
```
### DuplicateKeyError — 唯一索引冲突
错误码 `11000`,通常由 unique 索引冲突引起:
```javascript
async function register(email) {
try {
return await User.create({ email });
} catch (error) {
if (error.code === 11000) {
const field = Object.keys(error.keyPattern)[0];
throw { status: 409, message: `${field} already exists` };
}
throw error;
}
}
```
### 超时错误
查询超过 `maxTimeMS` 或连接超时时触发:
```javascript
// 设置查询超时
const users = await User.find({ status: "active" })
.maxTimeMS(5000) // 查询级超时
.catch(err => {
if (err.name === "MongooseError" && err.message.includes("timed out")) {
throw { status: 504, message: "Database query timeout" };
}
throw err;
});
```
## 重连策略
**注意**:Mongoose 7+ 版本已移除 `autoReconnect`、`reconnectTries`、`reconnectInterval` 选项。新版 MongoDB 驱动内置了自动重连机制,不再需要手动配置这些参数。
如果需要自定义重连逻辑(如限制重试次数、退避策略),可以在 `disconnected` 事件中实现:
```javascript
let retryCount = 0;
const MAX_RETRIES = 5;
const BASE_DELAY = 1000;
mongoose.connection.on("disconnected", async () => {
if (retryCount >= MAX_RETRIES) {
console.error("Max retries reached, shutting down");
process.exit(1);
}
const delay = BASE_DELAY * Math.pow(2, retryCount); // 指数退避
retryCount++;
console.log(`Reconnecting in ${delay}ms (attempt ${retryCount}/${MAX_RETRIES})`);
setTimeout(async () => {
try {
await mongoose.connect(process.env.MONGODB_URI);
retryCount = 0; // 重连成功,重置计数
} catch (err) {
console.error("Reconnect failed:", err.message);
}
}, delay);
});
```
## 连接池管理
连接池控制着应用与 MongoDB 之间的连接数量。配置不当会导致连接泄漏或性能瓶颈。
```javascript
await mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 50, // 单个连接池最大连接数,默认 100
minPoolSize: 5, // 最小保持连接数
maxIdleTimeMS: 30000, // 空闲连接最大存活时间
waitQueueTimeoutMS: 5000 // 等待可用连接的超时时间
});
```
监控连接池状态应使用官方 API 而非内部私有属性:
```javascript
// 正确方式:使用 serverStatus 命令
const status = await mongoose.connection.db.admin().serverStatus();
const poolInfo = status.connections;
// current: 当前连接数
// available: 可用连接数
// totalCreated: 总创建连接数
```
## 优雅关闭
应用收到终止信号时,应先关闭数据库连接再退出进程,避免数据丢失和连接泄漏:
```javascript
async function gracefulShutdown(signal) {
console.log(`Received ${signal}, closing MongoDB connection...`);
try {
await mongoose.connection.close();
console.log("MongoDB connection closed");
process.exit(0);
} catch (error) {
console.error("Error during shutdown:", error);
process.exit(1);
}
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
```
在容器化部署中,Kubernetes 发送 SIGTERM 后默认等待 30 秒,应确保 `mongoose.connection.close()` 在此时间内完成。如果存在进行中的长事务,可以设置强制关闭超时:
```javascript
// 强制关闭超时保护
const shutdownTimeout = setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 25000); // 25秒后强制退出
async function gracefulShutdown(signal) {
console.log(`Received ${signal}, shutting down...`);
try {
await mongoose.connection.close();
clearTimeout(shutdownTimeout);
process.exit(0);
} catch (error) {
clearTimeout(shutdownTimeout);
process.exit(1);
}
}
```
## 统一错误处理中间件
在生产项目中,推荐用中间件统一处理 Mongoose 错误,避免在每个路由中重复 try-catch:
```javascript
function handleMongooseError(err, req, res, next) {
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map(e => e.message);
return res.status(422).json({ error: "Validation failed", details: errors });
}
if (err.code === 11000) {
const field = Object.keys(err.keyPattern)[0];
return res.status(409).json({ error: `${field} already exists` });
}
if (err.name === "CastError") {
return res.status(400).json({ error: `Invalid ${err.path}` });
}
if (err.name === "MongooseError" && err.message.includes("timed out")) {
return res.status(504).json({ error: "Database timeout" });
}
next(err);
}
app.use(handleMongooseError);
```
这种写法将错误处理从业务逻辑中抽离出来,路由代码更简洁,错误响应格式也更统一。服务端5月28日 00:11
Mongoose 如何与 TypeScript 结合使用?Mongoose 与 TypeScript 结合的核心在于:通过接口定义文档类型,用泛型参数绑定 Schema 与 Model,让编译器在写查询、操作文档时提供类型检查和自动补全。Mongoose v7+ 推荐使用 HydratedDocument 替代 extends Document,并支持从 Schema 定义自动推断文档类型。
## 核心模式:Schema 泛型 + HydratedDocument
Mongoose v7+ 推荐的写法不再让接口继承 Document,而是通过 Schema 泛型参数让 Mongoose 自动推断文档类型,再用 HydratedDocument 包装获得完整实例类型:
```typescript
import mongoose, { Schema, HydratedDocument, Model } from 'mongoose';
interface IUser {
name: string;
email: string;
age: number;
createdAt: Date;
}
interface IUserMethods {
isAdult(): boolean;
}
type UserModel = Model<IUser, {}, IUserMethods>;
const userSchema = new Schema<IUser, UserModel, IUserMethods>({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
age: { type: Number, min: 0 },
createdAt: { type: Date, default: Date.now }
});
userSchema.method('isAdult', function(this: HydratedDocument<IUser & IUserMethods>) {
return this.age >= 18;
});
type UserDoc = HydratedDocument<IUser, IUserMethods>;
const User = mongoose.model<IUser, UserModel>('User', userSchema);
```
关键变化:IUser 是纯数据接口,不继承 Document;Schema 的三个泛型参数分别为 `RawDocType`、`ModelType`、`InstanceMethods`;HydratedDocument 将纯接口转为含 Mongoose 方法的完整文档类型。
## 静态方法与查询助手
静态方法定义在扩展 Model 的接口中,查询助手通过 Schema 第四个泛型参数声明:
```typescript
interface IUserModel extends Model<IUser, {}, IUserMethods, IUserQueryHelpers> {
findByEmail(email: string): Promise<UserDoc | null>;
}
interface IUserQueryHelpers {
byAge(min: number): Query<IUser[], IUser, IUserQueryHelpers>;
}
const userSchema = new Schema<IUser, IUserModel, IUserMethods, IUserQueryHelpers>({
// ...字段定义
});
userSchema.static('findByEmail', function(email: string) {
return this.findOne({ email });
});
userSchema.query.byAge = function(min: number) {
return this.where('age').gte(min);
};
// 链式调用有类型提示
const adults = await User.find().byAge(18);
```
## 虚拟字段与中间件
虚拟字段需要在 Schema 的泛型参数或 virtual 选项中声明才能获得类型推断。中间件的 this 指向 HydratedDocument,无需再导入已废弃的 HookNextFunction:
```typescript
userSchema.virtual('displayName').get(function(this: UserDoc) {
return `${this.name} <${this.email}>`;
});
userSchema.pre('save', function(this: UserDoc) {
this.email = this.email.toLowerCase();
});
```
## 关联与 populate 的类型安全
ref 字段用 ObjectId 声明,populate 时通过泛型参数指定填充后的文档类型:
```typescript
interface IPost {
title: string;
author: mongoose.Types.ObjectId;
}
const postSchema = new Schema<IPost>({
title: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: 'User' }
});
// populate 返回值需手动标注或使用类型工具
const post = await Post.findById(id).populate('author');
```
Mongoose 无法自动推断 populate 后的字段类型,需手动用类型交叉处理,这是目前社区常见的痛点。
## lean() 的类型处理
`.lean()` 返回纯 JavaScript 对象而非 Mongoose Document,类型应使用 FlattenMaps 或手动定义原始类型:
```typescript
type LeanUser = mongoose.FlattenMaps<IUser>;
const users: LeanUser[] = await User.find().lean();
```
## 追问
1. 为什么 Mongoose v7+ 不再推荐 `interface IUser extends Document`?extends Document 会把 Mongoose 内部属性(如 $__、$isNew)混入业务接口,导致类型污染;HydratedDocument 通过包装层隔离,接口保持纯净。
2. Schema 的 9 个泛型参数分别是什么?依次为 RawDocType、TModelType、TInstanceMethods、TQueryHelpers、TVirtuals、TStaticMethods、TSchemaOptions、THydratedDocumentType、TPathTypeMap,实际开发中通常只填前 3-4 个。
3. Typegoose 解决了什么问题?Typegoose 用 class + decorator 定义 Schema,一个类同时描述接口和 Schema,避免接口与 Schema 字段重复声明,但引入了装饰器实验特性的依赖。
4. populate 后如何获得完整类型?可定义一个带 author 详情的联合类型,或使用 Mongoose 的 `Promise<HydratedDocument<IPost & { author: UserDoc }>>` 手动标注。
5. 子文档数组的类型如何定义?使用 `Types.DocumentArray<ISubDoc>` 声明,它在 HydratedDocument 中会自动映射为带 Mongoose 子文档方法的数组类型。服务端5月28日 00:07
Mongoose 虚拟字段是什么,如何使用?Mongoose 虚拟字段是不存储在 MongoDB 中的计算属性,访问时动态求值。它适合派生数据(如全名、年龄、格式化输出)和反向关联查询,避免在数据库中冗余存储。
## 核心答案
虚拟字段通过 `Schema.virtual()` 定义,只有 getter(和可选 setter),不会写入数据库,也不能直接用于查询和排序。
```javascript
const userSchema = new Schema({
firstName: String,
lastName: String
});
userSchema.virtual('fullName')
.get(function() {
return `${this.firstName} ${this.lastName}`;
})
.set(function(name) {
const parts = name.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ');
});
const user = new User({ firstName: 'Zhang', lastName: 'San' });
console.log(user.fullName); // "Zhang San"
user.fullName = 'Li Si';
console.log(user.firstName); // "Li"
console.log(user.lastName); // "Si"
```
getter 用 `function` 而非箭头函数,因为需要通过 `this` 访问文档实例。
## 为什么虚拟字段不参与查询
虚拟字段只存在于 Mongoose 文档对象上,MongoDB 层面没有对应字段。`find({ fullName: 'Zhang San' })` 会直接报错或返回空结果,因为数据库里根本没有 `fullName` 这个 key。
需要按虚拟字段逻辑查询时,应该把计算条件拆成实际字段的查询:
```javascript
// 不支持
User.find({ fullName: 'Zhang San' });
// 替代方案
User.find({ firstName: 'Zhang', lastName: 'San' });
```
同理,`sort()`、索引、聚合管道都无法直接使用虚拟字段。
## JSON 序列化默认丢失虚拟字段
`toJSON()` 和 `toObject()` 默认不输出虚拟字段。这是最常踩的坑——接口返回的数据里看不到虚拟字段,但 `console.log` 打印时又有。
```javascript
// 方式一:Schema 级别配置
const userSchema = new Schema({
firstName: String,
lastName: String
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// 方式二:单次调用时指定
user.toJSON({ virtuals: true });
```
如果项目用了 `lean()`,虚拟字段也会丢失,因为 `lean()` 返回的是纯 JS 对象而非 Mongoose 文档。
## 虚拟字段关联(Virtual Populate)
虚拟字段最常见的实战场景是反向关联。比如 Book 引用了 Author,但 Author 模型上想直接拿到所有书籍:
```javascript
const authorSchema = new Schema({
name: String,
email: String
});
const bookSchema = new Schema({
title: String,
author: { type: Schema.Types.ObjectId, ref: 'Author' }
});
// Author 上定义虚拟字段,关联到 Book
authorSchema.virtual('books', {
ref: 'Book',
localField: '_id',
foreignField: 'author'
});
// 查询时 populate
const author = await Author.findById(id).populate('books');
console.log(author.books); // 该作者的所有书籍
```
关键配置项:
- `ref`:目标模型名
- `localField`:当前模型的字段
- `foreignField`:目标模型的字段
- `justOne: true`:返回单个文档而非数组
- `match`:添加过滤条件
多对多关联同理,只需要在另一端定义虚拟字段指向中间集合即可。
## 条件虚拟字段
虚拟字段也可以做状态判断,不限于拼接字符串:
```javascript
const userSchema = new Schema({
age: Number,
deleted: Boolean,
banned: Boolean
});
userSchema.virtual('isAdult').get(function() {
return this.age >= 18;
});
userSchema.virtual('status').get(function() {
if (this.deleted) return 'deleted';
if (this.banned) return 'banned';
return 'active';
});
```
这类虚拟字段在模板渲染或接口返回时特别有用,避免在每个使用的地方重复写判断逻辑。
## 计算型虚拟字段
聚合或格式化场景:
```javascript
// 订单总价
const orderSchema = new Schema({
items: [{ name: String, price: Number, quantity: Number }]
});
orderSchema.virtual('totalPrice').get(function() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
// 年龄计算
const personSchema = new Schema({ birthDate: Date });
personSchema.virtual('age').get(function() {
if (!this.birthDate) return null;
const today = new Date();
const birth = new Date(this.birthDate);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
});
```
注意每次访问都会重新计算,如果计算逻辑重或被频繁调用,考虑缓存或改用实例方法。
## 虚拟字段的限制与注意事项
1. **不能查询**:`find()`、`aggregate()` 都不支持虚拟字段作为条件
2. **不能排序**:`sort()` 只能作用于数据库中实际存在的字段
3. **不能建索引**:MongoDB 无法对不存在的字段建索引
4. **`lean()` 丢失**:`lean()` 返回普通对象,虚拟字段不可用
5. **每次访问重新计算**:对同一文档多次读取同一虚拟字段会多次执行 getter
6. **`populate` 需显式调用**:虚拟关联不会自动填充,必须手动 `.populate()`
## 什么时候该用虚拟字段 vs 实例方法
两者都能在文档上动态计算值,区别在于:
- **虚拟字段**:像属性一样访问 `doc.fullName`,支持 getter/setter,能参与 `toJSON` 输出
- **实例方法**:像函数一样调用 `doc.getFullName()`,逻辑更灵活,但不自动出现在 JSON 输出中
选择依据:如果值像属性(全名、年龄),用虚拟字段;如果逻辑像操作(计算折扣、发送通知),用实例方法。
## 追问
1. 虚拟字段的 setter 在什么时机执行?setter 设置的值会影响数据库写入吗?
2. 如何在虚拟关联中添加 `match` 过滤条件?比如只获取已发布的书籍。
3. `lean()` 和虚拟字段冲突时有哪些解决思路?
4. 虚拟字段和 Mongoose 的 `populate` 有什么性能差异?什么情况下虚拟关联更优?
5. 如果需要在聚合管道中实现类似虚拟字段的效果,应该怎么做?