MongoDB 计划优选为何拖慢查询

索引都建好了、也都有用,某条查询却时快时慢,甚至偶尔变超慢。这锅可能不在索引,而在 MongoDB 的执行计划优选和计划缓存。这篇聊清楚计划优选为什么会选错、缓存为什么失效,以及在索引无法割舍时,我们怎么用 hint 固定索引、再把固定语句钉到固定副本库上,双管齐下把关键查询焐稳。

explain 排查慢查询,套路是“看有没有走索引,没走就加索引”。

但有一类慢查询更阴险:索引都建好了、每个都有用,某条查询却时快时慢,隔三差五还给你来一次超慢。

这种时候你去 explain,可能还看到走了索引,一脸问号。

这锅,很可能不在索引,而在 MongoDB 的执行计划优选和计划缓存身上。

这篇就来扒一扒它。

先搞懂计划优选是啥

当一条查询有多个索引都能用时,MongoDB 得决定到底走哪个。

这个决定过程,就是计划优选。

它的做法说来挺朴素:

  • 把所有能用的索引各自组一个候选计划
  • 让这些候选计划都试跑一小段(官方叫 trial period,试验期)
  • 看谁在这段试跑里干的活最少、出的结果最多,谁就赢,成为获胜计划

听起来很合理对吧?问题恰恰出在“试跑一小段”这几个字上。

问题一 试跑的样本不靠谱

计划优选是拿一小段试跑的表现来给整条查询下结论的。

这就埋了个雷:这一小段,未必代表整体。

打个比方,两条路去公司,选哪条?

你只看了路口那 100 米——A 路口空旷、B 路口堵着。

于是你判定走 A。

可 A 路口是空的,不代表后面不堵;B 路口堵,也许拐过去就一路畅通。你拿路口 100 米的样本,替整条路做了决定。

MongoDB 的试验期就是这个“路口 100 米”。数据分布要是不均匀,某个索引在试跑那一小段里恰好表现好,就被选成了获胜计划,可放到全量数据上跑,它其实是更慢的那个。

于是就出现了那个让人抓狂的现象:明明走了索引,查询还是慢——因为走的是被误选的那个次优索引。

问题二 缓存把错误固化了

光选错一次还不至于反复难受。真正让它变成“慢性病”的,是计划缓存。

每次都重新试跑所有候选计划,开销太大。所以 MongoDB 会把选出的获胜计划缓存起来,之后相同形状的查询(同样的查询条件结构,官方叫 query shape)直接复用这个缓存的计划,不再重新优选。

这本是个性能优化。但如果缓存的恰好是那个被误选的次优计划,问题就被固化了:

  • 同一类查询,之后每次都照着这个次优计划跑
  • 你 explain 单看一次,还觉得走了索引挺正常
  • 实际上它一直在用那条更慢的路,稳定地慢

一次误判,被缓存放大成了持续的慢。

问题三 缓存还会突然翻脸

更头疼的是,计划缓存不是一成不变的,它会在一些时刻失效或重选,导致查询性能毫无征兆地抖动。

按官方文档,这些情况都会让计划缓存变动:

  • 实例重启:计划缓存不持久化,mongod 一重启,缓存全没,之前选好的计划得重新优选一遍
  • 索引变更(DDL):新建、删除、隐藏索引,都会清掉相关集合的计划缓存
  • LRU 淘汰:缓存有容量上限,很久没用的条目会被挤出去
  • 重新评估:即便是已生效的计划,MongoDB 也会持续评估它的表现,发现它不再划算,会把它打回去重新优选(俗称 replan)

每一次缓存重建,都是一次重新优选,也就意味着又有一次选错的机会。

这就解释了那个经典场景:一条查询平时好好的,某次发版加了个索引、或者半夜实例重启了一下,第二天就莫名其妙变慢——不是你的锅,是计划缓存重建时,优选又抽风选了个次优的。

大部分建议怎么做

查了一圈,社区和官方对这类问题的主流建议,大致这么几条:

  • 精简索引:候选索引越少,优选出错的概率越低。能删的冗余索引、重复索引删掉,别让优选器在一堆相似索引里纠结
  • 建更精准的复合索引:让某条查询有一个明显最优的索引可走,优选自然不容易选偏
  • 用 $planCacheStats 盯着:这个聚合阶段能看到缓存里存了哪些计划、表现如何,用来定位是不是缓存了次优计划
  • 必要时用 hint 强制指定索引:直接告诉 MongoDB 这条查询走哪个索引,跳过优选这道可能出错的环节

前两条是“优化环境让优选少犯错”,但有个前提——你得能砍索引、能重构索引。

可现实往往是:这些索引每个都有用,砍不掉。 这就轮到最后一条了。

