见过不少在 MongoDB 上跑生产业务的同学,遇到“明明建了索引,查询还是很慢”或者“查询走了不该走的索引”这类问题,第一反应就是把查询改一改、或者加个复合索引试试。但很多时候,真正的问题不在索引本身,而在 MongoDB 查询优化器留下的 Plan Cache(计划缓存)——它把一个糟糕的查询计划“记住”了,之后每次查询都照着坏计划走。
这篇文章聊的就是 MongoDB 为什么会选错索引,以及当问题出在 Plan Cache 时,该如何清理、如何在清理之后避免再次踩坑。适合正在排查慢查询、或者被“索引失效”困扰的朋友,尤其是生产环境不敢乱动的场景,本文的步骤都能给你一个相对稳妥的参考路径。
1. MongoDB查询优化器到底是怎么选索引的
1.1 优化器的工作流程
MongoDB 的查询优化器并不是什么高深莫测的 AI,它的核心逻辑是“枚举几份候选方案,挑一个看起来最便宜的来执行”。当一条查询请求进入实例,优化器会做这么几件事:
- 根据查询条件里的等值条件、排序字段、范围条件,初步筛选出所有可能用到的索引。
- 对这些候选索引分别生成一个执行计划。过程中优化器会尝试把多个字段条件组合起来,或者考虑索引与排序是否匹配,看哪种方案能“最小代价”地把数据取回来。
- 通过内部的 cost-based 估算,为每个计划打一个预估分,选出分数最优的作为 winning plan。
- 把 winning plan 写入 Plan Cache,后续同样的查询形状(query shape)直接复用这个计划,不再重新做全量枚举。
MongoDB 的索引选择逻辑虽然底层实现比较复杂,但把它理解为“用几个条件去匹配几个候选索引,然后挑成本最低的”,基本没有太大偏差。很多人容易忽略的一点是:优化器做的是“预估”,而不是“实际执行后挑选”。也就是说,在没有足够真实的统计信息做支撑时,它可能把某个计划估得很便宜,实际跑起来却很糟糕。
1.2 选错索引的几个典型原因
从我实际接触过的案例来看,MongoDB 选错索引通常有下面几种典型场景。
数据倾斜导致统计失真。MongoDB 的索引选择依赖于集合统计信息和采样估算。如果集合里某个字段值的分布极不均匀,比如 status 字段 99% 都是 pending,只有 1% 是 finished,优化器根据采样统计去估算,可能认为走某个索引能过滤掉很多数据,但实际扫描出来依然是一大堆 pending。这种“看着是走索引了,其实和全表扫没区别”的现象非常常见。
查询条件里有范围、排序和不等值操作。正则、$nin、$ne、$not,以及涉及时间范围的查询,对索引选择的稳定性影响很大。比如 { status: "pending", createTime: { $gte: ISODate("...") } },如果状态字段选择性很强但时间字段更宽泛,优化器可能因为估算误差,选择了 createTime 索引,结果扫描了大量文档,而实际上 status 索引更适合。这类问题在数据增长、统计值刷新不及时的时候最容易暴露。
索引前缀顺序和查询条件顺序不匹配。复合索引本身有最左前缀原则,但很多朋友在建复合索引时,习惯把“查询最多的字段”放前面,而不是把“区分度最高的字段”放前面。比如查询永远是 { city: "北京", age: 18 },但集合里 city 的区分度极低,age 区分度很高。如果索引为 { city: 1, age: 1 },优化器很可能优先使用 city 前缀,结果扫描了北京的一大堆数据;而 { age: 1, city: 1 } 更高效。索引顺序不合理,是优化器“选错”的高发原因。
此外,一个必须提的点是索引交错(index intersection)。MongoDB 支持对多个单字段索引做交集来匹配查询条件。这种方案在某些场景下是救命稻草,但优化器有时会被“交集”的预估成本迷惑,认为两个索引交集比一个更合适的复合索引更快,结果实际执行时反而要扫描大量的索引条目并做合并操作,性能一落千丈。
选错索引本身并不是 MongoDB 的缺陷。每个数据库的优化器都不同程度地存在估算误差,但在 MongoDB 里,选错索引后被 Plan Cache 固化,问题会被无限放大。这也是文章标题里把“查询优化器”和“Plan Cache”同时点出来的原因。
2. Plan Cache:优化器的“记忆”与误伤现场
2.1 Plan Cache到底缓存了什么
Plan Cache 是 MongoDB 3.0 引入的机制,目的很单纯:减少优化器重复做计划枚举的开销。同一个查询形状跑上万次,如果每次都重新枚举,CPU 和耗时都是浪费。所以 MongoDB 把第一次查询选出的 winning plan 缓存起来,后面同样形状的查询直接复用。
查询形状(query shape)是一个关键概念。MongoDB 不是简单地以 SQL 字符串作为缓存键,它会把查询做结构化归一化,忽略具体的字段值,只保留查询结构。例如:
- db.users.find({ age: 18, city: "北京" }) 和 db.users.find({ age: 20, city: "上海" }) 属于同一个查询形状。
- db.users.find({ age: 18 }) 和 db.users.find({ age: 18, city: "北京" }) 则是不同的查询形状,因为字段集合变了。
- sort、projection、collation 也会影响查询形状的定义。
Plan Cache 缓存的内容不仅包含 winning plan,还有候选计划列表、部分统计信息、以及用于判断缓存命中与否的 planCacheKey。
在 MongoDB 4.2 之后,我们可以通过 $planCacheStats 聚合阶段来查看集合级别的 Plan Cache 内容。比如在 mongosh 里执行:
db.users.aggregate([ { $planCacheStats: {} } ])能看到缓存了哪些查询形状、每个形状对应的 winningPlan 是什么、创建时间、命中次数等信息。
2.2 为什么缓存会让错误的计划持续生效
这是全文最需要理解的部分。优化器选错索引,可能只是一次估算失误;但如果这个错误被 Plan Cache 记住,那后续所有相同查询形状的请求都会直接复用这个坏计划,不再进行重新估算。
举个例子。假设凌晨跑批脚本对 orders 集合做大量状态聚合,那时集合统计信息还没更新,优化器估算后选了一个比较差的索引方案 A,并将计划 A 写入 Plan Cache。到了白天,业务高峰期来了,应用端大量查询 orderId 或者 userId 的订单,这些查询如果和凌晨的批处理查询形状一致,就会直接命中缓存里的坏计划 A。结果就是慢查询雪崩式出现。即使手动删掉索引、重建索引,Plan Cache 里的旧计划也未必自动失效,除非触发缓存失效条件。
Plan Cache 什么时候会失效或被重新评估?以下几种情况比较常见:
- 集合上发生重建索引、删除索引、创建新索引。
- planCacheKey 相关索引集合发生结构变化。
- 缓存条目被显式清理。
- 查询条件里字段变化导致查询形状改变。
- 实例发生大批量写操作、dropCollection 或 restore 等极端情况。
但注意,单纯的数据量增长和统计信息变化,并不会让 Plan Cache 自动“清醒”。这恰恰是生产环境里最坑的地方:你以为删了旧索引、建了新索引,查询就会自动走新计划,但结果可能还是走旧缓存。
2.3 如何确认你的查询命中了缓存
排查慢查询时,第一步就是要确认查询到底是在启用缓存的计划,还是重新做了计划选择。可以利用 explain 输出里的 planCacheKey 来判断。执行:
db.users.find({ age: 18 }).explain("queryPlanner")返回结果中如果有 planCacheKey 字段,说明这条查询可以关联到某个查询形状;如果发现 explain 多次执行结果中里 winningPlan 相同,并且缓存命中,则结果中通常还会包含 planCache 相关的条目引用。
另外一个更直接的验证手段是:第一次执行查询,记录执行计划;然后修改集合统计信息或删除某个索引,再执行同样查询,观察执行计划是否变化。如果依旧不变,基本可以判定是 Plan Cache 在起作用。
如果是慢查询日志中定位到某条查询,也可以去 $planCacheStats 输出中搜索对应的 planCacheKey,看看这条计划是什么时候生成的、命中次数多少,能帮你判断缓存是“老计划”还是“新计划”。
3. 排查选错索引的实操步骤
3.1 用explain识别真实执行计划
真正动 Plan Cache 之前,必须先看清当前的执行计划。explain 的三种模式要弄清楚:
- queryPlanner:只做计划分析,不实际执行,最快但不反映真实扫描行数。
- executionStats:会实际执行查询,返回执行后的统计信息,如 totalDocsExamined、totalKeysExamined、executionTimeMillis。
- allPlansExecution:不仅执行 winning plan,还会执行所有候选计划,并保留每个候选的被拒绝计划和执行统计,信息最全,代价也最大。
定位慢查询时,我一般先执行 executionStats 模式,重点看两个数:totalKeysExamined 和 totalDocsExamined。
db.users.find({ age: 18 }).explain("executionStats")关注输出里的这些字段:
- winningPlan.inputStage.stage:如果出现了 IXSCAN,说明走的是索引;如果出现 COLLSCAN,说明是全表扫。
- totalKeysExamined:扫描了多少条索引条目。
- totalDocsExamined:扫描了多少条文档。
- executionTimeMillis:实际执行耗时。
- rejectedPlans:被优化器拒绝的其他候选计划列表。这里非常值得关注,因为你可以看到优化器对比了哪些方案,最后选了当前这个。
很多情况下,对比 winningPlan 和 rejectedPlans,能直接看出优化器为什么选错。比如 winningPlan 走了单字段索引 A,而 rejectedPlans 里有复合索引 B,并且从索引条目规模上看 B 明显更优,那大概率是优化器的估算逻辑被集合统计信息骗了,或者索引选择性并不像你想的那么好。
3.2 判断计划好坏的关键指标
这里说几个我在实际排查中固定会看的指标组合。
第一个组合是 totalKeysExamined 与返回文档数(nReturned)的比值。如果 totalKeysExamined 是 10 万,但 nReturned 只有 100,说明扫描了大量索引条目才找到目标,索引选择性非常差。正常情况这个比值应该接近 1:1 或者 10:1 以内。如果达到几千比一,这个索引方案基本是错的。
第二个组合是 totalDocsExamined 与 nReturned 的比值。如果走了索引但还需要回表读取大量文档,且大部分文档被过滤掉,说明索引没法覆盖查询字段,匹配后还要回原集合取数据做进一步过滤。这种场景下,可能是索引字段顺序不佳,或者缺少必要的复合字段。
第三个是 stage 的连续性。看 winningPlan 的 inputStage 链路是否合理。比如 stage 是 FETCH -> IXSCAN,说明先扫索引再回表;如果 stage 是 PROJECTION_COVERED,说明索引完全覆盖查询和投影字段,效率最高。如果出现 SORT 阶段,还需要检查排序是否用到了索引,避免在内存中排序。
当你通过这些指标确认了当前计划是错的,再去看 $planCacheStats,就能确认这条坏计划是不是被缓存固化下来的。判断顺序不要反过来,先看计划,再看缓存,否则很容易被缓存存在这件事本身误导。
4. 清理Plan Cache的正确姿势
4.1 单集合清理与按查询清理
确认坏计划来自 Plan Cache 后,清理缓存就是最直接的操作。MongoDB 提供的方法主要有两类。
第一类是清空整个集合的 Plan Cache:
db.users.getPlanCache().clear()执行后,该集合下所有缓存条目都会被删除。下次查询时,优化器会重新做完整的计划选择,重新生成缓存。这个方法简单粗暴,适合索引改动比较大、需要整体刷新缓存的情况。
第二类是只清理特定的查询形状:
db.users.getPlanCache().clearPlansByQuery({ age: 18 })clearPlansByQuery 的参数是查询条件,MongoDB 会根据这个查询条件去匹配缓存条目,并删除对应的计划。这个方法只影响传入的查询形状,其他查询不受影响,适合只修复某一条慢查询、不想干扰其他正在运行的查询缓存的场景。
另外还有一个更细粒度的方式,就是更新查询自身,例如通过在查询中增加 hint 指定使用某个索引,使查询形状发生变化,从而不命中旧缓存。但 hint 属于“治标”手段,更适合紧急止血。
4.2 批量清理脚本与生产注意事项
生产环境往往有几十上百个集合,手动一个一个 clear 效率太低。可以写一段脚本批量处理。
// 在 mongosh 中运行 const dbName = "yourdb"; const db = db.getSiblingDB(dbName); db.getCollectionNames().forEach(function (coll) { const planCache = db.getCollection(coll).getPlanCache(); try { planCache.clear(); print("cleared plan cache for " + dbName + "." + coll); } catch (err) { print("failed to clear for " + coll + ": " + err.message); } });执行前务必先评估影响范围。Plan Cache 清理并不是删索引,不会影响数据,也不会导致查询失败,但它会让优化器在短时间内重新做多次计划计算,对 CPU 有一定额外开销。在业务高峰期,如果一次性清掉大量集合的 Plan Cache,可能有短时性能波动。
我的建议是:
- 生产环境先挑单个集合验证,观察 5~10 分钟,确认查询恢复、无新增慢查询再扩大范围。
- 清理操作建议在维护窗口或低峰期进行。
- 清理前用 $planCacheStats 或 explain 输出做好“现场留档”,便于事后对比。
- 不要写成定时任务每天清缓存。Plan Cache 本身是良性机制,频繁清理只是掩盖了更底层的索引或统计问题。
4.3 清理之后还要做什么
清完缓存只是第一步,真正要做的是让优化器下次“选对”,否则用不了几天坏计划又会回来。重点检查以下几项。
检查索引是否合理。用 explain 的 rejectedPlans 对比候选计划,确认当前索引组合是否真的是最优。如果优化器每次都不选复合索引,可能不是优化器的问题,而是这个索引本身建得有问题:字段顺序不对、区分度不够、或者包含了无用的排序字段。
确认集合统计信息是否过旧。执行 db.collection.stats(),查看集合的文档数量和存储大小。如果集合数据量已经增长了很多倍,可以考虑对相关字段执行一次 compact 或重建索引,让优化器有更接近真实分布的数据。
考虑在查询上使用 hint。生产环境里并不是所有查询都适合依赖优化器。对少数几个用户核心路径上的查询,如果手动指定索引能稳定得到最优计划,那就别不好意思用 hint。很多大厂的生产实践也表明,对关键查询用 hint 是降低“选错索引”事故概率的最有效手段之一。
MongoDB 中指定 hint 的写法非常简单:
db.users.find({ age: 18 }).hint({ age: 1, city: 1 })清理 Plan Cache 后,如果马上在查询上加了 hint,旧缓存就算清掉了,新缓存也会以 hint 指定的索引生成,后续执行不会再有偏差。
5. 常见问题与事后复盘
5.1 典型症状速查表
我把这几年在社区里和实际运维中遇到过的典型场景整理成了一张速查表,方便大家对照排查。
| 症状 | 可能原因 | 优先排查手段 |
|---|---|---|
| 查询走了全表扫描但建了索引 | 索引未被查询条件匹配,或优化器认为索引成本更高 | explain 看 COSCAN 是否出现在 winningPlan;检查索引字段顺序 |
| 查询走了索引但 totalDocsExamined 巨大 | 索引选择性差,回表过滤大量文档 | 比较 totalKeysExamined 与 nReturned;考虑复合索引覆盖 |
| 删除索引后查询仍然正常 | 内存中计划可能来自其他可用索引,或缓存未刷新 | 清 Plan Cache,观察新的 winningPlan |
| 新建索引后查询没有变化 | 查询形状命中旧 Plan Cache,没有重新计划 | 用 clearPlansByQuery 清理对应查询,或清整个集合缓存 |
| 相同查询时快时慢 | 查询形状相同但命中不同缓存条目,或数据分布突变 | 检查 $planCacheStats 的命中次数与创建时间 |
| 聚合查询结果异常但单条 find 正常 | 聚合管道涉及多个 collection 或 $lookup,缓存内容可能不匹配 | 检查 $planCacheStats,清理后重试 |
需要注意,Plan Cache 清理不是慢查询排查的“银弹”。如果清理后执行计划依然不变,说明问题不在缓存层,而在索引设计或查询结构本身。这时候继续反复清缓存就是在浪费时间和掩盖问题。
5.2 我踩过的几个坑
最后分享几个实操中踩过的坑,都是真金白银换来的经验。
第一个坑是清完缓存后,没有检查集合 stats 就急着做结论。有一次生产慢查询清完 Plan Cache 后短暂恢复,没过两天又慢了,后来发现是集合数据量从 100 万涨到了 3000 万,但索引统计信息没有跟上。清缓存只能解决“计划被固化”的问题,解决不了“统计数据失真”的问题。
第二个坑是盲目删除旧索引。有一段时间我发现某查询总是走一个快被淘汰的单字段索引,觉得是缓存问题,清完之后还是走它。后来用 allPlansExecution 对比 rejectedPlans,才发现那个单字段索引的选择性其实非常优秀,真正问题出在复合索引字段顺序上。如果当时直接把单字段索引删了,很可能引发更严重的全表扫描。
第三个坑是小看了索引交错。MongoDB 在多条件等值查询下会自动尝试索引交错,这在数据量小时表现不错,但数据量上来后,合并多条索引条目往往比直接扫一个复合索引慢很多。而且索引交错生成的计划也会被缓存,如果不留意,你在 explain 里看到 IXSCAN 加 AND_SORTED 相关的 stage,要特别警觉。
第四个坑是生产环境清了全库 Plan Cache。一次凌晨变更时,我写了个脚本批量把所有业务库的 Plan Cache 都清了,结果第二天早上业务报告延迟上升。原因是低峰期瞬间重新生成大量计划,导致优化器计算占用了 CPU。虽然影响不严重,但说明清理操作本身也有成本,不能随手全世界清一遍。
顺带说一个小技巧。如果排查时搞不清到底是优化器选错还是缓存固化,可以在测试环境复制线上数据,把相同的查询反复预热,然后执行 db.collection.getPlanCache().clear() 删除缓存,再执行 explain 看新计划。这样可以分离变量,确认问题到底出在创建计划阶段,还是出在复用计划阶段。这套思路在你拿到的不是线上权限而是测试库时,特别管用。
要理解 MongoDB 的索引选择,不能只站在“索引建得好不好”这个维度去思考,还要意识到优化器的估算机制和 Plan Cache 的记忆效应会放大所有设计上的小问题。慢查询出现时,先别急着改查询或者删索引,打开 explain,看一眼 winningPlan 和 rejectedPlans,再用 $planCacheStats 判断缓存是否在捣乱,最后才决定是清理缓存,还是调整索引策略,还是加上 hint 保平安。这套流程你跑通一次,后面再遇到类似问题,心里就有底得多。