1. 项目背景

业务场景:本地生活电商的 DBA 在巡检慢查询日志时发现一个诡异现象——商品表有 {status:1, category:1, price:1} 的复合索引,但某个查询 find({status:"在售", category:"数码"}).sort({sales:-1}).limit(20) 在 explain 中显示走了 {status:1, category:1, price:1} 索引而非预期中更优的 {status:1, category:1, sales:-1} 索引。更奇怪的是,同一个查询在测试环境跑 IXSCAN(索引扫描),在生产环境有时走 COLLSCAN(全表扫描)。运维困惑了:"我明明建了索引,为什么优化器不选?"

痛点:索引存在不等于索引被使用。MongoDB 的查询优化器会根据统计信息在候选索引中选择一个"它认为最优"的计划。但统计信息可能不准、索引选择可能出错、Plan Cache(查询计划缓存)可能导致过时的计划被反复使用。更深层——SBE(Slot-Based Execution)执行引擎在 MongoDB 5.0+ 成为默认,某些查询在 Classic Engine 下走索引、在 SBE 下走全表扫描,优化器行为不透明。

2. 项目设计

小胖(盯着 explain 输出发懵):大师!我商品表明明有 5 个索引,优化器偏要选一个最烂的,把查询搞成 3 秒!MongoDB 是不是傻了?

大师:先看看 explain,确认 winningPlan 和 rejectedPlans。

小胖:怎么看?

大师:看 explain 的三个级别:① queryPlanner——看优化器选了谁、拒绝了谁;② executionStats——看实际扫描了多少文档;③ allPlansExecution——看所有候选计划的竞速结果(优化器会让候选计划跑一小段,选最快的)。

技术映射explain("executionStats") 是日常调优的推荐级别;explain("allPlansExecution") 用于调试"优化器为什么选错"的疑难杂症。

小胖:但我 explain 出来 winningPlan 是 IXSCAN 啊,为什么还慢?

大师:IXSCAN 不等同于快。你要看三个数字:

指标 含义 健康值
totalKeysExamined 扫描了多少个索引键 越接近 nReturned 越好
totalDocsExamined 因为 FETCH 回了多少文档 越接近 nReturned 越好,0 最好(覆盖索引)
nReturned 实际返回了多少文档 你的 limit(20) 期望值

如果你的索引扫描了 10 万个键(totalKeysExamined = 100000)但只返回 20 个文档(nReturned = 20),说明索引的选择性极差——就像在一本字典里找"a 开头的单词",索引确实用了(你知道在 a 区),但你还是在 a 区翻了 10 万页。

技术映射:索引选择性 = 索引能过滤掉多少不相关数据。低选择性的索引(如 status 只有两种值)比高选择性索引(如 orderNo 唯一)扫描键数多得多。

小白(追问):那 Plan Cache 是什么?优化器选择了一次计划后是不是就缓存了?缓存会不会用错?

大师:太对了。Plan Cache 是 MongoDB 对查询形状(Query Shape)的缓存。同一个查询形状(条件结构相同参数不同)的第一次执行会触发优化器竞速,之后的执行直接用缓存的计划——哪怕这套执行计划在数据分布变了之后已经不适用

技术映射:查 Plan Cache 用 planCacheList()planCacheClear()。Plan Cache 的淘汰策略是——如果索引被删除/重建,或集合有大量写入改变了统计分布,缓存条目会被重新评估。

小胖:那如果优化器选错了,我怎么强制指定用哪个索引?

大师.hint({ indexName: 1 }) 强制走指定索引。但 hint 是硬编码的——如果将来索引被删除或被重命名,查询直接报错。更优雅的方式是用 indexHint,或者重建一个更适合的索引让优化器自动选对。

大师(总结):今天记住三个排查顺序——先查 explain 的三个数字(totalKeysExamined / totalDocsExamined / nReturned),再看 winningPlan vs rejectedPlans,最后查 Plan Cache 是否缓存了过时计划。优化器不是万能的,你得学会跟它对话。

