用Node.js操作MongoDB时,很多人最初会直接使用官方驱动。驱动本身足够灵活,集合里想存什么形状的文档都可以。但这种灵活性在项目变大之后会成为负担:写入用户集合的文档可能有的有email字段有的没有,有的把年龄存成字符串有的存成数字,关联查询需要手动拼接聚合管道。Mongoose作为ODM(Object Data Modeling)库,正是为了解决这些问题而设计的。它提供Schema定义、字段类型检查、数据校验、中间件以及查询构造器,让MongoDB的操作从无结构的文档读写转变成有约束的模型操作。

安装Mongoose只需要一条命令:npm install mongoose。安装完成后,首先要建立连接。连接字符串中如果包含用户名密码或者集群地址,建议放在环境变量里,不要把敏感信息写进代码仓库。连接对象应当保持单例,避免在多个模块中重复创建连接,这一点对性能有直接影响。下面是一个基础的连接示例,包含了连接成功与失败的回调处理。
const mongoose = require('mongoose');
const uri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/myapp';
mongoose.connect(uri, {
serverSelectionTimeoutMS: 5000,
maxPoolSize: 10,
}).then(() => {
console.log('数据库连接成功');
}).catch((err) => {
console.error('数据库连接失败:', err.message);
process.exit(1);
});
上面的配置里,serverSelectionTimeoutMS控制连接超时时间,maxPoolSize控制连接池大小。连接池过大会消耗较多内存和数据库连接数,过小则在高并发时可能造成请求排队。实际项目中可以根据压测结果调整,一般情况下5到20是比较合理的区间。连接建立之后,就可以开始定义Schema了。
Schema定义的核心细节
Schema是Mongoose的核心概念,它描述了一个集合中文档的结构。每条字段都可以指定类型、是否必填、默认值、索引等信息。字段类型常用的有String、Number、Boolean、Date、Array、ObjectId,以及嵌套的子文档。嵌套子文档适合把内聚性较强的数据放在一起,比如用户下的多个收货地址,而不需要单独创建一个集合。下面这个Schema展示了用户模型比较完整的定义。
const addressSchema = new mongoose.Schema({
province: { type: String, required: true, trim: true },
city: { type: String, required: true, trim: true },
detail: { type: String, required: true },
isDefault: { type: Boolean, default: false },
});
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, '用户名不能为空'],
unique: true,
trim: true,
minlength: 3,
maxlength: 20,
},
email: {
type: String,
required: true,
trim: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, '邮箱格式不正确'],
},
age: {
type: Number,
min: 1,
max: 120,
default: null,
},
status: {
type: String,
enum: ['active', 'inactive', 'banned'],
default: 'active',
},
tags: {
type: [String],
default: [],
},
addresses: [addressSchema],
createdAt: {
type: Date,
default: Date.now,
},
}, {
timestamps: true,
versionKey: false,
});
注意Schema选项里的 timestamps: true 会自动添加 createdAt 和 updatedAt 两个字段。如果已经显式定义了 createdAt,Mongoose不会重复添加同名字段,但更新时会自动维护 updatedAt。versionKey: false 去掉了默认的 __v 字段,这个字段用于内部乐观锁,对大多数业务场景没有直接用途,去掉可以减少存储空间并让文档更干净。
索引的配置需要特别小心。unique: true 虽然方便,但只有在索引真正创建之后才会生效。如果在索引创建之前数据库中已经存在重复数据,索引创建会失败。生产环境中更稳妥的做法是把索引创建放在独立的部署步骤中,使用 Model.syncIndexes() 方法显式同步,而不是依赖Mongoose在启动时自动创建。对于查询频繁的字段组合,复合索引比多个单字段索引更有效。比如经常按 status 和 createdAt 一起查询,就应该定义 userSchema.index({ status: 1, createdAt: -1 })。
Schema还支持自定义getter和setter。setter适合在写入前做数据规范化,比如统一把手机号去掉空格和横线;getter适合在读取时做展示层转换。不过要注意getter默认只在JSON序列化时生效,如果希望读取属性时就走getter,需要在Schema选项里设置 toObject: { getters: true }。这些细节配合起来,才能让Schema既严格又灵活。
Model、Document与数据校验
定义好Schema之后,需要用 mongoose.model() 生成Model。Model是对集合的抽象,提供了创建、查询、更新、删除的静态方法。Document则是通过Model创建出的具体实例,代表集合中的一条记录。理解两者的职责区别很重要:Model负责与集合级别的操作交流,Document负责承载单条数据并触发实例级中间件。举个例子,User.find({ status: 'active' }) 是Model方法,返回的是Document数组;user.save() 则是Document方法,只影响当前这条记录。
const User = mongoose.model('User', userSchema);
// 方式一:new一个Document再调用save
const newUser = new User({
username: 'john_doe',
email: 'john@ipipp.com',
age: 28,
});
await newUser.save();
// 方式二:Model的create方法,内部自动new并save
const createdUser = await User.create({
username: 'jane_doe',
email: 'jane@ipipp.com',
age: 26,
});
数据校验是Mongoose的另一大亮点。Schema中定义的 required、min、max、enum、match 等规则,在调用 save() 或 create() 时会自动触发。校验失败会抛出 ValidationError,可以通过 err.errors 拿到每个字段的错误详情。这种结构化错误信息对API开发特别友好,容易转换成前端需要的字段级提示。下面展示如何捕获并处理校验错误。
try {
await User.create({
username: 'ab',
email: 'not-an-email',
age: 200,
});
} catch (err) {
if (err.name === 'ValidationError') {
for (const field in err.errors) {
console.log(`${field}: ${err.errors[field].message}`);
}
}
}
Mongoose的校验器分为同步和异步两种。上面示例中的内置校验器都是同步的。异步校验器需要返回Promise,典型场景是查询数据库确认某个值是否已存在。比如用户名唯一性,虽然 unique: true 依赖索引约束,但在插入前通过异步校验器提前检查,可以给用户更友好的提示,而不是等到数据库报E11000重复键错误。实现异步校验器时,函数体内用 this.constructor.findOne() 查询,注意不能用箭头函数,因为箭头函数的 this 不指向当前文档。
值得注意的是,Mongoose校验默认只在 save() 和 create() 时执行。findOneAndUpdate() 和 updateOne() 这类更新方法不会触发Schema校验,因为它们直接操作数据库层。如果需要对更新也做校验,需要显式设置 runValidators: true 选项。这个差异在实际开发中经常被忽略,导致通过更新接口写入的数据绕过了必填和枚举限制。建议在封装更新逻辑时统一加上 runValidators: true,并配合 context: 'query' 让某些依赖上下文的自定义校验器正常工作。
中间件机制与查询优化
Mongoose中间件分为pre和post两种,pre中间件在指定操作之前执行,post中间件在之后执行。中间件可以绑定在Document上,比如 save、validate、remove;也可以绑定在查询上,比如 find、findOne、updateOne。最经典的用法是在保存用户之前对密码进行哈希处理。用pre save钩子可以在每次调用 save() 时自动检查密码字段是否被修改,如果修改了就重新哈希,业务层完全不需要关心加密逻辑。
const bcrypt = require('bcrypt');
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next();
}
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (err) {
next(err);
}
});
post中间件常用于日志记录、统计指标上报或者发送通知。例如用户注册成功之后,post save钩子可以异步写入一条审计日志,并用 try/catch 包裹防止日志失败影响主流程。对于查询中间件,pre find通常用来对查询条件做预处理,比如自动拼接租户ID或者软删除过滤条件。中间件的执行顺序是:pre中间件按注册顺序执行,post中间件按注册顺序执行,所有pre都完成后才执行真正的操作,操作完成后再执行post。如果pre中间件调用了 next(err),主操作会被中止,post中间件也不会执行。
查询优化方面,select() 用来做字段投影,只返回需要的字段。比如列表页只需要展示用户名和头像,就没必要把整个文档都查出来,尤其是文档里可能包含大文本字段或者数组。另一个重要的优化手段是 lean()。默认情况下,Mongoose查询返回的Document实例会携带getter、setter、validate、save等一整套机制,这需要额外的内存和性能开销。lean() 返回纯JavaScript对象,跳过所有Document包装,查询速度明显更快。在只读列表、报表导出、数据聚合等不需要写回的场景里,应当始终使用 lean()。
// 只查询需要的字段,并返回纯对象
const users = await User
.find({ status: 'active' })
.select('username email createdAt')
.sort({ createdAt: -1 })
.skip(20)
.limit(10)
.lean();
// 对比:不带lean的查询会返回Document实例
const docs = await User
.find({ status: 'active' })
.limit(10);
分页查询中 skip() 和 limit() 的组合在数据量较小时没有问题。但当页数很深时,数据库仍然需要扫描前面所有被跳过的文档,查询会越来越慢。对于大数据量分页,更推荐基于 _id 或者 createdAt 的游标方式,用上一页最后一条的ID作为条件查询下一页。这需要索引配合,查询条件使用 { _id: { $gt: lastId } } 并限制返回数量。这种模式可以保证每页查询时间基本稳定,不受总数据量影响。
Mongoose虽然强大,但也有自己的开销。对于一些只读的高并发接口,如果完全不需要Schema校验和Document机制,可以直接使用官方驱动的Collection对象,通过 mongoose.connection.db.collection() 获取底层驱动访问权限。这样做仍然复用同一个连接池,但绕过了Mongoose的包装层,性能会更好。关键是按场景选择工具,而不是一个方案用到底。构建数据访问层时,把Schema定义、校验逻辑和查询优化分开规划,才能让Mongoose真正成为提高开发效率的利器,而不是性能瓶颈。