MongoDB的慢查询定位,最常用的入口是对目标查询调用explain方法。explain会返回查询计划,但直接看完整JSON往往会让人头大。其实我们只需要重点关注executionStats模式下的几个统计值,以及winningPlan里出现的stage类型。explain有三种执行模式:queryPlanner只返回计划而不执行,executionStats会执行查询并统计扫描与返回情况,allPlansExecution则会展示优化器考虑过的多个候选计划。日常排查慢查询,使用executionStats就足够了。

举个例子,订单表orders中有status、createTime、userId等字段。下面这条查询需要按状态筛选并倒序取前20条:
db.orders.find({
status: 'paid',
createTime: { $gte: ISODate('2024-01-01T00:00:00Z') }
}).sort({ createTime: -1 }).limit(20).explain('executionStats')
返回结果中,executionStats.totalKeysExamined表示扫描的索引键数量,totalDocsExamined表示实际读取的文档数量,nReturned是最终返回给客户端的文档数。如果totalDocsExamined远大于nReturned,甚至totalKeysExamined也很大,基本可以判定查询效率不高。winningPlan里的stage如果是COLLSCAN,意味着没有走任何索引,数据库在遍历集合中的全部文档;如果是IXSCAN后面跟着FETCH,说明命中了索引,但还需要根据索引指针回表读取完整文档。FETCH阶段往往是大结果集查询的主要开销来源。
从stage定位常见的慢查询模式
最典型的慢查询模式是全表扫描。当查询条件没有对应索引,或者索引字段无法被利用时,winningPlan的stage会显示为COLLSCAN。此时totalDocsExamined几乎等于集合总文档数,而nReturned可能只有几条。例如在userId字段上没有任何索引,却执行db.orders.find({userId: 10086})。这种情况下即使数据量只有几十万,也会带来明显的延迟。解决方法是在userId上创建单字段索引:db.orders.createIndex({userId: 1})。加完索引后再次explain,stage会变成IXSCAN,totalDocsExamined会骤降到与nReturned接近。
第二种常见情况是索引选择性差。例如status字段只有pending、paid、cancelled三种值,如果在status上建了索引,查找status为paid的订单,虽然走了IXSCAN,但totalKeysExamined可能依然有几十万,因为绝大多数订单都是已支付状态。此时索引确实被使用,但过滤能力很弱。优化方向是组合其他高选择性字段,比如将status和createTime建成复合索引db.orders.createIndex({status: 1, createTime: -1}),让数据库在status相同的一组索引项内快速定位时间范围,减少扫描的索引键数量。
第三种常见问题是排序导致的内存压力。MongoDB对未使用索引的排序有内存上限,默认是32MB。如果查询包含sort,而排序字段没有包含在索引中,执行计划里会出现SORT阶段。这个阶段会把匹配到的文档全部加载到内存然后排序,一旦数据量超过限制,查询直接报错。即使没有报错,排序过程也会拖慢响应时间。解决方式是让排序字段成为复合索引的一部分,并且方向与sort一致。比如db.orders.createIndex({status: 1, createTime: -1})可以让上面的查询同时完成筛选和排序,执行计划中不再出现SORT。
还有一种容易被忽略的问题是回表过多。IXSCAN之后紧跟FETCH,意味着索引命中后还要去读取完整文档。如果查询只需要少数字段,而读取的文档数量又很大,FETCH的成本会很高。此时可以设计覆盖索引,把需要返回的字段也加入索引中。比如只需要status和createTime,使用db.orders.find({status:'paid'}, {status:1, createTime:1, _id:0}).hint({status:1, createTime:-1}).explain('executionStats'),计划中可能出现PROJECTION_COVERED,表示所有数据都从索引获得,无需回表。
如何对比优化前后的执行计划
修改索引后,不能只凭感觉判断是否变快,要用explain数据说话。最直观的指标是totalDocsExamined与nReturned的比值。理想情况下这个比值应该接近1,即数据库只读取了需要返回的文档。如果优化前是几万比20,优化后变成20比20,说明效果显著。同时executionTimeMillis会下降,但要注意这个值受缓存和硬件影响,单次对比可能波动,多跑几次取中位数更可靠。
在测试环境中,可以使用hint方法强制指定索引,观察不同索引下计划的变化。例如db.orders.find({status:'paid'}).hint({createTime:-1}).explain('executionStats'),如果强制使用createTime索引,stage可能先是IXSCAN,然后过滤大量不符合status条件的文档,totalDocsExamined依然很高。这能验证复合索引中字段顺序的重要性:等值字段放在前面,排序字段放在后面,符合最左前缀原则,索引效率才最高。
生产环境排查慢查询,往往不会对所有请求都执行explain。可以开启数据库Profiler,设置合适的慢查询阈值,比如超过100毫秒的查询记录下来。db.setProfilingLevel(1, { slowms: 100 })。之后在system.profile集合中查询慢查询语句,再单独对这条语句执行explain分析。需要注意Profiler会带来少量性能开销,建议分时段开启,或者在从库上分析。
explain的局限与补充手段
explain虽然能揭示执行计划,但它并不完全等于真实执行环境。比如explain执行时可能磁盘缓存已经预热,而线上高峰期磁盘IO压力大,同样的执行计划耗时差异会很大。另外锁等待、网络传输、客户端处理等环节不会体现在explain输出中。所以explain适合定位查询本身的问题,不适合做全链路性能分析。如果explain显示计划已经很优秀,但线上仍然慢,就需要查看db.currentOp()、监控opcounters和连接数,判断是否资源争用。
MongoDB还有一个查询计划缓存机制。优化器会缓存已经选定的查询计划,如果数据分布发生较大变化,旧计划可能不再最优,甚至变成慢查询。遇到这种情况,可以使用db.collection.getPlanCache().clear()清理计划缓存,让优化器重新评估。也可以用planCacheListPlans查看缓存中的计划。不过普通业务里,频繁清理缓存并不可取,更稳妥的做法是通过重建索引或调整数据分布来维持计划合理性。
最后,慢查询优化是一个循环过程:先通过Profiler或监控发现慢查询,再用explain分析执行计划,找出阶段瓶颈,然后针对性地创建复合索引、调整查询字段或重写语句,最后再次explain验证指标。不要一上来就堆索引,也不要只看totalDocsExamined而忽略FETCH和SORT。把explain当成数据库给你的诊断报告,读懂它,比记住一堆优化口诀更有用。
MongoDB慢查询explain执行计划查询优化修改时间:2026-10-03 08:16:04