前两篇看到了索引在磁盘上是什么(每个 index 自己一棵独立 B-tree)、内部是什么形状(internal page 存指针,leaf page 存数据)。这一篇回答一个更实际的问题:建了索引,查询就一定会用吗?不一定——查询规划器有一套具体的、可以在源码里读到的规则,决定一个索引到底"相不相关",以及即使用了索引,还要不要多一次"回表"、要不要额外排序。
本机建了一个 5000 篇文档的真实 collection,在建索引前后跑同一批查询,拿真实 explain() 输出说话;每条结论都能在 MongoDB Server 真实源码(v8.3.7 标签)里找到对应的判断逻辑。
5000 篇文档,只有默认的 _id 索引,查 category="electronics" 且 qty 在某个区间的文档。
$ mongosh --quiet blogdemo --eval '
db.inventory.find(
{ category: "electronics", qty: { $gte: 100, $lte: 150 } }
).explain("executionStats")
'
winning stage: COLLSCAN
nReturned: 100
totalKeysExamined: 0
totalDocsExamined: 5000
executionTimeMillis: 7
只想要 100 篇文档,规划器却把全部 5000 篇都扫了一遍——没有别的办法,唯一的索引是 _id,跟这条查询的字段完全无关,只能退化成全表扫描。
下面三节逐条验证,每一条都对应 MongoDB 查询规划器源码里一个具体的函数。
判断一个索引跟查询"相不相关",规划器只检查这个索引 key pattern 的第一个字段是否出现在查询条件里——不是任意字段,是第一个。
想跳过"回表"(FETCH)这一步,查询要的每一个字段都必须已经在索引条目里——少一个字段都不行。
去掉等值条件锁定的字段之后,剩下的索引字段顺序如果正好等于排序字段,就不需要额外的内存排序;对不上就得加一个阻塞式 SORT 阶段。
这条函数决定一个索引够不够"相关",进而决定它有没有资格成为候选执行计划——不合格的索引,规划器压根不会为它生成一个执行计划去比较。
src/mongo/db/query/planner_ixselect.cpp · mongodb/mongo @ r8.3.7, L315 // static std::vector<IndexEntry> QueryPlannerIXSelect::findRelevantIndices( const RelevantFieldIndexMap& fields, const std::vector<IndexEntry>& allIndices) { std::vector<IndexEntry> out; for (auto&& index : allIndices) { BSONObjIterator it(index.keyPattern); BSONElement elt = it.next(); const std::string fieldName = std::string{elt.fieldNameStringData()}; if (fields.contains(fieldName) && (!index.sparse || fields.find(fieldName)->second.isSparse)) { out.push_back(index); } }
注意 it.next() 只调用了一次——只取出 key pattern 的第一个字段 elt,然后检查查询条件里有没有覆盖这个字段名。索引后面还有几个字段,这个函数根本不关心。这就是"复合索引前缀规则"的全部真相:不是"用了非前缀字段效率低",是这个索引在候选阶段就直接被排除,从来没有机会被规划器考虑。
建一个复合索引 {category:1, qty:1},重新跑上面那条查询。
$ mongosh --quiet blogdemo --eval '
db.inventory.createIndex({ category: 1, qty: 1 });
db.inventory.find(
{ category: "electronics", qty: { $gte: 100, $lte: 150 } }
).explain("executionStats")
'
winning stage: FETCH
input stage: IXSCAN
index used: {"category":1,"qty":1}
nReturned: 100
totalKeysExamined: 100
totalDocsExamined: 100
executionTimeMillis: 3
| totalDocsExamined | totalKeysExamined | nReturned | |
|---|---|---|---|
| 建索引前 | 5000 | 0 | 100 |
| 建索引后 | 100 | 100 | 100 |
docsExamined 从 5000 降到 100——因为查询条件覆盖了索引的第一个字段(category),索引边界(index bounds)能同时收窄到 category="electronics" 且 100≤qty≤150 这个精确范围,keysExamined 和 nReturned 完全相等,说明索引扫描没有一条"白扫"的记录。
还是这个 {category:1, qty:1} 索引,分别只用 qty(第二个字段)和只用 category(第一个字段)做查询条件。
$ mongosh --quiet blogdemo --eval "db.inventory.find({qty:{\$gte:100,\$lte:150}}).explain('executionStats')"
winning stage: COLLSCAN
totalKeysExamined: 0
totalDocsExamined: 5000
# 用 allPlansExecution 确认:规划器根本没有为这个索引生成候选计划
$ mongosh --quiet blogdemo --eval "db.inventory.find({qty:{\$gte:100,\$lte:150}}).explain('allPlansExecution').queryPlanner.rejectedPlans.length"
0
# 换成只用第一个字段:
$ mongosh --quiet blogdemo --eval "db.inventory.find({category:'electronics'}).explain('executionStats')"
winning stage: FETCH
input stage: IXSCAN
totalKeysExamined: 1000
totalDocsExamined: 1000
rejectedPlans.length: 0 是关键证据——不是"用索引比 COLLSCAN 慢所以规划器选了 COLLSCAN",是压根没有第二个方案可选,跟上面 findRelevantIndices 只检查第一个字段的行为完全对上。只用 category 这个前缀字段,索引立刻变得可用,keysExamined 等于这个分类下的全部 1000 篇文档(因为 qty 没有约束,索引扫描没法进一步收窄)。
即使用了索引,默认还要多一步"回表"(FETCH)把完整文档从 collection 的 B-tree 里取出来——除非查询要的字段索引里全都有。
src/mongo/db/query/planner_analysis.cpp · mongodb/mongo @ r8.3.7, L450 /** * If any field is missing from the list of fields the projection wants, we are not covered. */ auto providesAllFields(const OrderedPathSet& fields, const QuerySolutionNode& solnRoot) { for (auto&& field : fields) { if (!solnRoot.hasField(field)) return false; } return true; }
逻辑非常直白:投影想要的每一个字段,都得挨个检查这份执行计划(比如一次 IXSCAN)能不能直接提供。只要有一个字段答不上来,就不算"覆盖",后面就得接一个 FETCH 去 collection 的 B-tree 里取完整文档。
$ mongosh --quiet blogdemo --eval '
db.inventory.find(
{ category: "electronics", qty: { $gte: 100, $lte: 150 } },
{ category: 1, qty: 1, _id: 0 }
).explain("executionStats")
'
winning stage: PROJECTION_COVERED
nReturned: 100
totalKeysExamined: 100
totalDocsExamined: 0
投影只要 category 和 qty,两个字段都已经在索引条目里——totalDocsExamined: 0,FETCH 阶段完全被省掉了,数据直接从索引本身读出来。这不是"查询变快了一点",是根本没有再碰 collection 的 B-tree 文件。
如果索引扫描出来的顺序本身就满足排序要求,规划器就直接用这个顺序,不再加 SORT 阶段。
src/mongo/db/query/planner_analysis.cpp · mongodb/mongo @ r8.3.7, L1301 // See if solnRoot gives us the sort. If so, we're done. auto providedSorts = solnRoot->providedSorts(); if (providedSorts.contains(sortObj)) { return true; }
每个执行计划节点(比如一次 IXSCAN)都能声明自己"天然提供"哪些排序顺序——如果请求的排序恰好在这个集合里,直接复用,函数提前返回,后面构建执行计划树时就不会再插入一个 SORT 节点。
$ mongosh --quiet blogdemo --eval "
db.inventory.find({category:'electronics'}).sort({qty:1}).explain('executionStats')
.queryPlanner.winningPlan.stage + ' <- ' +
db.inventory.find({category:'electronics'}).sort({qty:1}).explain('executionStats')
.queryPlanner.winningPlan.inputStage.stage
"
FETCH <- IXSCAN (排序字段 qty 正好是索引第二个字段,不需要额外 SORT)
# 换成按一个完全不在索引里的字段排序:
$ mongosh --quiet blogdemo --eval "
db.inventory.find({category:'electronics'}).sort({price:1}).explain('executionStats')
.queryPlanner.winningPlan.stage
"
SORT (阶段链变成 SORT -> FETCH -> IXSCAN,多了一步阻塞式排序)
按 qty 排序时,category 是等值条件可以忽略,剩下索引字段顺序正好是 [qty],跟排序要求完全一致,不需要额外阶段。换成按 price(完全不在这个索引里)排序,规划器只能先用索引把候选文档收窄,再在内存里做一次阻塞式排序——数据量一大,这一步就是实实在在的额外开销。
上面 6 个真实跑过的查询,按三条规则重新走一遍判断逻辑,对照真实 explain() 结果。
判断逻辑由 Python 脚本按上面三段真实源码逐条转写:规则一直接照抄 findRelevantIndices 只看第一个字段的行为;规则二照抄 providesAllFields;规则三照抄 providedSorts 命中检查。6 个场景每一个的模型预测结果,都和上面真实 mongosh explain() 输出逐条断言相等;一套独立重写的判断函数交叉核对过"这条查询到底用不用得上索引"。