3. 项目实战

3.1 环境准备

沿用第 17 章复制集或单机环境,准备一个有 50 万条数据的索引测试集合。

3.2 分步实现

步骤一:构造多种索引并存的数据环境

目标:创建一个集合,建多个"看起来都能用"的索引,观察优化器的选择。

use local_life
db.opt_demo.drop()

// 插入 30 万条商品数据
const categories = ["数码影音","手机配件","家居生活","美妆个护","食品饮料"]
const statuses = ["在售","下架"]
const brands = ["华为","小米","苹果","三星","OPPO","vivo","荣耀","一加","realme","魅族"]

for (let batch = 0; batch < 30; batch++) {
  const docs = []
  for (let i = 0; i < 10000; i++) {
    const idx = batch * 10000 + i
    docs.push({
      name: `查询优化测试商品_${idx}`,
      category: categories[idx % 5],
      brand: brands[idx % 10],
      price: NumberDecimal((Math.random() * 5000 + 10).toFixed(2)),
      stock: Math.floor(Math.random() * 1000),
      sales: Math.floor(Math.random() * 50000),
      rating: parseFloat((Math.random() * 5).toFixed(1)),
      status: idx % 20 === 0 ? "下架" : "在售",
      createdAt: new Date(2025, 0, 1, 0, 0, 0, idx),
      updatedAt: new Date()
    })
  }
  db.opt_demo.insertMany(docs, { ordered: false })
  print(`已插入 ${(batch + 1) * 10000} 条`)
}
print(`总文档数: ${db.opt_demo.countDocuments()}`)

// 创建多个可能被优化器选择的索引
db.opt_demo.createIndex({ status: 1, category: 1, price: 1 },     { name: "idx_a" })
db.opt_demo.createIndex({ status: 1, category: 1, sales: -1 },    { name: "idx_b" })
db.opt_demo.createIndex({ status: 1, brand: 1, sales: -1 },       { name: "idx_c" })
db.opt_demo.createIndex({ category: 1, sales: -1 },               { name: "idx_d" })
db.opt_demo.createIndex({ status: 1, sales: -1 },                 { name: "idx_e" })
print("已创建 5 个索引")

步骤二:explain 深度阅读——allPlansExecution

目标:看优化器是怎么在多个索引之间竞速的。

// 查询:在售 + 数码类目 + 按销量排序取前 20
const explain = db.opt_demo.find({
  status: "在售",
  category: "数码影音"
}).sort({ sales: -1 }).limit(20).explain("allPlansExecution")

print("=== 优化器决策 ===")
print("选中计划(Winning):", explain.queryPlanner.winningPlan.indexName || "COLLSCAN")
print("拒绝的计划(Rejected):",
  (explain.queryPlanner.rejectedPlans || [])
    .map(p => p.indexName || "COLLSCAN").join(", ")
)

// 查看竞速详情
if (explain.executionStats.allPlansExecution) {
  print("\n=== 候选计划竞速 ===")
  explain.executionStats.allPlansExecution.forEach((plan, i) => {
    print(`计划${i+1} (${plan.indexName || 'COLLSCAN'}):`)
    print(`  扫描键数: ${plan.totalKeysExamined}`)
    print(`  扫描文档: ${plan.totalDocsExamined}`)
    print(`  结果数: ${plan.nReturned}`)
    print(`  获胜: ${plan.works < explain.executionStats.executionStages.works ? '否' : '是'}`)
  })
}

// 关键发现:优化器会让每个候选计划"跑一小段"(通常是返回第一批结果),
// 然后选最快返回的作为 winningPlan。并非全量执行后再比较。

步骤三:Plan Cache——查看、清除与故障诊断

目标:操作 Plan Cache,理解缓存导致优化器用错索引的场景。

// 查看某个查询形状的 Plan Cache
const cacheList = db.opt_demo.aggregate([
  { $planCacheStats: {} }
]).toArray()

