MongoDB 如何构建全覆盖索引并避免聚合查询的FETCH阶段?
文档结构
_id: ObjectId, user_id: int, deleted: bool, 'additional.id': string, // 可选字段 synced_at: Date // 可选字段
示例文档
{ "_id" : ObjectId("5dce551d6ad5bb1fd829bd77"), "user_id" : NumberInt(1), "additional" : { "id" : "hahahah" }, "deleted" : false, "synced_at" : ISODate("2023-12-19T19:21:26.678+0000") }
聚合查询需求
统计匹配以下条件的文档数量:
aggregate( [ { $match: { user_id: 1, deleted: false, "additional.id" : {$exists : true}, synced_at : { $gte: new Date(new Date() - 7 * 60 * 60 * 24 * 1000) }, } }, { $count : "productsCount" } ] )
创建的索引
createIndex( { "user_id": 1, "deleted": 1, "additional.id": 1, "synced_at": -1, }, { partialFilterExpression: { "deleted" : false, "additional.id" : { "$exists" : true }, "synced_at" : { "$exists" : true } } )
执行计划问题
查询结果为20000条,但通过.explain("executionStats")分析发现:
totalKeysExamined和totalDocsExamined均为20000,说明MongoDB从索引取出数据后又回表查询了所有文档。- 执行计划中存在
FETCH阶段,并且仍在验证"additional.id" : {"$exists" : true}条件,尽管该条件已包含在索引的partialFilterExpression中:
"executionStages" : { "stage" : "FETCH", "filter" : { "$and" : [ { "additiona.id" : { "$exists" : true } }, ] },
在MongoDB 5中是可以避免这个Fetch阶段的,核心思路是让查询能够直接利用覆盖索引完成统计,无需回表验证文档。以下是具体步骤:
1. 修正查询中的拼写错误(关键前提)
从执行计划可以看到,过滤条件里的字段名拼写错误为additiona.id(少了字母l),这会导致MongoDB无法匹配索引中定义的additional.id条件,因此不得不回表验证每个文档的字段存在性。先将查询中的字段名修正为正确的additional.id。
2. 确保查询条件与索引的partialFilter完全匹配
你的索引已经通过partialFilterExpression限定了仅包含deleted: false、additional.id: {$exists: true}、synced_at: {$exists: true}的文档,而聚合查询也包含了这些条件。当两者完全一致时,MongoDB可以确定索引中的所有条目都满足过滤条件,无需在Fetch阶段重复验证。
3. 构建覆盖索引并触发COUNT_SCAN阶段
对于count统计需求,不需要返回任何文档字段,只要索引包含所有过滤条件,就能直接在索引上完成统计。当前的索引结构已具备覆盖能力,可通过以下优化确保触发COUNT_SCAN阶段(直接从索引统计,无需Fetch):
优化partialFilterExpression(可选)
将additional.id: {$exists: true}替换为更精确的类型判断(因为你的additional.id是string类型),让MongoDB更确定索引条目符合要求:
createIndex( { "user_id": 1, "deleted": 1, "additional.id": 1, "synced_at": -1, }, { partialFilterExpression: { "deleted" : false, "additional.id" : { "$type": "string" }, "synced_at" : { "$exists" : true } } )
强制使用指定索引(可选)
如果优化后优化器仍未选择正确路径,可使用hint()强制指定该索引:
aggregate( [ { $match: { user_id: 1, deleted: false, "additional.id" : {$exists : true}, synced_at : { $gte: new Date(new Date() - 7 * 60 * 60 * 24 * 1000) }, } }, { $count : "productsCount" } ] ).hint({ "user_id": 1, "deleted": 1, "additional.id": 1, "synced_at": -1, })
验证优化效果
重新执行查询并查看执行计划:
- 若看到
stage: "COUNT_SCAN",说明已成功避免Fetch阶段,直接从索引统计数量。 - 此时
totalKeysExamined等于统计结果,totalDocsExamined会变为0,无需回表读取文档。
内容的提问来源于stack exchange,提问作者Bogdan Dubyk

