4. 执行计划全解析

当一条查询变慢时,最忌讳的做法是凭直觉加索引或改语句。MongoDB 提供的 explain() 是唯一能让我们看到"数据库打算怎么执行、实际执行了多少工作"的官方手段。理解它的三种 verbosity 模式以及各模式返回的关键指标,是复杂性能故障诊断的基本功。

三种模式:从"计划"到"真实开销"

explain() 的 verbosity 参数决定返回信息的深度,三者是递进关系,而不是互相替代:

  • queryPlanner(默认):只返回查询优化器选出的获胜计划,以及被否决的候选计划概要。它不执行查询,因此不会产生真实执行统计。适合快速确认"优化器选了哪个索引"。
  • executionStats:在 queryPlanner 基础上真正执行获胜计划,返回扫描文档数、返回文档数、执行耗时等真实指标。这是日常诊断最常用的模式。
  • allPlansExecution:执行获胜计划,同时对候选计划也做有限试跑,返回各候选计划的试跑统计。用于回答"为什么优化器没选我期望的那个索引"。

调用方式非常直接:

db.orders.find({ userId: 1001, status: "PAID" })
  .sort({ createdAt: -1 })
  .explain("executionStats")

注意:explain() 只描述查询,不修改数据;但 executionStats 与 allPlansExecution 会真实执行查询,对慢查询本身会带来额外负载,生产环境应避免对超大集合频繁调用。

queryPlanner 阶段:优化器在做什么

queryPlanner 返回的核心是 winningPlan 与 rejectedPlans。winningPlan 是一棵计划树,从叶子到根反映数据如何被逐层处理。常见阶段节点包括:

  • COLLSCAN:全集合扫描,通常意味着没有可用索引或索引未被选中,是性能红灯。
  • IXSCAN:索引扫描,节点内会给出 indexName、keyPattern、isMultiKey、direction 等。
  • FETCH:根据索引返回的 RecordId 回表取完整文档。
  • SORT:内存排序;若出现 SORT_KEY_GENERATOR 且无索引支撑,说明排序无法走索引。
  • LIMIT / SKIP / PROJECTION_SIMPLE:结果裁剪阶段。

判读要点:如果 winningPlan.stage 是 COLLSCAN,或计划树里出现 SORT 而排序字段本可被复合索引覆盖,就应优先怀疑索引设计问题。rejectedPlans 则告诉我们优化器考虑过哪些方案,是 allPlansExecution 分析的基础。

executionStats 阶段:真实开销在哪里

executionStats 是诊断的核心。它返回一组直接决定性能好坏的计数:

指标含义判读方向
nReturned最终返回的文档数业务期望值,作为基准
totalKeysExamined索引键扫描次数越接近 nReturned 越好
totalDocsExamined文档扫描次数远大于 nReturned 说明回表过多
executionTimeMillis执行耗时(毫秒)结合数据量判断是否异常
executionStages各阶段真实统计树定位瓶颈阶段

最经典的判读公式是选择性与回表比:理想情况下 totalKeysExamined ≈ totalDocsExamined ≈ nReturned。若 totalDocsExamined 远大于 nReturned,说明索引过滤能力不足,大量文档被回表后才被过滤掉;若 totalKeysExamined 远大于 nReturned,说明索引前缀选择性差,扫描了大量无用键。

例如某订单查询返回 20 条,但 totalKeysExamined = 180000、totalDocsExamined = 175000,这几乎可以断定索引字段顺序不合理——把低基数的 status 放在了复合索引前面,导致优化器只能靠它做粗过滤。

allPlansExecution:为什么没选我想要的索引

当优化器选了一个"看起来更差"的计划时,用 allPlansExecution 可以看到候选计划的试跑结果。它会在 executionStats.allPlansExecution 中列出每个候选计划的 nReturned、executionTimeMillisEstimate 等。常见原因有两类:一是统计信息过期,优化器基于旧基数估算;二是查询形状(如排序方向、范围条件)让某个索引无法被有效利用。此时可结合 hint() 强制指定索引做对比验证。

explain() 三模式与关键指标判读 queryPlanner 不执行查询 winningPlan / rejectedPlans 看索引是否被选中 executionStats 真实执行获胜计划 nReturned / DocsExamined 日常诊断主力 allPlansExecution 候选计划也试跑 allPlansExecution 数组 分析优化器选择 winningPlan 计划树(自底向上) IXSCAN FETCH SORT(内存) LIMIT 关键指标判读 理想状态 KeysExamined ≈ DocsExamined ≈ nReturned 索引选择性高,回表少 DocsExamined 偏大 回表后才被过滤 索引过滤能力不足 调整复合索引字段顺序 KeysExamined 偏大 扫描大量无用索引键 前缀基数低 提高前缀选择性

把指标串成诊断路径

实际排障时,建议按固定顺序读 explain 输出,避免被大量字段淹没:

  1. 先看 winningPlan.stage:出现 COLLSCAN 直接定位为索引缺失或失效。
  2. 再看 executionStats.nReturned 与 totalDocsExamined 的比值:比值远大于 1 说明回表过多。
  3. 接着看 totalKeysExamined 与 nReturned:差距大说明索引前缀选择性差。
  4. 最后看计划树中是否有 SORT:若有且排序字段可被索引覆盖,应调整索引方向或字段顺序。
一个实用技巧:把 executionStats 中的 executionTimeMillis 与 nReturned 一起看。若耗时高但返回极少,问题几乎一定在扫描量而非网络或客户端;若返回很多且耗时高,则要区分是索引问题还是结果集本身过大。
不要只看 executionTimeMillis 就下结论。该值受缓存、并发、数据分布影响,同一查询多次执行可能差异明显。判断索引是否有效,应以 totalKeysExamined、totalDocsExamined 与 nReturned 的比例关系为主,耗时作为辅助参考。

掌握这三种模式与核心指标的判读逻辑后,面对"查询突然变慢"这类复杂故障,我们就能从"猜"转向"看":先用 queryPlanner 确认计划,再用 executionStats 量化开销,必要时用 allPlansExecution 追问优化器的选择依据,从而把性能问题定位到具体的索引或查询形状上。