同一条查询,加了索引反而更慢;换了查询条件的书写顺序,响应时间差了十倍;上线时飞快,跑了几天却开始超时。这类现象大多不是数据量的问题,而是查询优化器(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=off | hint()、清理缓存 |
| 常见问题 | 统计过期导致选错计划 | 数据倾斜导致计划固化 |
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.works | SBE 执行的工作单元数 | 越小越好 |
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 判断优化器为什么没选你以为更好的索引。
三条实践建议:
- 优先消除 COLLSCAN 与 SORT,这两者几乎总是收益最大的改动。
- 用索引覆盖(covered query)把
totalDocsExamined压到 0,让查询只读索引不回表。 - 模型或索引变更后清理计划缓存,避免旧计划固化拖累新代码。
把这三件事做扎实,绝大多数慢查询都能在 explain 层面定位并解决。剩下的少数情况——比如分片集群的 SHARD_MERGE 阶段、或者 SBE 执行树的编译开销——则需要结合更细粒度的 works 指标与数据库整体的排查顺序来处理,前者可以沿着分片相关的专题继续深入,后者则回到慢查询剖析的闭环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。