1. 别急着写代码:先把“查询”这件事想清楚
做MongoDB开发的人,十有八九是从关系型数据库转过来的。刚上手时最容易犯的错,就是把MongoDB的查询当成SQL来写。我的建议是:在你敲下find()之前,先花两分钟想清楚三件事——你要查的是“集合”还是“视图”?你的查询条件对应的是“字段”还是“文档结构”?你希望返回的是“原始文档”还是“加工后的结果”?
MongoDB里,查询操作的核心入口是集合对象,使用方式类似这样:
db.collection.find(query, projection)这里query是查询条件,projection是投影规则。看似简单,但真正决定查询质量的是你对数据模型的理解。MongoDB的文档是JSON风格的BSON结构,字段可以嵌套、可以是数组,同一集合里的文档结构甚至可以不完全一致,这意味着查询条件不仅要写“字段名等于什么”,还要考虑字段路径、数据类型、数组语义。
举个例子。假设你有一批订单数据,存成如下形式:
{ "_id": ObjectId("..."), "order_no": "A1001", "user": { "name": "张三", "level": "gold" }, "items": [ { "sku": "SKU001", "price": 29.9, "qty": 2 }, { "sku": "SKU002", "price": 9.9, "qty": 5 } ], "status": "paid", "created_at": ISODate("2024-06-01T10:00:00Z") }如果你要查“张三的已支付订单”,SQL思维会写成where user.name='张三' and status='paid'。MongoDB里也差不多,但要注意user.name是嵌套字段路径,而不是一个独立字段。如果你直接写{ "user": "张三" },大概率查不到数据,因为user字段的值是一个对象,不是字符串。这个区别虽然基础,但我见过太多人在这个点上卡了半天。
这篇文章我会按照实际开发中的使用频率,把MongoDB查询文档的常用姿势完整串一遍,从基础的find()语法、查询条件、投影,到排序分页、游标、性能分析,再到聚合管道的几个常用阶段,最后附上我平时排查问题的一些经验。无论你是刚接触MongoDB的新手,还是已经用了几年但没系统整理过查询方法的开发者,应该都能从中找到有用的东西。
2. 最常用的find():从“全部”到“条件过滤”
2.1 find() 的基础形态
find()是MongoDB查询文档的最基础方法,很多刚接触的人以为它只能“查全部”,其实它的完整能力远不止于此。先看最简单的场景:查询一个集合里所有的文档。
db.orders.find()这条命令返回orders集合的全部文档。在mongo shell里,find()返回的是游标(cursor),shell会自动帮你遍历并打印前20条,输入it继续翻页。在驱动程序里,find()同样返回游标对象,需要迭代才能拿到数据,这一点后面专门说。
如果你想控制返回结果的数量,可以在find()后面加上limit:
db.orders.find().limit(5)这条命令只返回前5条文档。注意,这个“前5条”在未排序的情况下,是自然顺序(natural order),也就是磁盘上的存储顺序,这个顺序一般没有业务意义。如果想按某个字段排序,需要显式指定sort。
find()的第一参数是查询条件,第二参数是投影。两者都是可选的,但实际业务里很少真的全表扫描,基本都会带上条件。全集合查询看起来简单,真正需要注意的是:如果集合很大,find()不带条件会扫描整个集合,这个操作非常危险,生产环境必须配合limit或者查询条件使用。
2.2 查询条件的基础语法
查询条件是一个文档,格式是“字段路径+条件表达式”。最简单的等值匹配:
db.orders.find({ "status": "paid" })这条命令返回所有status字段值为"paid"的文档。这里其实有一个新手容易忽略的点:MongoDB是动态类型数据库,字段的值不仅可以是字符串,还可以是数字、布尔值、数组、嵌套文档、空值等。如果你写入的时候status存的是字符串"paid",查询的时候用了数字、正则或者大小写不一致的值,就会查不到预期结果。
再看一个综合点儿的例子,查询user.name等于“张三”且status等于“paid”的订单:
db.orders.find({ "user.name": "张三", "status": "paid" })多个条件之间默认是AND关系。如果你想表达OR关系,需要用到$or运算符,这个放到后面的逻辑查询里展开。
还有一个实用的小技巧:find()查出来的结果默认包含所有字段,如果你只关心订单号和状态,可以加上投影参数:
db.orders.find( { "status": "paid" }, { "order_no": 1, "status": 1, "_id": 0 } )这里1表示返回该字段,0表示不返回。默认_id是返回的,想排除就显式写"_id": 0。投影的意义不只是减少网络传输,还能降低客户端处理数据的复杂度,尤其在字段多、文档大的场景下,收益非常明显。
2.3 投影(projection):只要我要的字段
投影的规则很简单,但实际用起来有几个容易踩的坑。
第一个坑:除了_id之外,1和0不能混用。比如你写{ "order_no": 1, "status": 0 },MongoDB会直接报错。如果只想排除status,其他都要,可以写{ "status": 0 },这样返回所有字段但不含status。如果只想返回order_no和status,可以写{ "order_no": 1, "status": 1 }。
第二个坑:嵌套字段的投影。如果你想只返回user里的name,可以写{ "user.name": 1 },但返回的结果是{ "user": { "name": "张三" } },整个user结构还是保留的,只是其他子字段被隐藏了。这个行为在聚合管道的$project阶段里不太一样,后面再对比。
第三个坑:数组字段投影。如果你想返回items数组里的前两个元素,可以用$slice运算符:
db.orders.find( { "order_no": "A1001" }, { "items": { "$slice": 2 }, "order_no": 1 } )这个操作在只需要数组前几条数据时非常高效,不用把整个大数组拉到客户端再截取。但要注意,$slice不能和普通字段的0/1投影混用,它属于一种特殊的投影表达式。
投影还有一个容易被忽略的作用:可以用它做简单的字段重命名吗?不行,那是聚合管道$project的职责,find()的投影只能“包含/排除/切片”,不能计算新字段。如果你需要把user.name重命名为userName,必须走聚合管道。我在开发中就曾经在find()投影里尝试做字符串拼接,结果碰了一鼻子灰,后来才意识到这个功能边界。
3. 查询条件进阶:比较、逻辑、数组与嵌套字段
3.1 比较运算符:不只是等于
等值匹配只是查询的起点。实际业务里,你经常需要查“金额大于100的订单”“创建时间在某范围内的记录”等等,这时候要用到比较运算符。
最常用的一组是$gt、$gte、$lt、$lte、$ne,分别对应大于、大于等于、小于、小于等于、不等于。举个例子:
db.orders.find({ "total_amount": { "$gte": 100, "$lt": 500 } })这条命令返回total_amount在100(含)到500(不含)之间的订单。这里需要注意,MongoDB会把你给定的查询值在BSON类型层面上和文档里的实际值进行比较。数字100是int类型,文档里的total_amount如果存的是Decimal128类型,比较结果取决于类型排序规则,某些边缘情况下会出现“感觉能查到但查不到”的问题。
还有一个非常常用的运算符是$in,用来匹配“字段值在给定的数组内”:
db.orders.find({ "status": { "$in": ["paid", "shipped"] } })这条命令返回status为paid或shipped的订单。与$in相对的$nin就是“不在给定数组内”,实际用得不多,因为否定条件往往对索引不友好。
此外,$ne看起来简单,但有一个特性需要注意:它不仅能匹配到“字段值不等于某值”的文档,还能匹配到“该字段不存在”的文档。这一点很多人不清楚。如果你只想看“status不等于paid且确实有status字段”的文档,还得加上{ "status": { "$exists": true } },否则结果里会混入没有status字段的文档。
3.2 逻辑运算符:AND、OR与NOT
多个条件组合,默认是AND。但业务里往往不是所有条件都必须满足,比如“查询张三的订单,或者状态为已支付的订单”。这就需要用$or:
db.orders.find({ "$or": [ { "user.name": "张三" }, { "status": "paid" } ] })注意$or的值是一个数组,数组里的每个元素是一个完整的查询条件文档。很多新手把$or写成了对象形式,比如{ "$or": { "user.name": "张三" } },这是错的,$or只接受数组。
$or的一个特点是:如果参与$or的字段都有索引,MongoDB会尝试用索引合并的方式来优化查询。如果只有一个字段有索引,或者都没有索引,查询性能可能会很差。这时候可以考虑用$in替代一部分$or的场景,比如两个条件都是同一个字段的等值比较,用$in更高效。
还有$nor与$not。$nor是“都不满足这些条件”,相当于多个条件的否定组合;$not是对单个条件的否定,比如:
db.orders.find({ "total_amount": { "$not": { "$gte": 100 } } })这条命令返回total_amount不满足大于等于100的文档,注意也包括total_amount不存在的文档。$not和$ne类似,在字段缺失时也会匹配上,这是MongoDB查询条件的一个普遍特性,写否定条件时要格外小心。
3.3 数组查询:$all、$elemMatch 与数组的“直觉陷阱”
数组字段的查询是新手最容易翻车的区域。假设你的商品文档里有tags字段:
{ "title": "无线鼠标", "tags": ["外设", "无线", "办公"] }如果你想查tags包含“无线”的文档,直觉写法是:
db.products.find({ "tags": "无线" })这个写法可行。MongoDB对数组字段的等值匹配有特殊语义:只要数组中有一个元素等于给定值,就匹配成功。这个行为对很多新手来说是惊喜,但对老手来说要小心,因为数组查询的“直觉”通常会在更复杂的场景中失效。
比如你想查“tags同时包含‘无线’和‘办公’”的文档,按照字段等值的思路写:
db.products.find({ "tags": { "$all": ["无线", "办公"] } })$all表示数组必须包含所有给定元素,这个写法是对的。但如果你写成:
db.products.find({ "tags": ["无线", "办公"] })这个条件表示“tags数组和['无线', '办公']完全相等”,顺序、数量、内容都要一致,大部分情况下都match不到你想要的数据。
再比如,如果数组里存的是对象:
{ "items": [ { "sku": "SKU001", "price": 29.9 }, { "sku": "SKU002", "price": 9.9 } ] }你想查“存在一个item,它的sku是SKU001且price大于20”,如果写成:
db.orders.find({ "items.sku": "SKU001", "items.price": { "$gt": 20 } })这个写法其实是错误的匹配逻辑。它要求的是“数组中存在某个元素的sku等于SKU001”同时“数组中存在某个元素的price大于20”,但这两个“存在”不一定是同一个数组元素。很可能一条订单里SKU001的价格是9.9,SKU002的价格是30,这条订单依然会被查出来,因为sku条件满足了SKU001,price条件满足了SKU002。
正确写法是用$elemMatch,它保证多个条件作用于同一个数组元素:
db.orders.find({ "items": { "$elemMatch": { "sku": "SKU001", "price": { "$gt": 20 } } } })$elemMatch的目标是数组的每一个元素,而不是整个数组。这是我反复跟团队强调的一个知识点:数组里存对象时,多字段条件一定要用$elemMatch,否则条件之间会“各找各的”,查询结果完全偏离预期。
3.4 嵌套文档查询的坑
嵌套文档的查询,这里也有一个经典误区。假设文档结构如下:
{ "user": { "name": "张三", "age": 30 } }如果你想查user的name等于“张三”且age等于30,写成:
db.users.find({ "user": { "name": "张三", "age": 30 } })这个查询只有在user字段恰好是{"name":"张三","age":30}并且字段顺序完全一致时才会命中。MongoDB在比较嵌套文档时,字段顺序是有影响的,一旦文档里age写在name前面,这条查询就失效了。这种写法非常脆弱,我见过不止一次线上排查问题,最后发现就是这里多了一个字段或换了一下顺序。
推荐的写法是用点号路径逐字段匹配:
db.users.find({ "user.name": "张三", "user.age": 30 })这样不仅灵活,而且能利用到嵌套字段上的索引。
嵌套查询还有一个要注意的地方:如果你要查的是“user对象存在且不为null”,用{ "user": { "$ne": null } },这个能排除user字段不存在或者值为null的情况。但如果user是空对象{},$ne: null也会匹配上,业务上需要单独判断。
4. 排序、分页与游标:查询结果的“加工线”
4.1 排序:sort的用法与多字段顺序
查询结果默认按自然顺序返回,要想按照业务规则排列,需要显式使用sort。sort的语法是:
db.orders.find({ "status": "paid" }).sort({ "created_at": -1 })1表示升序,-1表示降序。多字段排序时,先后顺序决定了优先级:
db.orders.find().sort({ "status": 1, "created_at": -1 })这条命令先按status升序,status相同再按created_at降序。这个顺序在SQL里对应ORDER BY status ASC, created_at DESC,逻辑是一样的。
排序有一个重要提醒:如果排序字段没有索引,当数据量大时,MongoDB需要在内存里做排序,而内存排序有32MB的上限(老版本是100MB,部分版本有所调整)。一旦排序数据量超过这个限制,查询会直接报错。这个问题在分页查询里更容易碰到,因为分页往往伴随着大范围排序。
解决思路通常有两个:要么给排序字段加索引,让索引天然有序,避免内存排序;要么缩小查询条件范围,减少参与排序的文档数。我在生产环境里处理过一个慢查询,就是list页面按时间倒序分页,每次查20条,看起来数据量不大,但排序范围其实是全表,加了一个created_at索引后,速度立竿见影。
4.2 分页:limit和skip的正确打开方式
分页查询最常用的方式是skip+limit组合:
db.orders.find({ "status": "paid" }) .sort({ "created_at": -1 }) .skip(20) .limit(10)这条命令跳过前20条,返回接下来的10条,也就是第3页的数据。用法很简单,但深层问题不少。
skip的代价是“跳过”不是“跳过后再查”,它的实现方式是先扫描并丢弃前N条结果,N越大,扫描成本越高。如果你做的是网页端的分页,第100页意味着skip(990),数据库要白白扫描990条记录再丢弃,性能随着页码增长而明显劣化。
更糟的是,排序不稳定会让分页出现重复或丢失数据。假设你在做“按时间倒序分页”,而created_at字段有大量相同值,那么skip+limit的边界可能在上一次查询和第下一次查询之间发生“抖动”,导致同一条记录出现在两页里,或者漏掉某些记录。
解决这个问题,我常用的方案有两种。
第一种是“翻页游标”模式。不基于页码,而是记住上一次查询的最后一条记录的排序字段值,下一次查询条件加上“排序字段小于该值”:
db.orders.find({ "status": "paid", "created_at": { "$lt": lastCreatedAt } }) .sort({ "created_at": -1 }) .limit(10)这种方式的查询条件天然具有指向性,可以走索引,深分页也不怕。
第二种是“键集分页”方式,如果排序字段可能有重复,需要带上唯一字段(如_id)作为第二排序键,查询条件里同时限定这两个字段。代码写起来复杂一些,但效果稳定,比skip解决方案可靠得多。
4.3 游标与资源释放
find()返回的是游标,不是一次性把所有结果全部加载到内存。游标从服务器分批获取数据,默认批次大小是101条或者16MB(按先到者为准)。在客户端迭代游标时,会按需向服务器请求后续批次。
游标使用上有一个很容易被忽视的坑:游标默认在10分钟无操作后超时关闭。如果你的业务逻辑是“先查询,然后跑一段很耗时的处理,再继续取游标数据”,中间一旦超过10分钟,游标就失效了。解决方案有两个:一是给游标设置noTimeout标志(不同驱动API略有差异),显式延长超时时间;二是不要持有游标太久,把需要的数据及时取到内存,然后关闭游标。长期占用的游标在服务端会占用资源,影响数据库整体性能。
还有一个相关的常见性能问题:如果你在客户端完全不迭代,而是直接把find()的结果交给某个函数处理,底层其实会默默把数据全部拉取下来,这和你的预期可能不同。比如你用find().toArray(),会把所有结果加载进内存,大数据量下内存可能爆炸。正确的做法是分批迭代处理,或者配合limit限制总量。
5. 查询性能:先看 explain,再看索引
5.1 explain:读懂查询计划
遇到慢查询,第一步不是加索引,而是搞清楚查询是怎么执行的。MongoDB提供了explain方法,用法很简单:
db.orders.find({ "user.name": "张三", "status": "paid" }).explain("executionStats")输出结果里,我最关心的几个字段是:
- executionStats.nReturned:实际返回的文档数。
- executionStats.totalKeysExamined:索引键扫描次数。
- executionStats.totalDocsExamined:文档扫描次数。
- executionStats.executionTimeMillis:执行耗时。
- queryPlanner.winningPlan.stage:获胜查询计划的执行阶段,常见的有COLLSCAN(集合扫描)和IXSCAN(索引扫描)。
判断一个查询是否高效,可以看totalDocsExamined和nReturned的比例。如果totalDocsExamined是100万,nReturned只有20,说明绝大部分文档都被“看了一眼”然后丢掉了,这对一个大集合来说是不可接受的。这种查询即使现在能跑完,数据量翻倍后大概率会变成灾难。
还有一种情况需要警惕:查询计划选择走了索引,但totalKeysExamined远超nReturned。这说明索引选择性不够好,比如你在status字段上建了索引,而这个字段只有“paid”和“unpaid”两种值,扫描一半的索引键才能筛选出目标文档。此时单独status索引帮助有限,需要考虑联合索引。
5.2 索引与查询条件的关系
MongoDB的索引很强大,但索引不是万能的。很多人会犯一个错误:为查询里的每个字段分别建一个单字段索引,以为这样查询就会快。实际上MongoDB一个查询最多只能用一个复合索引(有些场景会用索引交点合并多个单字段索引,但限制较多),如果查询条件里的字段分散在多个单字段索引上,效果往往不如一个针对性的复合索引。
举个例子,对于查询条件{ "user.name": "张三", "status": "paid" },最合适的索引是:
db.orders.createIndex({ "user.name": 1, "status": 1 })字段顺序有讲究:先等值匹配的字段放前面,排序字段、范围条件字段放后面。上面这个例子,user.name等值匹配在前,status等值匹配在后,实际效果不错。如果你想加排序,比如把created_at降序也纳入,可以建:
db.orders.createIndex({ "user.name": 1, "status": 1, "created_at": -1 })这样查询条件里的等值字段和排序字段都能利用索引,避免内存排序。
这里要理解一个要点:索引列的顺序不仅影响查询条件的匹配,还影响排序的利用。MongoDB的复合索引遵循“最左前缀”原则,查询条件必须包含索引的前缀字段,排序字段必须紧跟在等值条件字段之后才能走索引。没有任何一个索引能覆盖所有查询模式,所以索引设计必须在实际查询模式上做取舍,而不是拍脑袋建。
我在项目里的习惯是:先搜集所有线上慢查询日志,把查询模式按频率和耗时排序,再针对性建索引。而不是一开始就把所有字段全建上,那样不仅浪费存储,还会拖慢写入性能,因为每次写入都需要维护索引结构。
6. 聚合管道:当查询不够用的时候
6.1 $match、$group、$project:查询的升级版
有些查询需求,单靠find()做不好。比如“按用户分组统计订单总额”,find()只能把符合条件的文档查出来,分组和求和得靠客户端代码处理,不仅代码冗长,性能也差。这时要用聚合管道。
聚合管道的语法是:
db.orders.aggregate([ { "$match": { "status": "paid" } }, { "$group": { "_id": "$user.name", "totalAmount": { "$sum": "$total_amount" } } }, { "$project": { "_id": 0, "userName": "$_id", "totalAmount": 1 } }, { "$sort": { "totalAmount": -1 } } ])这段管道的执行逻辑是:先用$match过滤出已支付订单,然后按user.name分组,对每组的total_amount求和,再用$project把_id字段重命名为userName并去掉原来的_id,最后按总额倒序排列。
$match放在管道最前面是最优实践,因为这样可以利用索引提前过滤数据,减少后续阶段处理的数据量。$group是分组聚合的核心,$project负责字段的“整形”输出。
这段链路里,$project和find()投影有一个重要区别:$project可以创建新字段、做表达式计算、重命名字段,而find()的投影只能做包含和排除。如果你需要“把user.name拆出来变成userName字段显示”,find()投影做不到,只能走$project。
6.2 其他高频聚合阶段
除了$match、$group、$project,实际开发中还经常用到$unwind、$lookup和$addFields,这里简单提一下。
$unwind用于把数组字段拆分成多条文档。比如订单里items数组有2个元素,$unwind之后一条订单变成两条文档,每条对应一个item。这个操作在统计“每个SKU卖了多少件”的场景非常有用。注意,如果数组是空的,$unwind默认会把该文档丢弃,想保留空数组文档需要加上preserveNullAndEmptyArrays选项。
$lookup用于关联查询,相当于SQL里的LEFT JOIN。比如订单里有user_id,另一个集合users里有用户详细信息,你可以通过$lookup把用户信息添加到订单文档里。这个操作在旧版本里性能比较差,因为实现方式是逐个文档去目标集合里查,但新版MongoDB对$lookup的优化已经好了很多,配合索引还是能用的。
$addFields的作用是在管道里给文档添加新字段而不影响原有字段。比如你根据total_amount和discount算出实际支付金额,新增一个pay_amount字段,后续阶段就能直接使用。
聚合管道的调试方法也值得一说。你可以在管道数组里“截断”测试,比如只执行第一个阶段:
db.orders.aggregate([ { "$match": { "status": "paid" } } ])看看中间结果是否符合预期,再逐步往下加,这样定位问题比一次性写出完整管道再debug要高效得多。另外,聚合管道的执行计划也可以用explain查看:
db.orders.aggregate([...]).explain("executionStats")聚合管道的性能分析和find()类似,重点看每个阶段的扫描行数和时间占比。如果$lookup或$unwind消耗了大部分时间,优先考虑调整数据模型,而不是盲目加索引。
7. 常见问题与排查实录
我在维护MongoDB应用的过程中,整理过一张高频问题速查表,这些问题在开发群里也经常被问到。这里挑几个典型的分享出来。
7.1 查询结果为空,但数据明明存在
这是最经典的问题。出现这种情况,先别怀疑MongoDB有问题,依次排查这几点:
第一,字段名是否精确。MongoDB字段名是大小写敏感的,我见过有人把user.name写成user.Name,结果自然查不到。第二,字段路径是否正确。如果数据是嵌套文档,必须用点号路径,比如user.name,而不是整个user。第三,数据类型是否一致。字符串"100"和数值100在MongoDB里不一样,查询时如果用了错误类型,也会查不到。第四,中文或特殊字符是否转义。有些驱动对特殊字符的处理会有差异。
我印象最深刻的一次排查是:线上订单状态查询时好时坏,最后发现写入时status字段有的是字符串"paid",有的是整型数字1,因为代码里有两个写入入口,类型没有统一。查询时用"paid"或1,都会漏掉另一部分数据。这就是典型的“数据模型混乱导致的查询失效”,这种问题用索引解决不了,必须从写入端统一字段类型和枚举值。
7.2 数组字段查询结果不符合预期
前面已经提到过,数组里存对象时,用多字段条件普通写法会“各找各的”。这里再补充一个排查思路:当你发现数组查询多出了不该出现的文档,优先检查是不是忘了用$elemMatch。把查询条件打印出来,逐条判断条件之间的关系,是AND还是“数组元素内的AND”。
另外,数组里存基本类型时,等值查询能命中,很多人就误以为数组查询很简单。实际上,MongoDB对数组的索引和维护都更复杂,查询计划也更容易出现性能问题。我的建议是:如果数组字段经常作为查询条件,尽量让数组元素保持简单类型,不要在数组里塞一个巨型对象,否则查询性能和索引效率都会让你头疼。
7.3 sorted查询很慢,甚至报内存排序错误
查询慢,第一步看explain。如果执行计划里stage显示SORT而不是IXSCAN,说明排序没有走索引,MongoDB在内存里做了排序操作。数据量大的时候,不仅慢,还可能报错:
Exec error: Sort exceeded memory limit of 104857600 bytes
这个错误就是内存排序超过了100MB(不同版本限制不同)。碰到这个错误,不要想着调大内存限制,那是治标不治本。正确做法是给排序字段加上索引,让排序列的读取顺序天然有序,从根上消除内存排序。如果联合索引的字段顺序设计不好,排序走了索引但过滤没走,同样会出现这个错误,需要重新调整索引字段顺序。
7.4 查询结果比预期的多
对NULL值的处理是最常见的“多结果”来源。比如你想查“status不等于paid的订单”,用了{ "status": { "$ne": "paid" } },它会连status字段不存在的文档一起返回。如果你业务上认为“没有status字段的订单不算有效数据”,就需要加上$exists: true,也就是:
db.orders.find({ "status": { "$ne": "paid", "$exists": true } })另一个来源是嵌套字段匹配“父对象”本身。比如你查{ "user": { "$ne": null } },它会把user字段是空对象的文档也返回。判断这类问题时,建议把查询的文档实际打印出来,对照条件逐个字段检查,不要凭直觉推断。
7.5 索引明明建了,查询还是慢
索引不是建了就一定生效。常见原因有几个:一是查询条件里对索引字段做了表达式运算,MongoDB无法使用索引;二是复合索引字段顺序和查询条件不匹配,最左前缀原则被破坏;三是数据量太小,MongoDB优化器认为全集合扫描比走索引更快,主动选择了COLLSCAN。
遇到“建了索引但查询计划还是COLLSCAN”的情况,不要慌,先用hint强制走索引测试一下:
db.orders.find({ "user.name": "张三" }).hint({ "user.name": 1 }).explain("executionStats")如果强制走索引后性能反而更差,说明索引设计本身有问题;如果性能显著提升,可能是优化器的基数估算不准,这时候可以考虑通过hint固定索引,而不是改查询逻辑。
7.6 游标超时与迭代中断
前面提过游标10分钟超时的问题。这里补充一个实际场景:我做数据导出任务时,经常需要遍历几十万条记录,每条记录还要调外部接口补充信息。如果处理逻辑放在游标迭代内部,一个批次处理久了,游标直接失效,程序报错中断。解决办法是先把游标数据分批取到内存(比如每批1000条),处理完再取下一批,这样游标的生命周期控制在活跃状态,不会超时。
如果确实需要长时间持有游标,比如在聚合管道里进行外部数据补充后再写回,可以参考驱动的noCursorTimeout选项,但要在使用完后显式关闭游标,否则服务端资源可能一直被占用。生产环境下,这个选项慎用,除非你有明确的清理机制。
最后再分享一个小技巧
MongoDB的查询调优,本质上是对“数据模型”和“查询模式”的双向理解。每次写查询条件前,先看一眼集合里文档的真实结构,用db.collection.findOne()随便取一条看看,比翻文档管用得多。尤其是嵌套字段和数组字段,看一眼真实数据,条件怎么写、要不要用$elemMatch,心里就有数了。
还有一个小习惯值得养成:凡是线上要跑的查询,不管看起来多简单,至少跑一次explain("executionStats"),确认一下是不是COLLSCAN、扫描行数和返回行数的比例是否正常。把这条流程固化下来,很多性能问题会在上线之前就被拦住,而不是等到线上慢查询告警了才去排查。