Mongoose面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月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 缩减数据量。写段代码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 }]);
服务端阅读 05月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 共享认证字段)。共同点:字段大量重叠、需要跨类型统一查询。写段代码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', ... }
服务端阅读 05月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 更高效。写段代码// 批量混合操作await User.bulkWrite([ { updateOne: { filter: { name: 'Tom' }, update: { $inc: { age: 1 } } } }, { insertOne: { document: { name: 'Jerry', age: 20 } } }, { deleteOne: { filter: { name: 'Old' } } }]);// 只读查询用 leanconst users = await User.find({ age: { $gte: 18 } }) .select('name age').sort({ age: -1 }).lean();
服务端阅读 05月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 } 条件。写段代码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);
服务端阅读 05月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,让调用方决定是否加排序或分页。写段代码// 实例方法:依赖文档数据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');
服务端阅读 05月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 聚合在数据库端完成关联,或冗余存储常用字段减少关联需求。写段代码// lean + select + 索引 三板斧userSchema.index({ status: 1, createdAt: -1 });const users = await User .find({ status: 'active' }) .select('name email') .lean();// 批量插入替代循环await User.insertMany(usersToInsert);
服务端阅读 05月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),原生驱动不做隐式转换,数据原样存入,反而不会掩盖问题。对数据格式零容忍的场景,原生驱动更可控。写段代码// 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 });
服务端阅读 05月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 错误。解决:控制事务粒度、按固定顺序访问文档、加重试逻辑。写段代码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();
服务端阅读 05月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,子文档数组过大不仅逼近限制,还导致查询和更新性能下降。经验值:数组元素超过几百条就该考虑拆分为独立集合。写段代码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();
服务端阅读 05月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 含义不同。写段代码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 });});