Mongoose Queries 实战指南:从 Model 查询方法到 Query 构建器、游标流式读取与排序
2026/9/10 21:30:30 网站建设 项目流程

Mongoose Queries 实战指南:从 Model 查询方法到 Query 构建器、游标流式读取与排序

【免费下载链接】mongooseMongoDB object modeling designed to work in an asynchronous environment.项目地址: https://gitcode.com/GitHub_Trending/mo/mongoose

Mongoose 的 Model 提供了find()findOne()updateOne()deleteMany()等一系列静态辅助函数,用于对 MongoDB 集合执行 CRUD 操作,且每一个函数都会返回一个 mongooseQuery对象。本文以仓库中的官方指南 docs/queries.md 为骨架,结合 lib/query.js 与 lib/cursor/queryCursor.js 的源码实现,系统讲解 Query 的两种执行方式、链式构建器、thenable 陷阱、引用填充、游标流式读取、与聚合管道的差异以及多字段排序,帮助你写出可预测、可调试、性能正确的查询代码。

Model 的查询静态方法一览

Mongoose models 提供以下静态辅助函数,每个函数返回一个 mongooseQuery对象:

  • Model.deleteMany()
  • Model.deleteOne()
  • Model.find()
  • Model.findById()
  • Model.findByIdAndDelete()
  • Model.findByIdAndRemove()
  • Model.findByIdAndUpdate()
  • Model.findOne()
  • Model.findOneAndDelete()
  • Model.findOneAndReplace()
  • Model.findOneAndUpdate()
  • Model.replaceOne()
  • Model.updateMany()
  • Model.updateOne()

在源码层面,这些静态方法最终都会构造一个 Query 实例并执行。以find()为例,lib/query.js#L2547-L2564 中Query.prototype.find会设置this.op = 'find',然后通过merge(conditions)合并查询条件;Query.prototype.findOne(lib/query.js#L2829)则会额外处理投影与选项参数。Query 构造函数(lib/query.js#L116)内部维护了_transforms_hooks(Kareem 中间件容器)与_execCount(记录执行次数),这些字段是理解下文"Query 不是 Promise"的关键。

执行查询的两种方式

执行查询时,你将查询条件写成 JSON 文档,其语法与 MongoDB shell 完全一致:

const Person = mongoose.model('Person', yourSchema); // 查找姓氏为 'Ghost' 的人,只选择 `name` 和 `occupation` 字段 const person = await Person.findOne({ 'name.last': 'Ghost' }, 'name occupation'); // 输出 "Space Ghost is a talk show host" console.log('%s %s is a %s.', person.name.first, person.name.last, person.occupation);

person的形态取决于具体操作:findOne()返回一个可能为 null 的单个文档,find()返回文档列表,countDocuments()返回文档数量,updateOne()返回受影响文档数等。更多细节见 Model 的 API 文档。

延迟执行:先用 Query 构建,再手动 exec()

如果暂不await,你会得到一个尚未执行的 Query:

// 查找姓氏为 'Ghost' 的人 const query = Person.findOne({ 'name.last': 'Ghost' }); // 选择 `name` 和 `occupation` 字段 query.select('name occupation'); // 在之后的某个时刻执行该查询 const person = await query.exec(); // 输出 "Space Ghost is a talk show host" console.log('%s %s is a %s.', person.name.first, person.name.last, person.occupation);

