第34章:Mongo查询执行引擎源码——一次 find 是怎样跑完的

发布时间:2026/7/24 9:32:30
第34章:Mongo查询执行引擎源码——一次 find 是怎样跑完的 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 收够了就停。小白那 SBESlot-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(vari0;i50000;i){db.query_trace.insertOne({name:trace_i,status:i%100?下架:在售,category:[数码,家居,食品][i%3],price:i*1.5,createdAt:newDate(Date.now()-i*60000)})}// 分析查询计划的候选varexpdb.query_trace.find({status:在售,category:数码}).sort({createdAt:-1}).limit(20).explain(allPlansExecution)print( 优化器候选计划 )print(Winning:,exp.queryPlanner.winningPlan.indexName||COLLSCAN)varrejectedexp.queryPlanner.rejectedPlans||[]rejected.forEach(function(p,i){print(Rejected[i]:,p.indexName||COLLSCAN,p.stageSORT?(含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 执行计划的观察// 查看当前使用的执行引擎varparamsdb.adminCommand({getParameter:1,internalQueryFrameworkControl:1})print(当前执行引擎:,params.internalQueryFrameworkControl)// trySbeEngine 默认优先 SBESBE 不支持的查询 fallback 到 Classic// forceClassicEngine 强制 Classic// SBE 的 explain 格式与 Classic 不同// SBE explain 的 stage 显示为 EXEC 而非经典 Stage 树// 用以下方式对比两者// Classic explain强制 Classic 引擎看db.adminCommand({setParameter:1,internalQueryFrameworkControl:forceClassicEngine})varclassicExpdb.query_trace.find({status:在售,category:数码}).sort({createdAt:-1}).limit(20).explain(executionStats)// 恢复 SBEdb.adminCommand({setParameter:1,internalQueryFrameworkControl:trySbeEngine})varsbeExpdb.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.gdbGDB 脚本——find 全链路断点debug-scripts/query-trace-setup.js准备多索引测试数据debug-scripts/classic-vs-sbe.jsClassic 与 SBE 对比实验3.4 测试验证use local_life// 1. 验证 CanonicalQuery 标准化// 两种写法在 explain 中应有相同的 Plan Cache keyvarq1db.query_trace.find({status:{$in:[在售]}}).explain()varq2db.query_trace.find({status:在售}).explain()print(标准化: planCacheKey 相同?,q1.queryPlanner.planCacheKeyq2.queryPlanner.planCacheKey?PASS:注意观察)// 2. 验证 rejectedPlans 包含竞速数据varexpdb.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})varcdb.query_trace.find({status:在售}).limit(5).explain(executionStats)print(Classic stage:,c.executionStats.executionStages.stage)db.adminCommand({setParameter:1,internalQueryFrameworkControl:trySbeEngine})varsdb.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执行ClassicStage 树 pull 模型plan_executor_impl.cpp执行SBE槽位求值 VMsbe/stage_builder.cpp存储读取通过 RecordStore 读文档wiredtiger_record_store.cpp4.2 适用场景查询引擎源码追踪适用优化器选错索引的根因分析——是统计信息偏差还是竞速算法的近似缺陷。SBE 和 Classic 的行为差异排查——哪些查询在 SBE 下退化。自定义 MongoDB 功能——添加新的查询优化规则或聚合阶段。Plan Cache 命中率分析——缓存失效的触发条件。4.3 注意事项注意事项说明Plan Enumerator 的竞速不是全量执行每个候选计划只跑一小段选最快返回首屏的SBE 不是所有查询都支持复杂的地理空间查询、某些$lookup变体仍 fallback 到 ClassicPlan Cache 的失效条件索引变更、大量写入导致统计分布显著改变时失效强制internalQueryFrameworkControl仅调试用生产环境不应该强制切换执行引擎4.4 常见踩坑经验故障案例一SBE fallback 到 Classic 行为不同某团队从 MongoDB 4.4 升级到 5.0一个复杂聚合查询的 explain 从 COLLSCAN 变成了 IXSCAN。根因4.4 只有 Classic Engine5.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 实战修炼与源码剖析