1. 项目背景
业务场景:DBA 在慢查询日志中发现一个find({status:"在售"}).sort({createdAt:-1}).limit(20)的执行计划中有 SORT 阶段,但明明有一个{status:1, createdAt:-1}的复合索引。为什么优化器没用它?开发用 explain(“allPlansExecution”) 看到这个索引出现在 rejectedPlans 中——原因是优化器的竞速阶段中它返回第一批结果的速度比另一个候选计划慢了几毫秒,就被"淘汰"了。但开发不服气——“明明用了它就没有 SORT 了,凭什么淘汰?” 要回答这个问题,需要深入查询执行引擎的源码——看 Plan Enumerator 是怎么枚举候选计划的、Stage 树是怎么构建的、SBE 和 Classic Engine 在这个查询上的执行路径有何差异。
痛点:只看 explain 的 winningPlan 而不理解内部机制,就像医生只看 X 光片而不懂解剖学。优化器选错索引时不知道是统计信息问题还是竞速算法问题;对 SBE 和 Classic 的执行差异不理解,升级版本后发现索引行为变化却无法解释。
2. 项目设计
小胖(指着 explain 输出):大师!优化器在我的查询上拒绝了一个完美的索引,选了个有 SORT 阶段的!我要给它提 bug!
大师:先别急,看看 rejectedPlans 里那套索引的竞速数据——它的 totalKeysExamined 是多少?
小胖:嗯……rejected 的那个扫描了 12000 个键,winning 的扫描了 2000 个键。但 winning 有 SORT!
大师:这就是 Plan Enumerator 的竞速机制——每个候选计划在索引上"跑一小段",哪个最先返回 101 个文档的批次(默认 batchSize)哪个就赢。你的{status:1, createdAt:-1}索引虽然最终能消除 SORT,但因为这个索引要按 createdAt 降序扫描——降序扫描在 B-Tree 上的成本比升序略高——加上 status 的选择性不好(status 只有几个值),索引扫描的范围很大。另一个索引虽然多了一个 SORT 阶段,但它的等值过滤更高效,返回首批结果更快——所以竞速赢了。
技术映射:Plan Enumerator 生成所有可能的候选计划(不同索引组合 + 排序策略),每个候选跑一小段(trial period),返回 first batch 最快的计划获胜。这个竞速算法是近似最优而非全局最优。
小胖:那 Stage 树是什么?explain 里那些 COLLSCAN、IXSCAN、FETCH、SORT、LIMIT 是怎么串起来的?
大师:Stage 树是查询执行计划的物理表示——每个 Stage 是一个独立的执行单元,从子 Stage 获取数据,处理后传给父 Stage。一棵 Stage 树从叶到根是这样:
LIMIT (限制返回 20 条) └── SORT (按 createdAt 排序) └── FETCH (从集合中读取完整文档) └── IXSCAN (在索引中查找符合条件的 _id)每个 Stage 都有两个核心方法:work()(做一步工作)和getNext()(获取下一个结果)。引擎通过 Yield(让出锁)机制在 Stage 之间切换,实现非阻塞执行。
技术映射:Stage 树 = 查询的物理执行计划。数据从叶子 Stage 流向根 Stage,每个 Stage 可以提前终止(如 LIMIT 收够了就停)。
小白:那 SBE(Slot-Based Execution)和 Classic Engine 的区别是什么?为什么 MongoDB 5.0+ 默认 SBE?
大师:Classic Engine 是基于 Stage 树的——每个 Stage 是独立对象,数据通过getNext()方法在 Stage 之间"拉"(pull-based)。SBE 则用基于槽位的表达式求值——把查询计划编译成一系列低级的 VM 指令,在寄存器(slot)上直接运算,避免了 Stage 之间的函数调用开销。
可以这样类比——Classic Engine 是解释型语言(每个 Stage 独立执行),SBE 是 JIT 编译(查询编译成 VM 指令后高效执行)。在包含复杂表达式(如$addFields、$project中做了计算)的查询中,SBE 比 Classic 快 20%-50%。
技术映射:SBE 在src/mongo/db/query/sbe/中实现,核心类是PlanStage和CompiledExpression。SBE 使用 SSA(静态单赋值)形式的槽位,表达式求值被编译为线性指令序列。
大师(总结):查询执行引擎的三个核心——Plan Enumerator 生成候选计划 + Stage 树执行物理查询 + SBE 加速表达式求值。从 explain 到源码追踪,是理解查询行为和性能差异的终极手段。
3. 项目实战
3.1 环境准备
需要第 32 章搭建的 MongoDB 源码调试环境。
3.2 分步实现
步骤一:在源码中找到 find 命令的入口
// 源码追踪路径(在 GDB 中打断点从外到内)// 第 1 层:命令入口// 文件:src/mongo/db/commands/find_cmd.cpp// 函数:FindCmd::run()// 作用:解析 BSON 命令,提取 filter/sort/projection/limit 等参数// 第 2 层:获取查询执行器// 文件:src/mongo/db/query/get_executor.cpp// 函数:getExecutorFind()// 作用:解析查询 → 规范化 → 生成 CanonicalQuery → 选择执行计划// 第 3 层:计划生成// 文件:src/mongo/db/query/plan_enumerator.cpp// 函数:PlanEnumerator::enumerate()// 作用:枚举所有可能的索引组合 + 排序策略// 第 4 层:执行// 文件:src/mongo/db/query/plan_executor_impl.cpp (Classic)// 或 src/mongo/db/query/sbe/stage_builder.cpp (SBE)// 作用:构建 Stage 树并逐行执行# GDB 断点脚本——追踪 find 命令全链路 break mongo::FindCmd::run break mongo::getExecutorFind break mongo::PlanEnumerator::enumerate break mongo::PlanExecutorImpl::getNext步骤二:观察候选计划能否通过
目标:理解索引选择逻辑——哪些索引能成为候选。
// 用一个有多种索引的集合测试use local_life db.query_trace.drop()// 建多个索引让优化器有选择空间db.query_trace.createIndex({status:1,category:1,price:1})db.query_trace.createIndex({status:1,category:1,createdAt:-1})db.query_trace.createIndex({status:1,price:1})// 插入数据for(vari=0;i<50000;i++){db.query_trace.insertOne({name:"trace_"+i,status:i%10===0?"下架":"在售",category:["数码","家居","食品"][i%3],price:i*1.5,createdAt:newDate(Date.now()-i*60000)})}// 分析查询计划的候选varexp=db.query_trace.find({status:"在售",category:"数码"}).sort({createdAt:-1}).limit(20).explain("allPlansExecution")print("=== 优化器候选计划 ===")print("Winning:",exp.queryPlanner.winningPlan.indexName||"COLLSCAN")varrejected=exp.queryPlanner.rejectedPlans||[]rejected.forEach(function(p,i){print("Rejected["+i+"]:",p.indexName||"COLLSCAN",p.stage==="SORT"?"(含SORT阶段)":"")})// 查看各候选的竞速扫描数if(exp.executionStats.allPlansExecution){exp.executionStats.allPlansExecution.forEach(function(p,i){print("计划"+i+":",p.indexName||"COLLSCAN","扫描键:",p.totalKeysExamined,"扫描文档:",p.totalDocsExamined)})}步骤三:理解 CanonicalQuery 的作用
// CanonicalQuery 是优化器的输入——将查询标准化为统一的内部表示// 等价查询标准化:// { status: { $in: ["在售"] } } → { status: "在售" }// { $and: [{a:1}, {b:2}] } → { a:1, b:2 }// { price: { $gt: 100, $gte: 100 } } → { price: { $gte: 100, $gt: 100 } }// 这步标准化在 getExecutorFind() 中完成// 源码中对应:CanonicalQuery::canonicalize()// CanonicalQuery 还负责:// - 提取等值条件(Equality)、排序(Sort)、范围(Range)的分类// - 计算查询的"形状"(Query Shape)用于 Plan Cache 匹配// - 判断是否能用覆盖索引(Covered Query)步骤四:理解 Stage 树的执行模型
// 以下用伪代码展示 Stage 树的 pull-based 执行模型/* class LimitStage { work() { if (returned < limit) { auto child = childStage->getNext(); // 向子 Stage 请求下一条 if (child) { result = child; returned++; return ADVANCED; // 返回一条结果 } return EOF; // 子 Stage 没有更多数据 } return EOF; // 已经返回够了 } } class SortStage { work() { // 第一阶段:收集所有数据 + 排序 while (child->getNext()) { buffer.push_back(...); } sort(buffer); // 第二阶段:逐条返回 return buffer.next(); } } class IxScanStage { work() { auto next = indexCursor->next(); // 从索引中取下一个 key if (next) { return ADVANCED; } return EOF; } } */步骤五:SBE 执行计划的观察
// 查看当前使用的执行引擎varparams=db.adminCommand({getParameter:1,internalQueryFrameworkControl:1})print("当前执行引擎:",params.internalQueryFrameworkControl)// "trySbeEngine" = 默认优先 SBE,SBE 不支持的查询 fallback 到 Classic// "forceClassicEngine" = 强制 Classic// SBE 的 explain 格式与 Classic 不同// SBE explain 的 stage 显示为 "EXEC" 而非经典 Stage 树// 用以下方式对比两者:// Classic explain(强制 Classic 引擎看)db.adminCommand({setParameter:1,internalQueryFrameworkControl:"forceClassicEngine"})varclassicExp=db.query_trace.find({status:"在售",category:"数码"}).sort({createdAt:-1}).limit(20).explain("executionStats")// 恢复 SBEdb.adminCommand({setParameter:1,internalQueryFrameworkControl:"trySbeEngine"})varsbeExp=db.query_trace.find({status:"在售",category:"数码"}).sort({createdAt:-1}).limit(20).explain("executionStats")print("Classic:",JSON.stringify(classicExp.executionStats?.executionStages?.stage||"?"))print("SBE:",JSON.stringify(sbeExp.executionStats?.executionStages?.stage||"?"))print("Classic耗时:",classicExp.executionStats?.executionTimeMillis,"ms")print("SBE耗时:",sbeExp.executionStats?.executionTimeMillis,"ms")步骤六:Plan Cache 的源码视角
# GDB 中追踪 Plan Cache 的 hit/miss 行为# 打断点:(gdb)breakmongo::PlanCache::get(gdb)breakmongo::PlanCache::add# 当 get 返回空 → Cache MISS → 触发 Plan Enumerator 竞速 → 调用 add 写入缓存# 当 get 返回非空 → Cache HIT → 直接使用缓存计划,跳过竞速3.3 完整代码清单
| 文件 | 用途 |
|---|---|
debug-scripts/find-chain.gdb | GDB 脚本——find 全链路断点 |
debug-scripts/query-trace-setup.js | 准备多索引测试数据 |
debug-scripts/classic-vs-sbe.js | Classic 与 SBE 对比实验 |
3.4 测试验证
use local_life// 1. 验证 CanonicalQuery 标准化// 两种写法在 explain 中应有相同的 Plan Cache keyvarq1=db.query_trace.find({status:{$in:["在售"]}}).explain()varq2=db.query_trace.find({status:"在售"}).explain()print("标准化: planCacheKey 相同?",q1.queryPlanner.planCacheKey===q2.queryPlanner.planCacheKey?"PASS":"注意观察")// 2. 验证 rejectedPlans 包含竞速数据varexp=db.query_trace.find({status:"在售",category:"数码"}).sort({createdAt:-1}).limit(20).explain("allPlansExecution")print("候选计划数:",(exp.executionStats.allPlansExecution||[]).length)// 3. 验证 SBE vs Classic 切换db.adminCommand({setParameter:1,internalQueryFrameworkControl:"forceClassicEngine"})varc=db.query_trace.find({status:"在售"}).limit(5).explain("executionStats")print("Classic stage:",c.executionStats.executionStages.stage)db.adminCommand({setParameter:1,internalQueryFrameworkControl:"trySbeEngine"})vars=db.query_trace.find({status:"在售"}).limit(5).explain("executionStats")print("SBE stage:",s.executionStats.executionStages.stage)print("\n=== 查询引擎验证完成 ===")4. 项目总结
4.1 查询执行链路速查
| 阶段 | 职责 | 源码关键文件 |
|---|---|---|
| 命令解析 | BSON → 内部查询对象 | find_cmd.cpp |
| 查询规范化 | 等价变换、提取 ESR 分类 | canonical_query.cpp |
| 计划生成 | 枚举索引组合 + 竞速选择 | plan_enumerator.cpp |
| 计划缓存 | 缓存 winning plan 供后续复用 | plan_cache.cpp |
| 执行(Classic) | Stage 树 pull 模型 | plan_executor_impl.cpp |
| 执行(SBE) | 槽位求值 VM | sbe/stage_builder.cpp |
| 存储读取 | 通过 RecordStore 读文档 | wiredtiger_record_store.cpp |
4.2 适用场景
查询引擎源码追踪适用:
- 优化器选错索引的根因分析——是统计信息偏差还是竞速算法的近似缺陷。
- SBE 和 Classic 的行为差异排查——哪些查询在 SBE 下退化。
- 自定义 MongoDB 功能——添加新的查询优化规则或聚合阶段。
- Plan Cache 命中率分析——缓存失效的触发条件。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| Plan Enumerator 的竞速不是全量执行 | 每个候选计划只跑一小段,选"最快返回首屏"的 |
| SBE 不是所有查询都支持 | 复杂的地理空间查询、某些$lookup变体仍 fallback 到 Classic |
| Plan Cache 的失效条件 | 索引变更、大量写入导致统计分布显著改变时失效 |
强制internalQueryFrameworkControl仅调试用 | 生产环境不应该强制切换执行引擎 |
4.4 常见踩坑经验
故障案例一:SBE fallback 到 Classic 行为不同
某团队从 MongoDB 4.4 升级到 5.0,一个复杂聚合查询的 explain 从 COLLSCAN 变成了 IXSCAN。根因:4.4 只有 Classic Engine,5.0 的 SBE 对$project阶段有不同优化,导致了不同的索引选择。解决:在测试环境用forceClassicEngine对比确认行为差异;对有差异的查询添加 hint 确保可预期的执行计划。
故障案例二:Plan Cache 导致性能退化
某查询在数据分布变化后 Plan Cache 仍用旧计划(totalKeysExamined 从 1000 膨胀到 10 万),但优化器因为缓存命中不再重新竞速。解决:db.collection.planCacheClear()手动清除;或在查询上加 hint 后移除 hint(让优化器重新竞速)。
故障案例三:Sort Stage 在实际中比优化器认为的更贵
优化器选了有 SORT 的计划,因为它的竞速 trial period 还没到 SORT 阶段就开始返回数据——但全量执行时 SORT 把所有数据加载到内存排序,导致 OOM。根因:优化器对 SORT 的成本评估基于"平均文档大小",但如果实际文档很大,内存排序的开销被低估。解决:为需要排序的查询显式建覆盖排序需求的复合索引。
4.5 思考题
- 为什么 Plan Enumerator 不直接全量执行所有候选计划来选最优,而是用竞速(trial period)的方式?全量执行的代价有多大?
- 如果一个查询的 Plan Cache 命中但 explain 显示 totalKeysExamined 比预期多很多——是 Plan Cache 的问题还是统计信息的问题?怎么区分?
(答案将在第 35 章末尾揭晓)
上一章思考题答案:
缓存中 80% 是脏页的后果——Checkpoint 触发时需要刷入海量脏页,可能导致数秒甚至数十秒的磁盘 IO 风暴,期间应用层写入延迟飙升。MongoDB 默认限制脏页比例在 20% 以内——通过
eviction_target和eviction_trigger参数,当脏页超过 20% 时后台 eviction server 加速淘汰,应用线程在脏页超过 95%(eviction_dirty_target)时被迫参与淘汰。
j:true在 WiredTiger 层面调用WT_SESSION::log_flush(),强制将当前 Journal 缓冲区刷入磁盘并等待fsync()完成——产生至少一次磁盘 IO(数毫秒)。而w:1只写内存缓存(不发生磁盘 IO,或由后台 100ms 的 sync 完成)。这就是 j:true 比 w:1 慢 10-100 倍的原因。
延伸阅读与资源
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析