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,说明扫描了大量文档却只返回少量结果,索引选择性不足或未命中。

注意:COLLSCAN 不一定总是坏事。当查询需要返回集合中大部分文档(例如超过 30%~50%)时,全表扫描可能比“索引扫描 + 大量回表”更快。判断标准是 totalDocsExamined 与 nReturned 的比值,而不是单纯看 stage 名称。

实战示例:从输出定位问题

假设有一个订单集合 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() 执行计划结构与识别要点 verbosity 三档 queryPlanner 只看计划结构,不执行 executionStats 执行并返回统计(常用) allPlansExecution 含 rejectedPlans 统计 winningPlan 阶段树(数据自下而上) FETCH IXSCAN indexName: userId_1 叶子先执行 → 根节点返回 rejectedPlans COLLSCAN 全表扫描候选 IXSCAN (status_1) 选择性差被淘汰 优化器试跑竞赛:先返回 101 条者胜出 COLLSCAN 与 IXSCAN 识别:看三个计数 nReturned 最终返回文档数 totalKeysExamined 索引键检查数(IXSCAN) totalDocsExamined 文档检查数(COLLSCAN 时=总数) 理想:nReturned ≈ totalKeysExamined ≈ totalDocsExamined 若 totalDocsExamined ≫ nReturned → 索引选择性不足,考虑复合索引或调整查询

把 explain 的输出当作一张“体检报告”来读:先看 winningPlan 的 stage 判断走没走索引,再看三个计数的比例判断索引效率,最后看 rejectedPlans 判断优化器是否选错。养成每次新增或修改索引后都用 executionStats 复验的习惯,索引优化才能从经验驱动变成数据驱动。