标签

Mongoose

Mongoose 是一个面向 MongoDB 数据库的对象数据模型(ODM)库,用于在 Node.js 环境下建模和操作 MongoDB 文档结构。它提供了一些方便的特性,如数据验证、查询构建、业务逻辑钩子(hooks)和中间件,使得处理 MongoDB 文档更加直观和安全。

Mongoose
服务端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 } ]); ```
服务端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. 如果需要在聚合管道中实现类似虚拟字段的效果,应该怎么做?