MongoDB在执行查询前会生成执行计划,计划由不同的stage组成,其中IXSCAN和COLLSCAN是最常被比较的两种扫描阶段。看到执行计划里出现COLLSCAN通常意味着需要检查索引设计,但并不意味着所有COLLSCAN都必须消除。要准确判断,需要结合执行统计而不是只看stage名称。例如一个小集合全表扫描可能比走索引更快,而一个选择性极低的索引反而会拖慢查询。

在实际诊断慢查询时,explain方法输出中的winningPlan字段会告诉我们优化器最终选择了哪条路径。winningPlan.stage如果是IXSCAN,说明查询至少使用了某个索引;如果是COLLSCAN,则说明没有可用的索引,或者优化器认为全集合扫描代价更低。仅仅看stage还不够,还需要关注totalKeysExamined、totalDocsExamined和nReturned这几个数字,它们能反映索引到底省了多少工作。
执行计划怎么看:EXPLAIN与stage含义
MongoDB提供了explain方法来查看查询计划。常用的调用形式是db.collection.find(条件).explain("executionStats"),其中executionStats会返回实际执行后的统计信息。输出里有两个层级:queryPlanner和executionStats。queryPlanner.winningPlan表示优化器选中的计划,executionStats包含实际扫描的索引键数量、文档数量以及返回结果数量。
winningPlan本身可以嵌套。比如一个查询先通过IXSCAN扫描索引,再通过FETCH回表读取完整文档,这时外层stage可能是FETCH,内层inputStage才是IXSCAN。如果你只看到外层stage就下结论,容易误判。正确做法是沿着inputStage逐层往下看,找到最底层的扫描阶段。
下面是一个典型的explain输出片段。查询条件为user_id等于123,并且创建了user_id索引时,执行计划会呈现IXSCAN加FETCH的组合。
db.orders.find({ user_id: 123 }).explain("executionStats")
// 关键输出结构
{
"queryPlanner": {
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"keyPattern": { "user_id": 1 },
"indexName": "user_id_1"
}
}
},
"executionStats": {
"nReturned": 2,
"totalKeysExamined": 2,
"totalDocsExamined": 2,
"executionTimeMillis": 0
}
}
这个例子中totalKeysExamined等于2,totalDocsExamined也等于2,说明索引只定位到2个键,回表读取了2个文档,返回2条结果。这是一种比较理想的索引使用情况。如果查询还涉及其他字段过滤,比如status等于paid,而索引里没有status,那么totalDocsExamined可能大于nReturned,因为FETCH阶段还需要逐条读取完整文档来过滤不满足条件的记录。
IXSCAN:索引扫描的工作方式与代价
IXSCAN表示查询执行器通过索引来查找数据。MongoDB的索引底层通常使用B树结构,索引键按顺序存储,并关联到对应的文档位置。当查询条件命中索引字段时,执行器不必从集合的第一个文档开始逐个检查,而是直接在索引树中定位到符合范围的键,再根据键指向的位置读取文档。这个过程可以大幅减少需要扫描的数据量。
不过IXSCAN并不总是高效。它只解决了快速定位的问题,读取完整文档仍然可能成为瓶颈。比如一个查询通过索引找到10万个匹配的user_id,然后再回表读取10万个文档,最终过滤出100条结果。这时执行计划虽然显示IXSCAN,但totalDocsExamined远大于nReturned,查询依然可能很慢。这种情况通常被称为低选择性索引,或者索引无法覆盖完整查询条件。
如果索引本身已经包含查询需要的所有字段,MongoDB就不需要回表读取文档。此时执行计划的stage可能直接是IXSCAN,而没有FETCH。可以看下面这个例子,创建复合索引{ user_id: 1, status: 1, created_at: -1 },查询只返回user_id和status,执行计划可能只出现IXSCAN,并且totalDocsExamined为0。
db.orders.createIndex({ user_id: 1, status: 1, created_at: -1 })
db.orders.find(
{ user_id: 123, status: "paid" },
{ user_id: 1, status: 1 }
).explain("executionStats")
// 关键输出
{
"winningPlan": {
"stage": "PROJECTION_COVERED",
"inputStage": {
"stage": "IXSCAN",
"keyPattern": { "user_id": 1, "status": 1, "created_at": -1 },
"indexName": "user_id_1_status_1_created_at_-1"
}
},
"executionStats": {
"nReturned": 2,
"totalKeysExamined": 2,
"totalDocsExamined": 0
}
}
totalDocsExamined为0意味着查询完全由索引覆盖,没有读取集合中的实际文档。这也是MongoDB查询优化中一个重要的方向:尽量让索引覆盖查询所需的字段,减少回表开销。特别是在分页、统计、列表接口中,覆盖索引对性能提升非常明显。
COLLSCAN:集合扫描为什么会发生
COLLSCAN表示对整个集合中的文档逐一进行检查。如果集合有100万个文档,那么执行器就需要扫描这100万个文档,再根据过滤条件决定是否返回。与关系型数据库中的全表扫描类似,COLLSCAN通常会带来较高的CPU和IO消耗,尤其是集合数据量较大时。
出现COLLSCAN最直接的原因是查询条件没有命中任何索引,或者根本没有创建索引。例如对一个没有索引的email字段做精确匹配,MongoDB只能从头扫到尾。另一个常见原因是查询条件使用了低选择性字段,即使该字段有索引,优化器也可能在评估代价后选择COLLSCAN。比如某个字段只有true和false两个值,查询该字段为true且集合中大部分文档都满足条件,优化器可能认为直接扫全集合比先扫索引再回表读取大量文档更划算。
还有一种情况是查询使用了不支持索引的操作,比如某些正则表达式、$where表达式,或者对数组字段使用了错误的查询方式。这些操作可能让优化器无法利用索引,转而选择COLLSCAN。下面这个例子中,如果orders集合没有为user_id创建索引,查询计划就会显示COLLSCAN。
db.orders.find({ user_id: 123 }).explain("executionStats")
// 关键输出
{
"winningPlan": {
"stage": "COLLSCAN",
"filter": { "user_id": { "$eq": 123 } },
"direction": "forward"
},
"executionStats": {
"nReturned": 2,
"totalDocsExamined": 1000000,
"executionTimeMillis": 820
}
}
从这个输出可以看到,查询只返回2条结果,却扫描了100万个文档,执行时间达到820毫秒。如果这个字段经常被查询,就应该创建索引。创建索引后,同一个查询的计划会切换为IXSCAN,totalDocsExamined会下降到个位数。
不过COLLSCAN并不总是坏的。当一个集合非常小,比如只有几十条配置数据,全集合扫描的绝对代价可能低于维护索引和通过索引回表读取的成本。MongoDB优化器在评估时会根据集合统计信息做判断。所以在小集合上看到COLLSCAN,不必立刻优化。真正需要警惕的是那些大集合上高频查询出现COLLSCAN,并且伴随高执行时间和高扫描文档数。
从COLLSCAN到IXSCAN:判断与优化路径
当你通过慢查询日志或性能监控发现某个查询的COLLSCAN后,第一步是确认查询条件的字段以及排序、分页方式。通常最有效的做法是为查询条件、排序字段创建合适的复合索引。复合索引的字段顺序很重要,应优先放置等值过滤字段,再放置范围字段或排序字段。比如查询条件为user_id等于123且created_at早于某个日期,并按created_at降序排列,那么索引{ user_id: 1, created_at: -1 }通常比单独创建user_id索引更有效。
第二步是查看executionStats中的totalDocsExamined与nReturned的比值。如果创建索引后比值仍然很高,说明索引选择性不足,或者查询包含了大量非等值条件。这时可以考虑调整索引字段,把过滤性更强的字段加入到索引中,或者使用覆盖索引直接消除FETCH阶段。
第三步是使用hint方法强制指定索引,对比不同索引下的执行统计。例如db.orders.find({ user_id: 123 }).hint({ user_id: 1 }).explain("executionStats")可以强制走user_id_1索引。通过对比不同hint方案的totalDocsExamined和executionTimeMillis,可以判断哪个索引更适合当前查询。但要注意,hint只适合诊断,生产环境长期强制指定索引可能导致优化器无法根据数据分布变化自动调整。
还有一点容易被忽略:查询计划选择与索引统计信息相关性很高。当集合数据量发生变化,或者索引频繁更新后,统计信息可能不再准确,优化器可能因此选错计划。MongoDB一般会自动更新统计信息,但在某些版本或特殊场景下,也可能需要重建索引或手动触发统计刷新。对于分片集群,查询计划还会受到分片键和路由方式的影响,单独的COLLSCAN不一定是全局扫描,可能只是某个分片上的局部扫描。
理解IXSCAN与COLLSCAN的本质区别,不是为了记住两个名字,而是为了在看到执行计划时能快速判断:当前扫描路径是否合理,索引是否真正减少了工作量,以及下一步应该如何调整。大多数MongoDB查询性能问题都可以通过分析explain输出、调整索引设计来解决,而IXSCAN和COLLSCAN就是最基础的判断依据。

