3. 查询执行计划与explain()实战
当一条查询变慢时,最忌讳的做法是凭直觉猜“是不是缺索引”,然后盲目加索引。MongoDB 提供了 explain() 命令,它不会真正返回文档,而是返回查询优化器为这条查询选定的执行计划,以及候选计划的比较过程。读懂 explain() 的输出,是从“猜性能”转向“看性能”的关键一步。本节围绕三种 verbosity 模式、执行计划的阶段结构、winningPlan 与 rejectedPlans 的竞争关系,以及如何一眼识别 COLLSCAN 与 IXSCAN 展开。
三种 verbosity 模式:按需选择信息量
explain() 的调用形式是 db.collection.find(...).explain(verbosity),verbosity 决定返回信息的详细程度,共三档:
- queryPlanner(默认):只返回查询优化器的选择结果,包括 winningPlan、rejectedPlans 和 namespace。它不执行查询,因此开销极低,适合快速确认“优化器选了哪个索引”。
- executionStats:在 queryPlanner 基础上真正执行 winningPlan,并返回执行统计,如 nReturned、executionTimeMillis、totalKeysExamined、totalDocsExamined,以及每个阶段的详细指标。这是性能调优最常用的模式。
- allPlansExecution:执行 winningPlan,同时把 rejectedPlans 也各跑一遍并返回它们的统计。信息最全,但开销最大,通常只在怀疑优化器选错计划时使用。
选择原则很简单:先看结构用 queryPlanner,判断快慢用 executionStats,怀疑选错计划才用 allPlansExecution。日常调优 90% 的场景停在 executionStats 即可。
执行计划的阶段结构
执行计划是一棵由 stage 组成的树,数据从叶子节点向根节点流动。常见的阶段包括:
- COLLSCAN:集合扫描,逐条读取整个集合,没有使用索引。
- IXSCAN:索引扫描,通过索引定位候选文档,通常配合 FETCH 使用。
- FETCH:根据索引返回的文档位置,回表读取完整文档。
- SORT:内存排序,若数据量大且无法用索引顺序满足,可能触发 32MB 内存限制。
- LIMIT / SKIP:限制返回数量或跳过。
- PROJECTION_COVERED:投影被索引覆盖,无需回表。
理解阶段树的方向很重要:叶子阶段先执行,结果向上传递。例如一个典型的 IXSCAN + FETCH 计划,IXSCAN 在叶子层产出文档位置,FETCH 在父层回表取文档,最后交给根节点返回。
winningPlan 与 rejectedPlans:优化器的竞争
MongoDB 查询优化器在候选计划中做“试跑竞赛”:对每个可用索引和 COLLSCAN 各跑一小段,谁先返回 101 个结果或跑完,谁就胜出成为 winningPlan,其余进入 rejectedPlans。这个机制叫 plan ranking。理解它有两个实践意义:
- 如果 winningPlan 是 COLLSCAN,而 rejectedPlans 里出现了某个 IXSCAN,说明优化器认为全表扫描更快——这通常意味着索引选择性差,或查询条件无法有效利用该索引。
- 如果 winningPlan 的索引不是你以为的那个,可以检查 rejectedPlans 中候选计划的 stage 和索引名,判断是统计信息偏差还是查询写法问题。
需要注意的是,优化器的选择基于采样,可能随数据分布变化而改变。如果发现计划不稳定,可以用 hint() 强制指定索引做对比验证。
识别 COLLSCAN 与 IXSCAN
在 executionStats 模式下,最直接的判断依据是 winningPlan 的 stage 名称和两个关键计数:
- totalDocsExamined:实际检查的文档数。COLLSCAN 时它等于集合总文档数。
- totalKeysExamined:实际检查的索引键数。IXSCAN 时它反映索引扫描范围。
理想情况下,nReturned ≈ totalKeysExamined ≈ totalDocsExamined,三者越接近,索引效率越高。如果 totalDocsExamined 远大于 nReturned,说明扫描了大量文档却只返回少量结果,索引选择性不足或未命中。
实战示例:从输出定位问题
假设有一个订单集合 orders,文档形如 { orderNo, userId, status, amount, createdAt },并在 userId 上建了索引。执行以下查询:
db.orders.find({ userId: "U10086", status: "PAID" })
.explain("executionStats")
返回的 executionStats 关键片段(示例数据,非实测):
{
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"indexName": "userId_1",
"keyPattern": { "userId": 1 }
}
},
"rejectedPlans": [
{ "stage": "COLLSCAN" }
],
"executionStats": {
"nReturned": 12,
"executionTimeMillis": 3,
"totalKeysExamined": 480,
"totalDocsExamined": 480
}
}
解读:winningPlan 用了 userId_1 索引,方向正确;但 totalKeysExamined 为 480,nReturned 只有 12,说明 userId 为 U10086 的订单有 480 条,其中只有 12 条 status 为 PAID。status 条件是在 FETCH 之后过滤的,索引没有覆盖它。优化方向是建立复合索引 { userId: 1, status: 1 },让 status 也在索引层过滤,从而把 totalKeysExamined 降到接近 12。
建立复合索引后再次 explain,预期看到 IXSCAN 的 indexName 变为 userId_1_status_1,totalKeysExamined 大幅下降。这就是 explain 驱动索引优化的完整闭环:看计划 → 找差距 → 改索引 → 再验证。
把 explain 的输出当作一张“体检报告”来读:先看 winningPlan 的 stage 判断走没走索引,再看三个计数的比例判断索引效率,最后看 rejectedPlans 判断优化器是否选错。养成每次新增或修改索引后都用 executionStats 复验的习惯,索引优化才能从经验驱动变成数据驱动。