MongoDB 查询计划与 explain 深度剖析

拆解 MongoDB 查询优化器挑选 winningPlan 的完整流程,详解 explain 的三种 verbosity、执行阶段树、totalKeysExamined 等关键指标、计划缓存与 SBE 执行器,并给出常见慢计划反模式的修复方法与实战案例

同一条查询,加了索引反而更慢;换了查询条件的书写顺序,响应时间差了十倍;上线时飞快,跑了几天却开始超时。这类现象大多不是数据量的问题,而是查询优化器(Query Planner)选中了一个不合适的执行计划。要排查它,唯一的入口就是 explain()。

本文从优化器挑选计划的内部流程讲起,逐层拆解 explain() 的三种 verbosity 输出,说明 winningPlan 阶段树与关键统计指标该怎么读,再补充计划缓存与 SBE 执行器带来的新变量,最后归纳几类高频出现的反模式及其修复方式。目标是让你看到一段 explain 输出时,能立刻判断瓶颈在哪一层。

1. 查询计划的产生流程

一条查询进入 mongod 后,并不会直接执行,而是先经过查询优化器。整个过程可以拆成四个阶段,理解这四个阶段是读懂 explain 的前提。

1.1 四个阶段

规范化(Canonicalization) 把查询谓词改写成标准形式:嵌套的 $and 被拍平、$eq 提升到顶层、字段顺序被重排。这一步的意义在于保证"语义相同但书写不同"的查询命中同一个缓存条目。例如下面三种写法在规范化后完全等价:

{ a: 1, b: 2 }
{ b: 2, a: 1 }
{ $and: [{ a: 1 }, { b: 2 }] }

生成候选计划(Candidate Plans) 阶段为查询枚举所有可用的访问路径。每个可用索引生成一个 IXSCAN 计划,再加一个 COLLSCAN(全表扫描)计划兜底。如果查询含多个谓词,还会生成索引交集(Index Intersection)计划与 $or 展开计划。候选计划的多寡直接由可用索引决定,这也是为什么索引设计决定了优化器的选择空间,关于索引类型与选择性可参考 https://plumephp.com/mongodb-indexing/。

试跑竞赛(Plan Race) 从候选计划中挑出若干进行试跑,各自执行一小段时间,比较谁完成的工作量更多。早期版本会试跑全部候选,新版本会先做启发式筛选,只试跑最有希望的几个。

选出获胜计划(Winning Plan) 阶段把表现最好的计划定为 winningPlan,其余放进 rejectedPlans,胜出者被写入计划缓存供后续复用。

1.2 计划竞赛的评分逻辑

这里有一个容易踩坑的细节:优化器评判计划优劣的标准不是"返回最快",而是"单位时间内完成的工作单元最多"。在多计划试跑阶段,MongoDB 为每个计划记录一个得分(score),得分的计算与计划完成的工作量成正比、与耗时成反比。

这个评分口径带来两个后果:

  • 一个计划可能因为"扫描得快"而胜出,即使它返回了大量无用文档。试跑阶段通常只跑很短的时间窗口,计划可能还没跑到最坏的部分就被判定了。
  • 数据分布变化后,原本胜出的计划可能不再合适,但计划缓存会继续复用它,形成"计划固化"。

1.3 与关系型优化器的差异

关系型数据库(如 PostgreSQL)的优化器是基于代价模型(Cost-Based) 的,依赖统计信息估算选择性与行数;MongoDB 的优化器是基于试跑(Trial-Based) 的,靠真实执行一小段来打分。两者的差异决定了排障手段不同:

维度基于代价(PostgreSQL)基于试跑(MongoDB)
决策依据统计信息 + 代价公式真实试跑结果
统计信息依赖强(需 ANALYZE)弱(不依赖直方图)
计划稳定性受统计信息波动影响受数据分布与缓存影响
干预手段SET enable_seqscan=offhint()、清理缓存
常见问题统计过期导致选错计划数据倾斜导致计划固化

2. explain 的三种 verbosity

explain() 支持三种详细程度(verbosity),输出内容与适用场景完全不同。

2.1 三种模式对比

verbosity是否真正执行关键输出适用场景
queryPlanner否winningPlan、rejectedPlans只想看选了哪个索引,零成本
executionStats是(执行完整查询)executionStats 各项统计分析真实执行代价
allPlansExecution是(并试跑所有候选)executionStats + allPlansExecution想知道为什么没选某个索引

注意:executionStats 会真正执行查询。对写操作或代价极大的查询做 explain 时,优先用 queryPlanner 先看计划,确认无 COLLSCAN 风险后再上 executionStats。

2.2 三种命令形式

// 写法一:shell 链式
db.orders.find({ status: "paid", userId: 42 }).explain("executionStats")