我们的做法之一 用 hint 钉死索引

说下我们自己踩出来的经验,场景正是那种“索引无法割舍”的:

每个索引都服务着不同的查询,谁都不能删。但偏偏有那么几条关键查询,因为可选索引多,优选老是不稳,时不时被缓存进一个次优计划,性能抖得厉害。

我们的第一步思路很直接:既然优选靠不住,那就不让它选。

对这几条特定的、性能敏感的查询,用 hint 把它钉死在我们明确知道最优的那个索引上:

// 不让 MongoDB 自己优选,明确指定走 idx_user_status 这个索引
db.orders.find({
    userId: 12345,
    status: "PAID"
}).hint("idx_user_status")
​

加了 hint 之后,这条查询跳过计划优选,每次都走我们指定的索引。相当于绕开了“试跑取样 + 缓存”这套可能出错的机制,让它稳定地走我们验证过的那条最优路。

几个实践要点,是我们踩下来的体会:

  • 只钉关键查询:不是所有查询都上 hint,只挑那几条“可选索引多、优选老抽风、又对性能敏感”的。大部分查询交给优选没问题,别过度干预
  • hint 前一定先 explain 验证:你指定的索引得真的是最优的,别拍脑袋。先 explain 确认它扫描量小、耗时低,再钉死
  • 数据分布变了要复查:hint 是把优化“固化”了,好处是稳,代价是它不会再随数据变化自适应。业务数据结构变了,得回头确认当初钉的索引还是不是最优

光 hint 还不够 内存这道坎

以为钉死索引就万事大吉了?我们后来发现,hint 有时候还是救不了。

问题出在内存上。

索引要跑得快,前提是它的热页得待在内存里(MongoDB 用的 WiredTiger 有块内存缓存,索引和数据页都往里放)。查询走索引时,如果索引页正好在内存,那是真快;可要是机器内存不够,索引页在缓存里待不住、被挤出去了,下次用到就得回磁盘重新读。

磁盘和内存差着好几个数量级。这一读盘,你 hint 得再准,走的是再对的索引,照样慢。

说白了:hint 解决的是“走哪条路”的问题,但解决不了“这条路的地图没装进脑子、每次都得翻书”的问题。 内存不足时,索引缓存不住,就是那个反复翻书的窘境。

这时候单靠 hint 就到头了,得再想办法。

我们的做法之二 固定语句走固定副本库

我们的解法是:把那几条固定的关键查询,专门路由到固定的一个副本节点上去跑。

MongoDB 副本集本来就有多个节点。我们的思路是:让某几条固定语句,稳定地只打到某个指定的副本(secondary)上。

这么做的妙处在于焐热缓存:

  • 这类固定查询反复只在那台副本上跑,它需要的索引页、数据页就被持续访问,长期霸占着那台机器的内存缓存,不容易被挤出去
  • 而且这台副本没有别的杂七杂八的查询来抢缓存、把它的热页顶掉
  • 于是这几条查询要用的索引,就稳稳地常驻内存了

配合起来就是一套组合拳:

  • hint 固定索引:保证走对的那条路(解决优选选错的问题)
  • 固定语句路由到固定副本:保证这条路的索引常驻那台机器内存(解决索引缓存不住的问题)

两个一叠,关键查询才算真正焐稳了——既走对索引,索引又不落盘。缺了任何一个,都可能功亏一篑。

补一句权衡:这套打法是拿架构上的“专机专用”换性能确定性。那台副本某种程度上被“绑定”给了这些关键查询,读负载的分布不再均匀。但对那几条命根子查询来说,用一台副本的偏向去换它们稳定的低延迟,值。

小结

MongoDB 计划优选拖慢查询这事,捋清楚就这么条链子:

  • 优选靠试跑取样,样本不代表整体,可能选中次优索引 → 走了索引也慢
  • 计划缓存把误选固化,同形状查询一直复用那个次优计划 → 稳定地慢
  • 缓存会因重启、索引变更、淘汰、重评而重建 → 性能毫无征兆地抖动

主流解法是精简索引、建更精准的复合索引、拿 $planCacheStats 盯着。而当索引都有用、割舍不掉时,我们的组合拳是:

  • 先用 hint 把关键查询钉死在验证过的最优索引上,解决“优选选错”
  • 再把这些固定语句路由到固定副本库,把索引热页焐在那台机器的内存里,解决“内存不足索引缓存不住”

索引齐全不代表高枕无忧:优选器会帮倒忙,内存不够还会让对的索引也变慢。走对路、还得让路“记在脑子里”,两头都顾上,关键查询才真稳。那就翻翻你那些时快时慢的查询,看看是优选在作怪,还是索引压根没待在内存里吧。

更多推荐

章节目录