转载于:https://cloud.tencent.com/developer/article/1416663
原文发布于微信公众号 - Mongoing中文社区(mongoing-mongoing)
原文发表时间:2018-05-25
作者: A.Jesse Jiryu Davis
译者: 孔德雨
对于一个MongoDB的复杂查询,如何才能创建最好的索引?在本篇文章中,我将展现一种给读请求定制的索引优化方法,这种方法会考虑读请求中的比较,排序以及范围过滤运算,并展示符合索引中字段顺序的最优解。我们将通过研究explain()命令的输出结果来分析索引的优劣,并学习MongoDB的索引优化器是如何选择一个索引的。
构建MongoDB使用场景
假设我们现在要基于MongoDB做一个类似于Disqus的评论系统(Disqus实际是基于PG数据库的,但是我们可以假设它是基于MongoDB的)。如果评论数有数百万,下面的代码段展示出其中的四条。每一条有一个timestamp,一个rating字段(关于评论品质的打分),和anonymous字段(表示是否匿名评论,bool类型)。
现在,我想要查询非匿名评论中,timestamp在[2,4]之间的。返回结果按照rating排序。我们将分三个步骤构建查询语句,并通过MongoDB的explain()命令选择最合适的索引。
范围查询(Range Query)
首先,我们构建一个简单的范围查询,查询timestamps在[2,4]之间的记录。
很明显,有三条满足条件的记录,通过explain(),我们可以看到Mongodb是如何找到这三条记录的:
如何解读explain()的结果呢,首先看游标类型, BasicCursor是一个需要警惕的标识,BasicCursor意味着MongoDB必须要做全表扫描,如果记录数量在百万级别,全表扫描肯定是太慢了,因此我们给timestamp字段增加一个索引:
加完索引后,我们再执行一下explain,现在的输出是这样的:
现在,游标类型是基于我刚加的索引的BtreeCursor游标类型,nscanned 从4变成了3,这是因为MongoDB通过索引直接定位到了需要访问的记录,跳过了timestamp不满足条件的记录。
对于一个命中索引的查询,nscanned 是Mongodb在索引范围内扫描的索引的条数。nscannedObjects 是Mongodb为了获得获得最终结果而访问数据的行数(译者注:MongoDB中的索引和数据是通过RecordId二级关联起来的,没有类似于Mysql中聚簇索引的概念,当查询无法被索引完全覆盖时,就需要获得整一行数据)。 上面的explain结果中虽然通过索引就可以覆盖上面的查询,但是explain的结果中,nscannedObjects还是大于等于n,这是为什么呢?其实,这是因为上面的find操作默认返回一行的所有字段,而annonymous和rating字段都没有被索引覆盖,只能读整行数据才能获取。 一般情况下,nscanned,nscannedObjects和n三者的关系满足
范围查询结合等式查询(Equality Plus Range Query)
什么情况下nscanned才会比n大呢? 一般Mongo检索一个不能完全覆盖某个查询的索引时,会发生这种情况,举个例子:
虽然n降为2了(译者注:在更上面的例子中,n是3),nscanned 和nscannedObjects 还是3, Mongo检索timestamp索引的[2,4]区间,这个区间内的三条记录中,有两条非匿名的,还有一条匿名的。但是根据timestamp索引无法过滤掉非匿名的那条记录(timestamp索引没覆盖anonymous字段)。
如何修改索引,才能使得nscanned = nscannedObjects = n呢?我们可以尝试把anonymous字段也加到timestamp索引里,构成一个复合索引。
我们发现,这个explain的结果会更好一些,nscannedObjects从3变成了2。 但是nscanned还是3。 Mongo这次扫描了 [(timestamp:2,anonymous:false), (timestamp:4,anonymous:false)]这个闭区间,其中包括(timestamp:3,anonymous:true)这条不满足查询条件的索引,当Mongo扫描到这条不满足条件的索引时,就跳过去了,不会去读这条索引对应的一整行数据这个操作。 因此,nscannedObjects就少了1,只有2。
这个执行计划能不能进一步优化呢?nscanned能不能降低到2?聪明的读者可能猜到了,如果我们把复合索引的字段顺序颠倒一下,似乎就可以达到这个目标了。我们把索引顺序从 (timestamp,anonymous)变成(anonymous,timestamp)。
和所有数据库一样,字段的顺序在MongoDB的复合索引中至关重要。如果索引以anonymous字段为前缀,Mongo可以直接跳到非匿名评论对应的记录。然后再执行timestamp在[2,4]内的范围扫描。
通过上面的讨论,我给出建索引的启发式规则的规则一:等式过滤先于范围过滤。
让我们考虑下,将anonymous字段放入索引中是否值得。在一个每天有百万条记录和数十亿查询的系统中,降低nscanned可以显著提高吞吐。此外,如果索引中的匿名记录部分很少被用到,它就可以从内存中置换到硬盘上,从而为更热点的索引让出内存空间。 然而从反面来说,一个包含两个字段的索引会比只包含一个字段的索引占用更多的内存。 查询效率的优势可能会被内存消耗的劣势所抵消。 大多数情况下,如果匿名记录占所有记录中很大的比例,那将anonymous字段放入索引中,就是值得的。
MongoDB如何选择一个索引
在先前的例子中,我们先后创建了timestamp索引, timestamp,anonymous索引和 anonymous,timestamp索引。 MongoDB是如何在多个索引中选择最合适的哪个呢?
MongoDB的查询优化器在选择索引时,会有两个阶段,首先,它检查已有的索引中是否有该查询的"最优"索引,其次,如果它发现没有最优索引存在时,它会进行一个试验来判断哪个索引表现的最好。对于模式类似的查询,查询优化器会缓存它的选择,直到有索引被删除或创建,或者有1000条记录被插入或更改。
对于某个查询模式,查询优化器如何评估某个索引是最优的?最优索引必须包含查询的所有过滤字段和排序字段。另外,所有的范围过滤字段或排序字段必须跟在等式过滤字段后面。如果有多个满足条件的索引,Mongo会选择任意一个。在我的例子中, "anonymous,timestamp"索引显然是满足"最优索引"的苛刻条件的。
上面只解释了,针对某个查询模式,怎样的索引是最优索引。可是,如果没有任何索引是最优索引呢,MongoDB会如何处理? 在这种情况下,MongoDB会把所有和查询模式相关的索引都拿出来。然后对这些索引相互比较,看哪个索引能够最快跑完查询,或者能够找出最多的返回结果。
还是先前的查询模式
表上的三个索引都和查询相关,MongoDB把这三个索引都列出来,对这三个索引进行迭代。
第一次迭代,索引索引都返回了
第二次迭代,左边和中间的索引返回了
这条记录不满足查询条件,而最右边,我们的"冠军"索引,返回了
这条记录满足查询条件,此时,右边的索引率先完成查询过程,因此,这个索引在查询优化器的比较中胜出,被缓存起来,直到下一次比较。
简而言之,如果有多个可用的索引,MongoDB选择nscanned最低的那个。
小技巧:explain()中增加{verbose:true}参数,可以得到更详细的查询分析计划。
等式查询,范围查询,和排序
现在,我们对于查询某一段时间内的非匿名记录,有了最优索引。最后,我们要将结果集按照rating字段由高到低进行排序后返回。
上面的查询计划和之前的类似,结果也令人满意,因为nscanned = nscannedObjects = n。不过多出了一个scanAndOrder的字段,值为true,这个字段表示MongoDB把扫描结果汇总在内存里进行排序后再返回。又一件不幸的事情,首先,这个行为会消耗服务端的内存和CPU。 其次,相比于将结果集流式批量返回,MongoDB只是将排序后的结果一次性的塞到网络缓冲区,使得服务器的内存消耗进一步增加。最后,MongoDB的内存排序有32MB的大小限制。 我们现在只有四条记录还好,可是真实场景下是有数百万条记录的。
如何才能避免scanAndOrder? 需要有一个索引,能让MongoDB快速定位到非匿名区,并以rating字段由大到小的顺序扫描该区。
MongoDB会使用这个索引吗?并不会,因为这个索引无法在查询优化器的选择中胜出。因为他的nscanned不是最低的。 查询优化器可不管索引是否对排序有帮助。
不过我们可以使用Hint字段强制Mongo使用该索引
现在,nscanned从2变高到了3,可是scanAndOrder变成false了。 MongoDB逆序扫描anonymous,rating索引,扫描的顺序和排序字段一致。 对于每条记录,获取整行记录来判断timestamp字段是否满足区间范围。
scanAndOrder带来的问题算是解决了,代价是增加了nscanned。nscanned已经无法优化了,可是nscannedObjects还可以再降低。我们把timestamp字段放到索引的最后面,这样索引就可以完全覆盖查询,就不需要读取整行数据了。
结果符合预期,MongoDB扫描anonymous,rating,timestamp索引,扫描顺序和排序顺序一致。nscannedObjects从3降到了2,因为MongoDB可以从索引中判断timestamp是否满足条件,不需要读取整行数据了。
如果timestamp字段会被作为查询的范围过滤字段,那么把它加到索引里就是有价值的。否则只会额外增加索引的大小。
总结
针对一个包含等式过滤,范围过滤和排序字段的查询,建立的复合索引的字段优先级,可以参考下面的规则
将所有等式过滤字段放在复合索引中最靠前的部分。
其次放入排序字段。如果有多个排序字段,升降序和返回结果的升降序保持一致。
最后放入范围过滤字段,区分度低(举个例子,性别的区分度为2,年龄的区分度为100,籍贯的区分度为10000)的放在前面。
如果某些字段不会被查询条件使用到,那就不需要将其加入索引中,这样可以减小索引大小。此外,如果某个字段作为索引,无法过滤掉90%以上的数据,就建议将其从索引中忽略。
最后,如果一张表上有多个索引,有时业务指定Hint可能会比MongoDB使用查询优化器选择的索引更好。
讲完了,对于包含多个字段的复杂查询,建立复合索引是需要技巧的。希望本篇文章能够帮助到你。
译者简介
孔德雨
MongoDB中文社区深圳分会主席。
在存储领域有多年经验,曾负责腾讯云与腾讯内部海量MongoDB集群。对MongoDB源码有较为深入的理解,对源码优化,参数调优等有过丰富的经验。MongoDB committer (Of Resizing Oplogs).
现就职于腾讯互娱 技术运营部,参与MongoDB的集群维护工作。