// 写法二:db.runCommand 显式指定
db.runCommand({
  explain: { find: "orders", filter: { status: "paid", userId: 42 } },
  verbosity: "executionStats"
})

// 写法三:聚合管道
db.orders.explain("executionStats").aggregate([
  { $match: { status: "paid" } },
  { $group: { _id: "$userId", total: { $sum: "$amount" } } }
])

三种形式返回结构一致,区别只在入口。db.runCommand 形式在需要显式控制 filter、sort、projection 时更清晰,也是驱动内部调用的形式。

2.3 聚合管道的 explain 特殊之处

聚合管道的 explain 尤其值得留意:$match、$sort、$limit 会被下推(pushdown)到管道前端,但 $group、$lookup、$graphLookup、$unwind 无法下推。因此 explain 里会出现 $cursor 阶段包裹最初的访问路径,后面才是 $group 等阶段:

stages: [
  { '$cursor': { queryPlanner: { winningPlan: { stage: 'IXSCAN', ... } },
                 executionStats: { nReturned: 1523, ... } } },
  { '$group': { ... } },
  { '$sort': { ... } }
]

看到 $cursor 里是 COLLSCAN,就说明管道入口没有走索引,后面再多的阶段优化也救不回来。反过来,如果 $match 出现在 $cursor 之后,说明它没有被下推——这通常是因为 $match 依赖了前面阶段产生的字段。

3. 读懂 winningPlan 的阶段树

queryPlanner.winningPlan 是一棵树,自顶向下描述数据的流动方向:上层阶段消费下层阶段的输出。

3.1 常见阶段清单

阶段含义是否值得警惕
COLLSCAN全集合扫描是,数据量大时几乎必然慢
IXSCAN索引扫描否,理想入口
FETCH回表取完整文档视情况,取文档多时开销大
SORT内存排序是,未用索引序时出现
SORT_MERGE利用多个索引的有序性归并否
PROJECTION_SIMPLE字段裁剪否
LIMIT / SKIP截断SKIP 大值时是
OR多分支合并视情况
TEXT_MATCH全文匹配否
COUNT_SCAN仅靠索引完成计数否,最优
SHARD_MERGE分片结果合并分片场景常见

3.2 树形输出解读

一个典型的树形输出(已省略无关字段):

winningPlan: {
  stage: 'FETCH',
  inputStage: {
    stage: 'IXSCAN',
    keyPattern: { status: 1, createdAt: -1 },
    indexName: 'status_1_createdAt_-1',
    isMultiKey: false,
    indexBounds: {
      status: [ '["paid", "paid"]' ],
      createdAt: [ '[MaxKey, MinKey]' ]
    }
  }
}

阅读顺序是自底向上:IXSCAN 用复合索引 status_1_createdAt_-1 先按 status = "paid" 定位,索引内部已按 createdAt 逆序排列,因此上层直接是 FETCH 而没有 SORT,说明排序被索引序满足了。反过来,如果树里出现 SORT 包着 IXSCAN,就说明排序字段没被索引覆盖。

isMultiKey: true 说明该索引是多键索引(某个被索引字段是数组)。多键索引不能用于覆盖查询的部分场景,且索引项会随数组长度膨胀,是判断索引是否"超载"的信号。

3.3 indexBounds 的判读

indexBounds 是判断索引利用率的核心证据。它展示了每个索引字段被约束成的区间:

indexBounds 形式含义
'["paid", "paid"]'精确等值匹配,最优
'[MinKey, MaxKey]'全区间,该字段未被约束
'[MaxKey, MinKey]'反向全区间,仅用于提供排序序
'(18, inf.0]'范围匹配(开区间左端)
'[/^foo/, /^foo/]'前缀正则,可用索引

当某个字段的 bound 是全区间([MinKey, MaxKey] 或 [MaxKey, MinKey])时,它只贡献了排序,不贡献过滤。 这正是"索引字段顺序写反"的典型特征:范围字段在前、等值字段在后时,后者只能全区间扫描。

4. 关键统计指标

executionStats 才是判断"慢在哪"的依据。核心字段与判读方法如下。

4.1 核心指标表

指标含义健康标准
nReturned实际返回文档数与预期一致
executionTimeMillis端到端耗时业务可接受
totalKeysExamined扫描的索引键数接近 nReturned
totalDocsExamined回表读取的文档数接近 nReturned
executionStages.worksSBE 执行的工作单元数越小越好
executionStages.isEOF是否提前结束有 limit 时应为 true
executionStages.docsExamined该阶段读取的文档数逐阶段定位瓶颈

