MongoDB 4.x——合理使用索引(二)
合理使用索引
- 6、查询缓存原理
- 6.1、工作流程
- 6.2、案例
- 6.3、内部原理
- 6.3.1、查询优化器如何选择最优计划
- 6.3.2、如何保证已缓存计划的效率
- 6.3.3、如何清理计划缓存
- 7、强制命中
- 7.1、使用hint方法
- 7.2、使用IndexFilter方法
- 8、索引正交
- 8.1、索引正交
- 8.2、一些限制
- 9、使用MongoDB Compass
- 10、优化原则
- 10.1、在适当的时机使用索引
- 10.2、使用前缀匹配
- 10.3、避免低效的操作符
- 10.4、使用覆盖索引优化
- 10.5、高基数优先原则
- 10.6、控制索引的数量
- 10.7、避免设计过长的数组索引
- 10.8、避免创建重复的索引
- 10.9、删除无用的索引
- 10.10、避免深度分页
- 10.11、避免一次性返回大量结果集
- 10.12、谨防内存排序
- 10.13、避免大量的扫描
- 10.14、为索引预留足够的内存
6、查询缓存原理
MongoDB通过查询计划(query plan)来描述一个查询语句的执行过程。通常情况下,一个查询操作可能对应多个不同的查询计划,例如,对于{x:100,y:100}这个条件,既可以选择{x:1}索引,也可以选择{y:1}索引,甚至是全表扫描计划。但无论是哪一种方式,都会先经过内部的评分机制进行评估,最终选出一个最优的执行方案。
那么,查询计划的评估必然会产生一定的计算开销。如果不进行缓存,数据库就无法应对高吞吐、高性能的场景。
为此,MongoDB提供了PlanCache用以实现查询计划的缓存能力,可以避免在一定条件内对同一个查询模型进行重复性的分析、评估工作。而且,如果对于某个查询已经有了确定性的选择,查询优化器会直接做出选择,此时缓存并不会启用。例如,对于已经存在{a:1}索引的集合,在执行find({a:100})这样的查询时很可能并不会产生查询计划缓存。
6.1、工作流程
查询计划缓存的具体工作流程,如图所示。
说明:
- 查询开始执行,判断PlanCache中是否有对应的缓存。
- 如果没有缓存,则进入计划生成阶段,执行下列步骤。
- 分析查询语句与全部索引,产生候选计划。
- 评估查询计划,包括对计划的并发执行、采样。
- 选择最优的计划。
- 将计划存入缓存。
- 如果存在缓存,则触发replanning机制,评估查询性能,此时有以下两种结果。
- 查询性能不达标,淘汰缓存重新生成计划。
- 查询性能达标,采纳计划。
- 执行最终计划,返回结果。
查询模型
查询模型(query shape)是对于当前查询场景的唯一性结构描述。查询优化器会首先将查询请求解析为某个查询模型,根据这个模型再进行计划缓存的查询。查询模型的组成包括以下3个部分。
query:描述查询条件的结构,该结构由条件的字段、操作符(谓词)以及条件的嵌套关系组成,并不包含查询条件的具体值。projection:描述即将返回哪些字段。sort:描述排序的规则。
6.2、案例
为了呈现查询计划缓存,我们为集合创建了两个索引:
>db.foo.createIndex({x:1})>db.foo.createIndex({y:1})接着,执行如下的查询:
>db.foo.find({x:{$lt:10},y:{$gt:54}},{x:1,z:1}).sort({z:-1})不难判断,无论是使用索引{x:1},还是{y:1},都无法完美地匹配这次查询。因此,MongoDB会为当前的查询评选出一个最优的计划并写入PlanCache。记住,没有确定性选择是产生计划缓存的关键。
对于update操作中的查询,同样可能触发查询计划的评估,MongoDB对此会一视同仁。如果你执行的是explain命令,那么缓存则一定不会被使用。使用PlanQuery的listQueryShapes方法可以展示当前已经缓存的查询模型,代码如下:
>db.foo.getPlanCache().list()
返回的结果是一个数组,包含了刚刚的查询模型,而且,query、sort、projection这几个字段也正如我们预期的一样。queryHash是由查询模型计算得出的稳定哈希值,该字段也会在explain的结果中出现。
需要注意的是,尽管query字段呈现了查询条件中具体的值,但在计算模型结构时这些条件值都会被忽略。也就是说,query={x:{$lt:10},y:{$gt:55}}和query={x:{$lt:100},y:{$gt:0}}在结构上仍然是相同的,最终的queryHash值也是一致的。
使用PlanQuery的getPlansByQuery方法可以查看针对某个查询模型所产生的计划缓存,代码如下:
返回结果如下:
上述结果中的plans为一个数组,其中包含了当前查询可能采取的多个计划。reason.score是指得分,得分越高的计划被采纳的概率越大,reason.stats则是对这个计划进行评估时的一些执行过程信息,和explain命令的输出基本一致。
6.3、内部原理
6.3.1、查询优化器如何选择最优计划
最初,查询优化器需要根据现有的上下文产生一些候选计划。如果同时存在多个候选计划,那么需要根据一种评分机制从这些计划中选出一个最优计划,这就涉及计划的评优(evaluate)过程,如图所示。
具体的机制如下。
首先,让所有计划都同时执行一定量的扫描任务,扫描任务在满足以下条件时停止:
- 扫描次数达到numWorks次,numWorks=Math.max(10000,0.3×collection.count)。
- 返回结果达到numResults个,numResults=Math.min(101,query.getN(),query.getLimit()),其中query.getN()来自getMore命令,query.getLimit()则只有限制了limit条件才会出现。这两个参数只有存在时才会参与比较,否则numResults默认就是101。
然后,为每个计划的执行情况打分,计算分数的因子来自下面几点:
- isEOF是否为true,如果出现isEOE则说明扫描的指针已经到达了末尾。如果计划提前结束了,则扫描会获得最大的机会。
- advance/workUnits(%),如果返回的结果数占扫描数的比例越大,则代表扫描效率越高。
是否存在以下低效率的阶段?
PROJECTION+FETCH(非覆盖索引查询)、SORT(内存排序)、AND_HASH|STAGE_AND_SORTED(索引正交阶段)。
任意一种低效阶段的存在都会导致候选计划被扣分。
最后,根据所得分数进行排序,得分最高的计划被评选为最优计划并写入缓存。
6.3.2、如何保证已缓存计划的效率
事实上,对于已经缓存的计划,MongoDB仍会采用一种replaining的机制来保证其高效性。在返回缓存的计划之前,首先对该计划进行扫描采样,这次的采样数相比之前的numWorks会扩大10倍。
- 扫描过程中如果返回了numResults个条目,或者到达了EOF,则达到通过(pass)条件,此时仍然选用此计划。
- 如果超过采样数之后仍未达到通过条件,则转为失败(fail)状态,触发replain过程,此时将重新评选计划。
- 如果扫描过程中出错,则同样会变成失败状态,此时也会触发replain过程。
6.3.3、如何清理计划缓存
MongoDB在发生如下一些情况时,可实现计划缓存的清除。
- 执行了PlanCache.clear方法。
- 创建、删除索引或者执行了集合的drop操作。
- 将MongoDB进程重启。
查询缓存功能属于MongoDB的内部机制,官方文档并未对此提供特别详细且公开的描述。
7、强制命中
7.1、使用hint方法
在某些极少情况下,某些查询产生的最终计划可能并不是你想要的。事实上,想实现一个完美的查询计划是非常困难的,MongoDB在查询优化器上做了很多合理性方面的努力,也提供了一些干预的手段。
hint方法实现了对查询计划机制的干预,在查询计划中使用hint语句可以让MongoDB忽略查询优化器的结果,从而直接使用指定的索引。
比如下面的做法:
>db.test.find().hint({name:}).explain()该语句将被强制使用{name:1}这个索引。如果没有指定,则会使用全表扫描的方式。hint方法的参数可以传入索引的定义对象,也可以是索引的名称,代码如下:
>db.test.find().hint("name_1")如果所传入的索引并不存在,则将会得到错误提示。
7.2、使用IndexFilter方法
IndexFilter方法也可以于预查询索引,代码如下:
>db.runCommand({planCacheSetFilter:"test",query:{a:3,b:4},indexes:[{b:1}]})执行planCacheSetFilter命令会在test集合中增加一个IndexFilter对象,该对象将会自动关联到同时包含a字段与b字段的等值查询,并引导查询优化器使用{b:1}这个索引。执行planCacheListFilters命令可以查看它的定义,代码如下:
>db.runCommand({planCacheListFilters:"test"}){"filters":[{"query":{"a":3,"b":4},"sort":{},"projection":{},"indexes":[{"b":1}]}],"ok":1}其中,查询模型(query shape)会忽略查询条件中具体的数值,因此下面的查询是适用的:
db.test.find({a:99,b:-1})db.test.find({a:3,b:10})对于关联了IndexFilter的查询,在explain结果中的indexFilterSet字段为true,代码如下:
在上述例子中,如果使用了非等值查询(如ge、lt),或者排序(sort)、投射(projection)发生了变化,就会导致匹配失效。
IndexFilter的优先级高于hint方法,如果查询优化器发现了关联的IndexFilter,则一定会忽略hint语句。但是,IndexFilter并不能保证查询优化器最终一定会选择对应的索引,事实上优化器会将这些索引与全表扫描方式一并进行评估,再抉择出最终的结果。在最坏的情况下,如果我们指定了不存在的索引,就会导致全表扫描。
IndexFilter是内存态的,如果重启了MongoDB,则会自动失效。另外,也可以使用planCacheClearFilters进行擦除,代码如下:
>db.runCommand({planCacheClearFilters:"test"})注意,MongoDB的查询优化器已经足够强大,无论是hint还是IndexFilter,都会改变默认的查询优化行为。除非迫不得已,否则应该尽量少用。
8、索引正交
8.1、索引正交
在符合“前缀匹配”的原则下,组合索引可以满足一些不同的查询模式。对于如下的索引:
{x:1,y:1}可以很好地满足以下查询:
db.test.find({x:100})db.test.find({x:100,y:9})db.test.find({x:{$gt:10},y:{$lt:3}})但是,对于以下的查询却无能为力:
db.test.find({y:5})db.test.find().sort({y:1})索引正交是一种特殊的检索方式,它会利用多个索引来满足当前的查询。如果将上面的组合索引拆为如下两个:
{x:1}{y:1}此时,利用索引正交的特点就可以同时满足上述这些查询。另一个问题是,如果查询中存在排序会是什么情形呢?
假定有以下索引:
{x:1,y:1}{z:1}{y:1}对于下面的查询仍然不适用:
db.test.find({z:3}).sort({y:1})但是,如果稍微做一下调整,就可以使用索引正交:
db.test.find({z:3,x:10}).sort({y:1})这里的关键点在于,需要先存在一个索引能同时覆盖查询和排序字段,上面的这个查询会先以{x:1,y:1}为基础,结合索引{z:1}进行正交计算。
如果查询使用了索引正交,则其explain结果会表示为"AND_SORT" 或者"AND_HASH"类型。
8.2、一些限制
- 在索引正交的实现上,MongoDB规定最多只能使用两个索引。
- 在大多数情况下,查询优化器很可能并不会选择索引正交作为最优计划。这其中的原因来自一些可变的因素。例如,假设大部分的文档数据都可以通过内存获取,那么查询优化器很容易会认为,比起索引正交来说,直接在内存中获取文档进行过滤计算的开销要小得多。
9、使用MongoDB Compass
对于查询分析优化,除了使用explain方法,还可以使用一些GUI工具。
MongoDB Compass是MongoDB官方提供的GUI工具,可以用于数据管理、查询优化等,如图所示。
MongoDB Compass分为社区版、企业版等多个不同分支,不同的版本具备的功能也有些差别。总的来说,MongoDB Compass主要提供了以下功能:
- 数据管理,如一般的CRUD操作。
- 表结构分析,可查看各字段的取值分布,需要企业版才支持。
- 实时的服务器监控,需要企业版才支持。
- 帮助创建管道(pipeline)化的聚合操作。
- 使explain结果可视化,辅助查询分析优化。
使用方法
- 下载安装Compass社区版本。
- 打开Compass,选择集合并切换到Explain Plan视图。
- 将输入框中的OPTIONS展开,在FILTER处输入{x:{$in:[1,2,3,4]}},在SORT处输入{y:1}。
- 单击“EXPLAIN”按钮,可看到详细的图形化的执行计划,如图所示。
10、优化原则
10.1、在适当的时机使用索引
记住一点,索引的作用在于提高查询效率。当返回结果集只是全部文档的一小部分时,增加合适的索引才能获得不错的性价比。当然,你可以自己设定结果集的比例,例如15%~20%。
10.2、使用前缀匹配
对于模糊匹配的查询,尽量使用前缀匹配。比如搜索名称时使用/^keyword/要明显优于/keyword/。后者很可能需要对整个索引树进行遍历。
10.3、避免低效的操作符
nin、ne、not不利于索引命中。相反,这些操作符会导致大范围扫描,建议避免这些操作。除此之外,where、exist也无法利用索引优化,同样需要避免。
10.4、使用覆盖索引优化
建议只返回需要的字段,同时,利用覆盖索引来提升性能。
10.5、高基数优先原则
基数(cardinality)是指某个字段拥有的唯一值的数量。以用户信息为例,基数高的字段为用户身份证号、手机号等,基数低的字段为性别等。一个字段的基数越高,越有利于快速检索。这是因为高基数的索引能迅速将检索范围圈定到一个比较小的结果集上,如果基数太小则会产生许多排除性的工作。比如想要查询“1990年10月13日出生的女性”,如果使用性别(gender)这个索引进行查找,将只能将结果集范围缩小50%左右。
一般来说,唯一索引的数量是最高的(基数等同于文档的数量),建议优先为基数高的字段建立索引,另外,组合索引上也应尽量将基数高的字段放在前面。
10.6、控制索引的数量
索引并不是越多越好,相反,过多的索引会带来一些问题:
- 多余索引会抢占内存空间,导致常用的索引被挤兑,影响查询性能。
- 写操作性能降低,一次文档写入会触发多个索引的更新,增加了系统I/O开销。需要谨慎地对整个文档进行保存。如果使用了SpringDataMongo这样的ORM框架,开发者往往会使用save命令保存整个文档,此时将会触发集合中所有索引的更新。如果能在update操作中明确指定需要更新的字段,那么影响则会减少一些。也就是说,只有当索引中的字段被update指定时才会触发更新。
- 不利于查询计划优化,太多的索引会增加查询计划评估的难度,在极端情况下可能会产生“索引跳变”。
10.7、避免设计过长的数组索引
数组索引是多值的,在存储时需要使用更多的空间。如果索引的数组长度特别长,或者数组的增长不受控制,则可能导致索引空间急剧膨胀。
10.8、避免创建重复的索引
组合索引具有“前缀覆盖”的特点,应避免存在已经被覆盖的索引。例如:
>db.test.createIndex({x:1,y:1,z:1})>db.test.createIndex({x:1,y:1})>db.test.createIndex({x:1}){x:1,y:1,z:1}同时具备{x:1}索引和{x:1,y:1}索引的功能,因此后面两个索引可以去掉。
如果使用了哈希分片,则可能会获得该分片键上的一个哈希索引。如果应用上只需要对该字段进行单个查询,那么该索引已经完全可以满足。不要对分片键创建重复的索引。
10.9、删除无用的索引
对环境中的数据库例行检查,及时删除不使用的索引。
10.10、避免深度分页
避免使用skip命令实现超大集合的分页,该命令会消耗CPU资源,当跳转到深度页面时响应会非常缓慢。
10.11、避免一次性返回大量结果集
对于较大的集合,应避免直接使用find命令进行全表查询;当返回的结果集很大时,使用limit方法限制合理的返回条目数,比如一次批量查询不超过1000条。这个约束还要考虑结合当前系统所能承受的性能压力,一般在高并发、高实时性的场景下,这个值通常要设置得更小。
无论何时,都应该对结果集的大小保持警惕。如果对此不加控制,则很可能导致应用程序出现OOM错误。
10.12、谨防内存排序
对于存在排序需求的查询,在缺失索引,或者组合索引的排序方向不匹配的情况下,会产生内存排序。内存排序需要更多的临时内存,影响资源占用。此外,MongoDB对于排序的内存使用有32MB的限制,超过该阈值会产生失败。
10.13、避免大量的扫描
避免全表扫描或大范围的记录扫描,通过explain结果中的totalKeysExamined、totalDocsExamined可获知当前的扫描次数。避免涉及大范围扫描的计算操作。
10.14、为索引预留足够的内存
为了保证性能,最好保证索引能被装入内存中。当内存不足时,索引需要从磁盘中加载,这会导致昂贵的I/O操作。一种例外的情况是递增式的索引,即索引的值呈现出递增的特点,而且应用上也只关心最近产生的一部分数据。例如,系统日志可以按照日期建立索引,而通常我们也只关心最近几天产生的日志,此时内存中则可以仅保留“被关心”的部分数据。