print("=== Plan Cache 条目 ===")
cacheList.forEach(entry => {
  print(`  形状: ${JSON.stringify(entry.queryHash).slice(0,20)}...`)
  print(`  缓存大小: ${entry.cachedPlansSize}`)
  print(`  命中: ${entry.isActive ? '是' : '否'}`)
  if (entry.planCacheKey) print(`  缓存键: ${entry.planCacheKey}`)
  print(`  ---`)
})

// 清除一条查询形状的 Plan Cache(需要指定查询形状的 filter/sort/projection)
// db.opt_demo.planCacheClear()

// 清除整个集合的 Plan Cache
db.opt_demo.planCacheClear()
print("Plan Cache 已清除")

// 清除后重新 explain,优化器会重新竞速

步骤四:hint 强制走索引——手动干预优化器

目标:演示 hint 绕过优化器选择。

// 查询:在售商品按销量排序
const query = { status: "在售" }
const sort = { sales: -1 }
const projection = { _id: 0, name: 1, sales: 1, brand: 1 }

// 1. 让优化器自己选
const autoPlan = db.opt_demo.find(query).sort(sort).limit(10)
  .explain("executionStats")
print("自动选择:", autoPlan.queryPlanner.winningPlan.indexName || "COLLSCAN")
print("  扫描文档:", autoPlan.executionStats.totalDocsExamined)

// 2. hint 强制走 idx_e(专门为 {status, sales} 设计的索引)
const hintPlan = db.opt_demo.find(query).sort(sort).limit(10)
  .hint("idx_e")
  .explain("executionStats")
print("\nhint(idx_e):", hintPlan.queryPlanner.winningPlan.indexName)
print("  扫描文档:", hintPlan.executionStats.totalDocsExamined)

// 3. hint 强制走 idx_a(不适合排序的索引,会导致内存排序)
const hintWrong = db.opt_demo.find(query).sort(sort).limit(10)
  .hint("idx_a")
  .explain("executionStats")
print("\nhint(idx_a) 错误:", hintWrong.queryPlanner.winningPlan.indexName)
print("  有 SORT 阶段:", JSON.stringify(hintWrong.queryPlanner.winningPlan).includes("SORT"))
// ⚠️ hint 可以强行走任何索引(哪怕一点都不适合),使用须谨慎

步骤五:SBE 与 Classic Engine 的差异

目标:对比两种执行引擎下 explain 的变化。

// 查看当前引擎模式
const engine = db.runCommand({ getParameter: 1, internalQueryFrameworkControl: 1 })
print("执行引擎:", engine.internalQueryFrameworkControl)
// MongoDB 5.0+: "trySbeEngine"(默认优先 SBE)
// MongoDB 6.0+: SBE 更广泛覆盖

// 强制使用 Classic Engine
db.adminCommand({ setParameter: 1, internalQueryFrameworkControl: "forceClassicEngine" })
const classicExplain = db.opt_demo.find({
  status: "在售", category: "数码影音"
}).sort({ sales: -1 }).limit(20).explain("executionStats")
print("Classic 引擎:", classicExplain.executionStats.executionStages.stage)

// 恢复 SBE
db.adminCommand({ setParameter: 1, internalQueryFrameworkControl: "trySbeEngine" })
const sbeExplain = db.opt_demo.find({
  status: "在售", category: "数码影音"
}).sort({ sales: -1 }).limit(20).explain("executionStats")
print("SBE 引擎:", sbeExplain.executionStats.executionStages.stage)
// SBE 的 stage 名称为 "EXEC" 而非 "FETCH/IXSCAN"

// 比较耗时
print("耗时对比: Classic", classicExplain.executionStats.executionTimeMillis, "ms | SBE",
      sbeExplain.executionStats.executionTimeMillis, "ms")

步骤六:索引隐藏——不删索引先测试影响

目标:隐藏一个索引后观察查询性能变化(MongoDB 4.4+)。

// 隐藏索引 idx_a(让优化器"假装它不存在",但不删除)
db.opt_demo.hideIndex("idx_a")
print("idx_a 已隐藏")