上面的query变量类型是 Query。它允许你用链式语法逐步构建查询,而不是一次性给出完整的 JSON 对象。从源码看,Query.prototype.exec(lib/query.js#L4744-L4761)会先做三项校验:不再接受回调函数(传入函数会直接抛出'Query.prototype.exec() no longer accepts a callback')、校验操作类型op与关联的model是否为空,随后通过opToThunk表查找到对应操作的执行函数。这也解释了为什么"先构建、后执行"的延迟模式是可行的——Query 对象本身只是条件与选项的容器。

JSON 文档写法与 Query 构建器写法等价

下面两个例子完全等价,你可以按场景自由选择:

// 方式一:一次性传入 JSON 文档 await Person. find({ occupation: /host/, 'name.last': 'Ghost', age: { $gt: 17, $lt: 66 }, likes: { $in: ['vaporizing', 'talking'] } }). limit(10). sort({ occupation: -1 }). select({ name: 1, occupation: 1 }). exec(); // 方式二:使用 Query 构建器链式组装 await Person. find({ occupation: /host/ }). where('name.last').equals('Ghost'). where('age').gt(17).lt(66). where('likes').in(['vaporizing', 'talking']). limit(10). sort('-occupation'). select('name occupation'). exec();

构建器模式由一组"可链式调用"的方法支撑。在 lib/query.js 中可以看到它们的实现轮廓:limit(v)(lib/query.js#L932)会把值写入this.options.limit并返回thisselect()(lib/query.js#L1128)内部通过parseProjection解析字段投影,并支持sanitizeProjection选项;sort(arg, options)(lib/query.js#L3135)最多接受 2 个参数并将排序写入this.options.sort。所有方法都返回this,从而形成链式调用。完整的方法清单见 Query 的 API 文档。

Queries 不是 Promise(但它们是 thenable)

Mongoose 的 Query不是Promise。它们只是 thenable(拥有.then()方法的对象),为async/await提供便利。关键在于:与 Promise 不同,调用 Query 的.then()会真正执行查询,因此对同一个 Query 多次调用then()会抛出错误。

const q = MyModel.updateMany({}, { isDeleted: true }); await q.then(() => console.log('Update 2')); // 抛出 "Query was already executed: Test.updateMany({}, { isDeleted: true })" await q.then(() => console.log('Update 3'));

源码给出了直接证据。lib/query.js#L4901-L4934 中三个方法都通过exec()触发真正的执行:

Query.prototype.then = function(resolve, reject) { return this.exec().then(resolve, reject); }; Query.prototype.catch = function(reject) { return this.exec().then(null, reject); }; Query.prototype.finally = function(onFinally) { return this.exec().finally(onFinally); };

也就是说,await queryquery.then(...)query.catch(...)query.finally(...)都会各自触发一次查询执行。如果想要安全的"重复使用"查询条件,请保留原始 Query 并在每次执行前通过链式方法复制/重建,或者直接对同一条件对象多次调用 Model 静态方法,而不是复用同一个 Query 实例。

引用其他文档:Population

MongoDB 没有 join,但有时我们仍然希望查询结果中能包含其他集合中文档的引用。这正是 population(填充) 的用武之地。关于如何在查询结果中引入其他集合的文档,详见 Query#populate 的 API 文档。Population 是查询阶段的可选步骤:先执行原始查询拿到主文档,再根据ref与本地外键字段批量发起对目标集合的二次查询,从而在业务层面模拟出"关联查询"的效果。

流式读取:Query#cursor() 与 QueryCursor

你可以从 MongoDB流式读取查询结果。需要调用 Query#cursor() 获取一个 QueryCursor 实例:

const cursor = Person.find({ occupation: /host/ }).cursor(); for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) { console.log(doc); // 逐条打印文档 }

源码层面,Query.prototype.cursor(lib/query.js#L5368-L5386)在创建游标前会先调用_castConditions()进行条件转换,若转换失败(例如过滤器含有sanitizeFilter拒绝的$where),会返回一个标记了错误的 QueryCursor;否则返回new QueryCursor(this)QueryCursor.prototype.next(lib/cursor/queryCursor.js#L307-L333)同样不再接受回调,内部以 Promise 形式逐条取文档,并对已关闭的游标调用next()抛出'Cannot call next() on a closed cursor'

使用 async iterators 遍历

使用 async iterators 遍历 Mongoose 查询也会自动创建游标:

for await (const doc of Person.find()) { console.log(doc); // 逐条打印文档 }

在 lib/cursor/queryCursor.js#L434-L440 中可以看到实现:当Symbol.asyncIterator存在时,QueryCursor.prototype[Symbol.asyncIterator]会设置_mongooseOptions._asyncIterator = true并返回自身,后续_next回调会把结果包装成{ value, done }形式(lib/cursor/queryCursor.js#L476-L483)。测试 test/query.test.js 中也有对应的遍历用例,例如使用for await消费经过transform()处理后的游标结果。

游标超时与 noCursorTimeout

游标受游标超时约束。默认情况下,MongoDB 会在 10 分钟后关闭游标,之后的next()调用会抛出MongoServerError: cursor id 123 not found。要覆盖这一行为,请为游标设置noCursorTimeout选项:

// MongoDB 不会在 10 分钟后自动关闭该游标 const cursor = Person.find().cursor().addCursorFlag('noCursorTimeout', true);

不过,游标仍然可能因为会话空闲超时(session idle timeouts)而失效:即使设置了noCursorTimeout,游标在空闲 30 分钟后依然会超时。这在 MongoDB 官方文档中也有明确说明(cursor.noCursorTimeout一节)。因此对于长时间运行的批处理任务,更稳妥的做法是控制单批处理时长、及时关闭游标,或将数据按时间片拆分查询,而不是无限期依赖noCursorTimeout

何时使用 aggregate():Queries vs Aggregation

Aggregation(聚合) 能做很多查询能做的事情。例如,下面是用aggregate()查找name.last = 'Ghost'的文档:

const docs = await Person.aggregate([{ $match: { 'name.last': 'Ghost' } }]);

但"能用"不等于"应该用"。一般来说,能用普通查询就优先用查询,只有确实需要时才使用aggregate()两者的关键差异有三点:

1. 聚合结果不做 hydrate

与查询结果不同,Mongoose不会对聚合结果调用hydrate()。聚合结果永远是普通对象(POJO),而不是 Mongoose 文档:

const docs = await Person.aggregate([{ $match: { 'name.last': 'Ghost' } }]); docs[0] instanceof mongoose.Document; // false

这意味着聚合结果没有 Mongoose 文档的实例方法、getter/setter、虚拟字段与修改追踪能力;如果你需要这些能力,必须对结果自行 hydrate 或做二次查询。

2. 聚合管道不做类型转换(cast)

与查询过滤器不同,Mongoose会 cast(类型转换) 聚合管道。也就是说,你必须自己保证传入聚合管道的值类型正确:

const doc = await Person.findOne(); const idString = doc._id.toString(); // 能查到这个 Person,因为 Mongoose 把 `idString` 转换成了 ObjectId const queryRes = await Person.findOne({ _id: idString }); // 查不到这个 Person,因为 Mongoose 不转换聚合管道中的类型 const aggRes = await Person.aggregate([{ $match: { _id: idString } }]);

这是实践中非常容易踩坑的地方:查询条件中的字符串 ObjectId 会被自动 cast,而聚合管道的$match则不会。从源码结构看,查询条件在 lib/query.js 的_castConditions/castFilterPath链路中会基于 Schema 类型逐路径转换,而 lib/aggregate.js 对管道阶段默认不做同样的 Schema 级 cast。因此在使用聚合时,请先用mongoose.Types.ObjectId(...)等构造函数显式转换类型。

3. 关于 type casting 的进一步阅读

想深入了解 Mongoose 对查询条件、更新条件与聚合条件的类型转换规则(包括字符串化 ObjectId、数字与日期的隐式转换边界),请阅读 查询类型转换指南。

排序:保证结果顺序可控

Sorting 用于确保查询结果按期望的顺序返回:

const personSchema = new mongoose.Schema({ age: Number }); const Person = mongoose.model('Person', personSchema); for (let i = 0; i < 10; i++) { await Person.create({ age: i }); } await Person.find().sort({ age: -1 }); // 返回结果以 age=10 开头 await Person.find().sort({ age: 1 }); // 返回结果以 age=0 开头

-1表示降序,1表示升序,也可以使用字符串形式sort('-age')/sort('age')(这也是上文构建器示例中sort('-occupation')的写法)。

多字段排序:键的顺序决定优先级

多字段排序时,排序键的书写顺序决定了 MongoDB 服务端先按哪个字段排序

const personSchema = new mongoose.Schema({ age: Number, name: String, weight: Number }); const Person = mongoose.model('Person', personSchema); const iterations = 5; for (let i = 0; i < iterations; i++) { await Person.create({ age: Math.abs(2 - i), name: 'Test' + i, weight: Math.floor(Math.random() * 100) + 1 }); } await Person.find().sort({ age: 1, weight: -1 }); // 先按 age 升序,age 相同时再按 weight 降序

下面是一次实际运行的输出,可以看到 age 从 0 升到 2,而在 age 相同的记录之间,则按 weight 降序排列:

[ { _id: new ObjectId('63a335a6b9b6a7bfc186cb37'), age: 0, name: 'Test2', weight: 67, __v: 0 }, { _id: new ObjectId('63a335a6b9b6a7bfc186cb35'), age: 1, name: 'Test1', weight: 99, __v: 0 }, { _id: new ObjectId('63a335a6b9b6a7bfc186cb39'), age: 1, name: 'Test3', weight: 73, __v: 0 }, { _id: new ObjectId('63a335a6b9b6a7bfc186cb33'), age: 2, name: 'Test0', weight: 65, __v: 0 }, { _id: new ObjectId('63a335a6b9b6a7bfc186cb3b'), age: 2, name: 'Test4', weight: 62, __v: 0 } ];

排序在 lib/query.js#L3135 的sort()实现中被写入this.options.sort,最终以 MongoDB 排序规范({ field: 1|-1 }对象或'field -field'字符串)下发给驱动。若需跨字段稳定排序,请始终显式给出完整键序列,不要依赖数据库的自然顺序。

小结与下一步

Mongoose Query 的核心心法可以概括为四点:

  1. 两种写法等价:一次性 JSON 文档 vs 链式 Query 构建器,选一种并保持一致;
  2. Query 是 thenable 而非 Promise.then()/await都会执行查询,不要重复调用同一个 Query;
  3. 大数据量用游标cursor()+for await逐条处理,注意 10 分钟默认超时与 30 分钟会话空闲超时的边界;
  4. 查询优先于聚合:聚合不 hydrate、不 cast,能写查询就写查询。

接下来可以继续阅读 Validation(校验),学习如何在查询与文档保存前定义数据校验规则。

【免费下载链接】mongooseMongoDB object modeling designed to work in an asynchronous environment.项目地址: https://gitcode.com/GitHub_Trending/mo/mongoose

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询