4.2 三条黄金判据

  • totalKeysExamined 远大于 nReturned:索引选择性差,或索引边界太宽。例如对布尔字段 isDeleted 建单键索引,扫描一半索引却只返回少量文档。
  • totalDocsExamined 远大于 nReturned:回表过多,说明过滤条件没有全部放进索引。典型场景是复合索引字段顺序不对,或者 $or 拆成了多个分支。
  • executionTimeMillis 高但 nReturned 很小:瓶颈在扫描或排序,而非网络传输。

理想情况是三个数字相等:扫描了多少索引键,就回表了多少文档,就返回了多少结果。这就是索引覆盖(covered query) 的效果——查询只读索引不回表,totalDocsExamined 为 0。

4.3 SBE 的 works 指标

从 MongoDB 4.4 起,查询执行换成了基于槽位的执行器(Slot-Based Execution Engine,SBE)。SBE 之后多了一个容易被忽视的指标 executionStages.works,它统计执行器完成的工作单元数(每次 next() 调用等),比 docsExamined 更细粒度。对比两个版本、或对比加索引前后的 works,能更准确地量化改进幅度。

这套指标也是 数据库查询优化 通用方法论在 MongoDB 上的具体落地形式。要长期跟踪这些指标,靠手工 explain 显然不现实——更工程化的做法是打开慢查询剖析,让数据库自动把超过阈值的操作连同完整执行统计落盘,再由工具生成索引建议,具体流程见 https://plumephp.com/mongodb-query-profiling-index-advisor/。

5. 计划缓存与 SBE 执行器

计划一旦胜出,会被放进计划缓存(Plan Cache),后续"形状"相同的查询直接复用。

5.1 计划缓存机制

所谓形状相同,指的是查询的谓词结构一致,但具体的字面值可以不同:

db.users.find({ age: { $gt: 18 } })            // 形状 A
db.users.find({ age: { $gt: 65 } })            // 与 A 同形状,复用同一计划
db.users.find({ name: "x", age: { $gt: 1 } })  // 形状 B,不同计划

计划缓存的键是 planCacheKey,它由查询形状与索引版本共同决定。查看与清理:

// 查看某集合所有缓存计划及其命中统计
db.orders.aggregate([{ $planCacheStats: {} }])

// 只清空某个查询形状的缓存
db.orders.getPlanCache().clearPlansByQuery(
  { status: "paid" },
  { createdAt: -1 }
)

// 清空整个集合的计划缓存(模型上线后索引变更时常用)
db.orders.getPlanCache().clear()

5.2 参数化与数据倾斜陷阱

这里有个经典陷阱:参数化查询配合数据倾斜会导致计划固化。假设 status 字段 99% 是 "archived"、1% 是 "paid",优化器在试跑时如果先遇到 "archived",可能选中 COLLSCAN 计划并缓存。之后查询 status: "paid" 也会复用这个坏计划。

识别这种问题的方法是看 $planCacheStats 里的 hits 与 works:命中数很高但平均 works 也高的缓存条目,就是可疑的固化计划。

5.3 干预手段

三种干预方式,按推荐度排序:

// 1. 显式指定索引(最直接)
db.orders.find({ status: "paid" }).hint("status_1_createdAt_-1")

// 2. 用 $expr 打断参数化,让不同条件的值生成不同形状
db.orders.find({ $expr: { $eq: ["$status", "paid"] } })

// 3. 清理缓存,让优化器重新试跑
db.orders.getPlanCache().clear()

SBE 引入了执行树的缓存,因此缓存的粒度从"计划形状"变成了"编译后的执行树"。这带来一个副作用:索引重建或集合元数据变化后,旧的编译产物会失效并被重新生成,此时第一次查询会偏慢,属于正常现象,不要误判为性能退化。

6. 常见反模式与修复

以下几类反模式在真实线上环境中出现频率最高,每一类都可以在 explain 里找到对应的特征。

6.1 索引字段顺序颠倒

索引 { createdAt: 1, status: 1 } 无法同时服务 filter: {status} 与 sort: {createdAt},因为索引先按 createdAt 排序,status 的过滤只能做索引内过滤(filter),explain 里会出现 SORT。修复:把等值过滤字段放前面,范围/排序字段放后面,即 { status: 1, createdAt: -1 }。

通用规则(ESR 原则):Equality(等值)→ Sort(排序)→ Range(范围)。等值字段最左,排序字段居中,范围字段最右。

6.2 OR 与否定操作符

{ $or: [{a: 1}, {b: 2}] } 若 a、b 各有索引,会生成 OR 计划并行扫描再合并去重,totalKeysExamined 是两倍。若条件选择性都不高,不如建一个复合索引 {a: 1, b: 1} 走单次扫描。

$ne、$nin、$not 这些否定操作符无法形成有效区间,通常退化为全索引扫描,explain 中表现为 totalKeysExamined 接近集合总大小。

6.3 正则与 collation

