90%的MongoDB复杂查询变慢主因是索引未覆盖导致totalDocsExamined远大于nReturned;需用explain("executionStats")盯紧该值,并按等值在前、范围在后的顺序建复合索引,必要时用partialFilterExpression创建部分索引。

直接结论:90% 的 MongoDB 复杂查询变慢,不是因为数据量大,而是 explain("executionStats") 显示 totalDocsExamined 远大于 nReturned —— 说明在用索引扫描后又做了大量文档级过滤(fetch + filter),本质是索引没覆盖查询条件或顺序不合理。
为什么 explain("executionStats") 比 explain() 更关键
只调用 explain() 只能看到执行阶段(如 IXSCAN、FETCH),但无法量化代价。真正要盯住的是 explain("executionStats") 返回的三个数字:
-
executionTimeMillis:总耗时,但容易受缓存干扰 -
totalKeysExamined:实际读了多少索引项 —— 理想情况下应接近nReturned -
totalDocsExamined:回表读了多少完整文档 —— 如果这个数高达几百万甚至千万,而nReturned只有几百,就是典型的“索引失效”信号
比如你查 {"producttags": {"$exists": true}, "deliverytime": {"$gte": 1656864000000}},explain("executionStats") 显示 totalDocsExamined: 49800000,基本可以断定:索引虽然用了,但只靠 deliverytime 扫了一大片范围,再逐个检查 producttags 是否存在 —— 白读了近 5000 万文档。
复合索引字段顺序怎么排才不踩坑
MongoDB 复合索引遵循“前缀匹配”原则,顺序错了,等于没建。判断依据就一条:等值查询字段必须放最左,范围查询字段放其后。
以这个查询为例:
db.collection.find({
"status": "active",
"category": "electronics",
"created_at": {"$gte": ISODate("2025-01-01")}
})
正确索引是:
db.collection.createIndex({ "status": 1, "category": 1, "created_at": 1 })
而不是反过来把 created_at 放前面 —— 那样 status 和 category 就无法利用索引做等值跳转,只能扫整个时间范围再过滤。
额外注意点:
- 如果查询中含
$in,它算“伪等值”,可放中间,但别放最后(否则后续字段失效) -
$exists、$ne、$not通常无法高效走索引,除非配合部分索引 - 数组字段(如
producttags)会触发多键索引("ismultikey": true),此时索引膨胀严重,更要谨慎选前缀
什么时候必须用部分索引(partial index)
当集合里只有小部分文档满足某个高频查询条件时,部分索引是唯一解。典型场景就是你知识库提到的:producttags 字段仅在约 10 万条记录中存在,其余 5000 万为空或缺失。
这时建普通复合索引 {"deliverytime": 1, "producttags": 1} 没用 —— 索引体积巨大,且绝大部分键对应的是空值,查询时仍要遍历海量无效索引项。
换成部分索引:
db.collection.createIndex(
{ "deliverytime": 1 },
{ partialFilterExpression: { "producttags": { "$exists": true } } }
)
效果是:
- 索引只包含那 10 万条有
producttags的记录,体积极小 - 查询
{"producttags": {"$exists": true}, "deliverytime": {...}}时,IXSCAN直接命中有效子集,totalDocsExamined接近nReturned - 写入开销几乎为零 —— 没有
producttags的文档完全不进这个索引
⚠️ 注意:partialFilterExpression 中不能用 $regex、$text 等操作符,且表达式必须是确定性的(不能含函数或变量)。
Profiling 要开在哪一级才真有用
别一上来就设 db.setProfilingLevel(2)(记录所有操作)。生产环境这么干,system.profile 集合自己就会成为性能瓶颈。
务实做法是分两步:
- 先设为级别 1,阈值调到 100ms:
db.setProfilingLevel(1, 100)。这能捕获明显拖慢响应的查询,又不至于压垮日志系统 - 拿到慢查询语句后,**立刻在测试环境复现并跑
explain("executionStats")**,而不是在生产上反复试错 - 优化完成后,再临时提级到 50ms 或更低,验证是否还有漏网之鱼
查 profile 日志时,重点筛:db.system.profile.find({ "millis": { "$gt": 500 }, "ns": /your_collection/ }).sort({ "ts": -1 }) —— 把耗时超半秒、且命中目标集合的记录揪出来,逐条分析。
最常被忽略的一点:索引优化不是一劳永逸。当业务逻辑变化(比如突然开始查 producttags.0 而不是整个字段)、或者数据分布偏移(原本稀疏的字段变得密集),原来高效的索引可能一夜之间退化。定期用 explain("executionStats") 抽样关键查询,比等用户投诉更可靠。











