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 适用场景
查询优化器调优适用:
- 排查"有索引但查询慢"——explain 分析 totalKeysExamined vs nReturned。
- 确认覆盖索引是否生效——totalDocsExamined = 0 即覆盖。
- 诊断优化器选错索引——查看 rejectedPlans 或用 hint 对比。
- 生产环境索引变更前验证——先隐藏索引观察,无影响再删。
- 分析 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 思考题
- 如果一个查询
find({a:1, b:2}).sort({c:1})的 explain 显示 winningPlan 中有SORT阶段(内存排序),而候选计划中存在{a:1, c:1, b:1}索引可以消除排序,为什么优化器没选它? - MongoDB 6.0+ 中引入的
$planCacheStats和传统的planCacheList()有什么区别?如何通过统计信息评估一个 Plan Cache 条目的"性价比"?
(答案将在第 22 章末尾揭晓)
上一章思考题答案:
同时命中 2dsphere 和普通复合索引时,MongoDB 优化器会评估每个索引的查询成本(基于索引选择性估算)并在候选计划中进行短程竞速,选择最快返回第一批结果的计划。如果两个索引"质量"相似,哪个索引的名字典序靠前或建得早没有确定性——不依赖隐式行为。对关键查询建议
hint("idx_name")强制指定。一个集合只能有一个 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 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号