正则未锚定前缀:/foo/ 无法用索引,只有 /^foo/ 才能利用索引前缀。explain 中前者必然是 COLLSCAN。

collation 不一致:集合默认排序规则与查询指定的 collation 不匹配时,索引无法用于排序,explain 中会出现 SORT。修复:建索引时显式指定同一 collation:

db.users.createIndex(
  { name: 1 },
  { collation: { locale: "zh", strength: 2 } }
)

6.4 大 SKIP 与深分页

skip(100000).limit(20) 会让数据库先扫描并丢弃 10 万条,totalKeysExamined 随之膨胀。修复思路是基于游标的分页(用上一页最后一条的排序键作为下一页的起点):

// 差:深分页
db.items.find().sort({ createdAt: -1 }).skip(100000).limit(20)

// 好:游标分页
db.items.find({ createdAt: { $lt: lastSeenCreatedAt } })
         .sort({ createdAt: -1 })
         .limit(20)

7. 实战案例:从 1200 ms 到 15 ms

把上面所有概念串起来看一个真实场景。某订单列表接口按"用户 + 状态 + 时间倒序"查询,线上 P99 达到 1200 ms。

第一步:看计划,确认入口。

db.orders.find({ userId: 42, status: "paid" })
         .sort({ createdAt: -1 })
         .limit(20)
         .explain("executionStats")

输出关键片段:

winningPlan: {
  stage: 'LIMIT', inputStage: {
    stage: 'SORT', sortPattern: { createdAt: -1 }, inputStage: {
      stage: 'FETCH', inputStage: {
        stage: 'IXSCAN', indexName: 'userId_1', indexBounds: {
          userId: [ '[42, 42]' ],
          status: [ '[MinKey, MaxKey]' ]
        }
      }
    }
  }
},
executionStats: {
  nReturned: 20,
  executionTimeMillis: 1204,
  totalKeysExamined: 184320,
  totalDocsExamined: 184320
}

第二步:定位瓶颈。 三个数字立刻暴露问题:totalKeysExamined 是 nReturned 的 9000 倍;status 的 indexBounds 是全区间,说明它没参与过滤;树里出现 SORT,说明排序没被索引满足。根因是索引 { userId: 1 } 只服务了等值过滤,状态过滤与时间排序都在内存里做。

第三步:按 ESR 原则重建索引。

db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })
db.orders.getPlanCache().clear()   // 清掉固化的旧计划

第四步:复测。

winningPlan: {
  stage: 'LIMIT', inputStage: {
    stage: 'FETCH', inputStage: {
      stage: 'IXSCAN', indexName: 'userId_1_status_1_createdAt_-1',
      indexBounds: {
        userId: [ '[42, 42]' ],
        status: [ '["paid", "paid"]' ],
        createdAt: [ '[MaxKey, MinKey]' ]
      }
    }
  }
},
executionStats: {
  nReturned: 20,
  executionTimeMillis: 15,
  totalKeysExamined: 20,
  totalDocsExamined: 20
}

三个数字全部收敛到 20,SORT 阶段消失,耗时从 1204 ms 降到 15 ms。这个案例覆盖了本文的全部要点:用 indexBounds 判断索引是否真正参与过滤、用三个计数比值判断效率、用 ESR 原则重建索引、最后清理计划缓存让新索引生效。

值得一提的是,userId 的基数(cardinality)在这里起了决定作用:单个用户平均只有几百条订单,等值过滤已经把候选集压得很小,此时 status 的过滤收益有限,真正的大头是让 createdAt 的排序走索引,从而省掉内存排序与 LIMIT 前的大量扫描。索引设计的这类取舍,在 https://plumephp.com/mongodb-indexing-strategies/ 里有更系统的讨论。

8. 小结

读 explain 的正确顺序是:先看 queryPlanner.winningPlan 确认入口是不是 IXSCAN、有没有意外的 SORT;再用 executionStats 核对 totalKeysExamined、totalDocsExamined 与 nReturned 的比例;最后结合 rejectedPlans 与 allPlansExecution 判断优化器为什么没选你以为更好的索引。

三条实践建议:

  1. 优先消除 COLLSCAN 与 SORT,这两者几乎总是收益最大的改动。
  2. 用索引覆盖(covered query)把 totalDocsExamined 压到 0,让查询只读索引不回表。
  3. 模型或索引变更后清理计划缓存,避免旧计划固化拖累新代码。

把这三件事做扎实,绝大多数慢查询都能在 explain 层面定位并解决。剩下的少数情况——比如分片集群的 SHARD_MERGE 阶段、或者 SBE 执行树的编译开销——则需要结合更细粒度的 works 指标与数据库整体的排查顺序来处理,前者可以沿着分片相关的专题继续深入,后者则回到慢查询剖析的闭环。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践