// 再次 explain,优化器不会选择 idx_a
const afterHide = db.opt_demo.find({
  status: "在售", category: "数码影音"
}).sort({ sales: -1 }).limit(20).explain("executionStats")
print("隐藏后选择:", afterHide.queryPlanner.winningPlan.indexName || "COLLSCAN")

// 恢复隐藏的索引
db.opt_demo.unhideIndex("idx_a")
print("idx_a 已恢复")

// 这个方法的意义:线上不先删索引,而是先隐藏,观察 1-2 天确认无影响再真正删除

3.3 完整代码清单

文件 用途
mongodb-lab/scripts/ch21-create-index-data.js 构造多索引测试数据
mongodb-lab/scripts/ch21-explain-deep.js allPlansExecution 深度分析
mongodb-lab/scripts/ch21-plan-cache.js Plan Cache 操作
mongodb-lab/scripts/ch21-hint-vs-auto.js hint 对比实验
mongodb-lab/scripts/ch21-sbe-vs-classic.js SBE vs Classic 引擎对比

3.4 测试验证

use local_life

// 1. allPlansExecution 包含候选计划
const exp = db.opt_demo.find({ status:"在售", category:"数码影音" })
  .sort({ sales: -1 }).limit(10).explain("allPlansExecution")
print("候选计划数:", (exp.executionStats.allPlansExecution || []).length,
      exp.executionStats.allPlansExecution?.length > 1 ? "PASS" : "FAIL")

// 2. Plan Cache 在清除后可重新缓存
db.opt_demo.planCacheClear()
const cacheBefore = db.opt_demo.aggregate([{ $planCacheStats: {} }]).toArray()
print("清除后缓存数:", cacheBefore.length, cacheBefore.length === 0 ? "PASS" : "FAIL")

// 重新执行查询触发缓存
db.opt_demo.find({ status:"在售", category:"手机配件" }).sort({ sales: -1 }).limit(10).toArray()
const cacheAfter = db.opt_demo.aggregate([{ $planCacheStats: {} }]).toArray()
print("执行后缓存数:", cacheAfter.length, cacheAfter.length > 0 ? "PASS" : "FAIL")

// 3. hint 强制走索引验证
const hintExp = db.opt_demo.find({ status:"在售" }).sort({ sales: -1 }).limit(10)
  .hint("idx_e").explain("executionStats")
print("hint 生效:", hintExp.queryPlanner.winningPlan.indexName === "idx_e" ? "PASS" : "FAIL")

// 4. 隐藏索引验证
db.opt_demo.hideIndex("idx_a")
sleep(100)
let idxVisible = false
db.opt_demo.getIndexes().forEach(i => { if (i.name === "idx_a") idxVisible = i.hidden !== true })
print("隐藏状态:", idxVisible ? "可见" : "已隐藏")
db.opt_demo.unhideIndex("idx_a")

print("\n=== 优化器验证完成 ===")

4. 项目总结

4.1 explain 速查表

explain 字段 含义 诊断
winningPlan.stage 主执行阶段 IXSCAN=好, COLLSCAN=坏, FETCH=中间, SORT=内存排序
rejectedPlans 优化器放弃的计划 看为什么放弃——索引顺序不对、无排序利用
totalKeysExamined 扫描的索引键数 ≫ nReturned → 索引选择性差
totalDocsExamined FETCH 回文档数 0 = 覆盖索引(最优);≫ nReturned → 需优化
nReturned 实际返回数 应与 limit 一致
executionTimeMillis 执行耗时 参考当前负载;绝对时间依赖数据量,相对性对比更有意义
allPlansExecution 候选计划竞速 看哪个候选计划阶段性的 works 更少

4.2 适用场景

查询优化器调优适用

  1. 排查"有索引但查询慢"——explain 分析 totalKeysExamined vs nReturned。
  2. 确认覆盖索引是否生效——totalDocsExamined = 0 即覆盖。
  3. 诊断优化器选错索引——查看 rejectedPlans 或用 hint 对比。
  4. 生产环境索引变更前验证——先隐藏索引观察,无影响再删。
  5. 分析 Plan Cache 是否缓存了过时计划——清除后重新 explain 对比。

4.3 注意事项

注意事项 说明
explain 本身会执行查询 "executionStats" 级别会真实执行(但不返回文档),idle 时间内安全
allPlansExecution 开销大 优化器需要运行多个候选计划,生产环境避免在高频路径上频繁使用
hint 是"硬编码",不可长期依赖 索引被删除/改名后 hint 会导致查询报错
Plan Cache 影响 explain 缓存的计划不会重新竞速,allPlansExecution 看到的是缓存 plan 的统计,不是实时竞速
internalQueryFrameworkControl 是高级参数 生产环境不建议频繁切换执行引擎

4.4 常见踩坑经验

故障案例一:Plan Cache 让优化器"固守"了一个慢计划

某业务执行 find({category:"数码"}).sort({price:1}),初期数据量小,优化器选了 {category:1, price:1} 索引跑得很快。三个月后数据量增长到 500 万,价格分布变了,但 Plan Cache 让优化器一直用旧计划——totalKeysExamined 达到 50 万。排查发现 explain 中有 Plan Cache 命中标记。解决db.collection.planCacheClear() 清除后,优化器重新竞速选择了一个更适合当前数据的复合索引。

故障案例二:SBE 引擎让覆盖索引失效

某团队在 MongoDB 5.0 上升级后,之前 totalDocsExamined=0 的覆盖索引查询突然出现了 FETCH 阶段。根因:该查询的 projection 在 SBE 引擎下处理方式与 Classic 不同——SBE 对某些操作符(如 $ifNull)无法利用索引。解决:简化 projection 表达式;或者在该查询上用 hint{ $natural: 1 } 可能无济于事,需要针对性修改查询逻辑。

故障案例三:explain 用 "queryPlanner" 级别看到 IXSCAN 就以为没问题

某开发用 explain("queryPlanner") 检查查询,看到 winningPlan.stage = IXSCAN,就认为索引已经生效。结果查询在生产仍需要 5 秒——因为 queryPlanner 只显示"计划是什么",不显示实际扫描了多少。根因:检查不完全。解决:标准流程——线上查询必须用 explain("executionStats"),关注总扫描量与返回量的比例。

4.5 思考题

  1. 如果一个查询 find({a:1, b:2}).sort({c:1}) 的 explain 显示 winningPlan 中有 SORT 阶段(内存排序),而候选计划中存在 {a:1, c:1, b:1} 索引可以消除排序,为什么优化器没选它?
  2. MongoDB 6.0+ 中引入的 $planCacheStats 和传统的 planCacheList() 有什么区别?如何通过统计信息评估一个 Plan Cache 条目的"性价比"?

(答案将在第 22 章末尾揭晓)


上一章思考题答案

  1. 同时命中 2dsphere 和普通复合索引时,MongoDB 优化器会评估每个索引的查询成本(基于索引选择性估算)并在候选计划中进行短程竞速,选择最快返回第一批结果的计划。如果两个索引"质量"相似,哪个索引的名字典序靠前或建得早没有确定性——不依赖隐式行为。对关键查询建议 hint("idx_name") 强制指定。

  2. 一个集合只能有一个 Text Index 是因为 $text 操作需要唯一的文本索引来解析和评分。如果需要支持多语言搜索,三种方案:① 将中文和英文分词结果合并到同一个 searchTokens 数组中,建立一个 Text Index;② 利用 weights 参数对不同语言字段设不同权重——{ name_cn: "text", name_en: "text" } 作为一个联合 Text Index;③ 如果中英文质量要求都很高,直接使用 MongoDB Atlas Search(支持语言分析器),或外接 Elasticsearch 做独立搜索服务。

延伸阅读与资源

Python 3实战精进:从脚本到高并发订单引擎
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

posted on 2026-08-01 11:47  一天不进步,就是退步  阅读(7)  评论(0)    收藏  举报