数据库索引类似于图书索引。有了索引便不需要浏览整本书,而是可以采取一种快捷方式,只查看一个有内容引用的有序列表。这使得 MongoDB 的查找速度提高了好几个数量级。其中我们理解使用索引进行查询的效果,可以通过以下执行计划分析
>db.test.find({“username”:“101”}).explain(“executionStatus”)
其中:
“totalDocsExamined” 是 MongoDB 在试图满足查询时查看的文档总数
“nReturned” 字段显示返回的结果数
“executionTimeMillis” 字段会显示执行查询所用的毫秒数

1、创建索引

>db.test.createIndex({“username”:1})
创建索引只需几秒的时间,除非集合特别大。如果 createIndex调用在几秒后没有返回,则可以运行 db.currentOp()(在另一个 shell 中)或检查 mongod 的日志以查看索引创建的进度。
MongoDB 索引的工作原理与典型的关系数据库索引几乎相同。

2、复合索引简介

索引的目的是使查询尽可能高效。对于许多查询模式来说,在两个或更多的键上创建索引是必要的。索引会将其所有值按顺序保存,因此按照索引键对文档进行排序的速度要快得多。然而,索引只有在作为排序的前缀时才有助于排序。
1、“username” 上的索引对下面这种排序就没什么帮助:
>db.test.createIndex({“age”:1,“username”:1})
2、如果查询中有多个排序方向或者查询条件中有多个键,那么这个索引会非常有用。复合索引是创建在多个字段上的索引。
如果有集合如下:
{“username”:“u0”,“age”:11}
{“username”:“u1”,“age”:13}
{“username”:“u2”,“age”:12}
{“username”:“u3”,“age”:15}
如果使用 {“age” : 1, “username” : 1} 在这个集合中创建索引,那么这个索引会是下面这个样子:
[11,“u0”]->234324324
[12,“u2”]->234324323
[13,“u1”]->234324322
每个索引项都包含年龄和用户名,并指向一个记录标识符.存储引擎在内部使用记录标识符来定位文档数据。注意,“age” 字段严格按升序排列,在每个年龄中,用户名也按升序排列
>db.test.find({“age”:21}).sort({“username”:-1})
这是等值查询,用于查找单个值。可能有多个文档具有该值。多亏了索引中的第二个字段,结果已经按照正确的顺序排序:MongoDB 可以从 {“age” : 21} 的最后一个匹配项开始,然后依次遍历索引。
这种类型的查询非常高效:MongoDB 可以直接跳转到正确的年龄,并且不需要对结果进行排序,因为只要遍历索引就会以正确的顺序返回数据。
这种类型的查询非常高效:MongoDB 可以直接跳转到正确的年龄,并且不需要对结果进行排序,因为只要遍历索引就会以正确的顺序返回数据。
3、范围查询导致索引失效
>db.test.find({“age”:{“$gt”:21,“$lt”:30}}).sort(“username”:1)
与上一个方式类似,这是多值查询,但这次需要对结果进行排序。和之前一样,MongoDB 会使用索引来匹配查询条件。不过,索引不会按照顺序返回用户名,而查询要求按用户名对结果进行排序。这意味着 MongoDB 需要在返回结果之前在内存中对结果进行排序,而不是简单地遍历已经按需排好序的索引。因此,这种类型的查询通常效率较低。
4、MongoDB查询速度取决于有多少结果与查询条件相匹配:如果结果集中只是几个文档,那么 MongoDB 将不会耗费多少时间进行排序;如果结果比较多,那么速度就会很慢或者根本不能工作。如果结果超过了 32MB,MongoDB 就会报错,拒绝对这么多数据进行排序。
要避免这个问题,则必须创建一个支持此排序操作的索引,或者将 limit 与 sort 结合使用以使结果低于 32MB。
在上一个示例中,可以使用的另一个索引是按相反顺序排列的相同键:{“username” : 1, “age” : 1}。MongoDB 会遍历所有索引项,但会按照希望的顺序返回。然后它会使用索引的 "age"部分来挑选匹配的文档

3、MongoDB如何选择索引

假设有一个查询进入,5 个索引中的 3 个被标识为该查询的候选索引。然后,MongoDB 会创建 3 个查询计划,分别为每个索引创建 1 个,并在 3 个并行线程中运行此查询,每个线程使用不同的索引。这样做的目的是看哪一个能够最快地返回结果。形象化地说,可以将其看作一场竞赛,如图 5-1 所示。这里的设计是,到达目标状态的第一个查询计划成为赢家。但更重要的是,以后会选择它作为索引,用于具有相同形状的其他查询。每个计划会相互竞争一段时间(称为试用期),之后每一次竞赛的结果都会用来在总体上计算出一个获胜的计划。
在这里插入图片描述
要赢得竞赛,查询线程必须首先返回所有查询结果或按排序顺序返回一些结果。考虑到在内存中执行排序的开销,其中排序的部分非常重要。
让多个查询计划相互竞争的真正价值在于,对于具有相同形状的后续查询,MongoDB 会知道要选择哪个索引。服务器端维护了查询计划的缓存。一个获胜的计划存储在缓存中,以备在将来用于进行该形状的查询。随着时间的推移以及集合和索引的变化,查询计划可能会从缓存中被淘汰。而 MongoDB 会再次进行尝试,以找到最适合当前集合和索引集的查询计划。其他会导致计划从缓存中被淘汰的事件有:重建特定的索引、添加或删除索引,或者显式清除计划缓存。此外,mongod 进程的重启也会导致查询计划缓存丢失。

4、使用复合索引

1、概括来说,在设计复合索引时:
等值过滤的键应该在最前面;
用于排序的键应该在多值字段之前;
多值过滤的键应该在最后面。
在设计复合索引时遵循这些准则,然后在实际的工作负载下进行测试,这样就可以确定索引所支持的查询模式都有哪些了。
2、键的方向
只有基于多个查询条件进行排序时,索引方向才是重要的,并且需要一致。如果只是基于一个键进行排序,那么 MongoDB 可以简单地从相反方向读取索引。
3、覆盖查询
索引都是用来查找正确的文档,然后跟随指针去获取实际的文档的。然而,如果查询只需要查找索引中包含的字段,那就没有必要去获取实际的文档了。当一个索引包含用户请求的所有字段时,这个索引就覆盖了本次查询。只要切实可行,就应该优先使用覆盖查询,而不是去获取实际的文档,这样可以使工作集大幅减小。

为了确保查询只使用索引就可以完成,应该使用投射(只返回查询中指定的字段)来避免返回 “_id” 字段(除非它是索引的一部分)。可能还需要对不做查询的字段进行索引,因此在编写的时候就要在所需的查询速度和这种方式带来的开销之间做好权衡。

如果对一个被覆盖的查询运行 explain,那么结果中会有一个并不处于 “FETCH” 阶段之下的 “IXSCAN” 阶段,并且在"executionStats" 中,“totalDocsExamined” 的值是 0。
4、隐式索引
复合索引具有“双重功能”,而且针对不同的查询可以充当不同的索引。如果有一个在 {“age” : 1, “username” : 1} 上的索引,那么 “age” 字段的排序方式就和在 {“age” : 1} 上的索引相同。因此,这个复合索引可以当作 {“age” : 1} 索引一样使用。

这可以推广到所需的任意多个键:如果有一个拥有 N 个键的索引,那么你同时“免费”得到了所有这些键的前缀所组成的索引。如果有一个类似 {“a”: 1, “b”: 1, “c”: 1 …, “z”: 1} 这样的索引,那么实际上也等于有了 {“a”: 1}、{“a”: 1, “b” : 1}、{“a”: 1,“b”: 1, “c”: 1} 等一系列索引。

注意,这一点并不适用于这些键的任意子集:使用 {“b”: 1} 或者{“a”: 1, “c”: 1} 作为索引的查询是不会被优化的。只有能够使用索引前缀的查询才能从中受益。

5、$运算符如何使用索引

1、低效的运算符
取反的效率是比较低的。“$ne” 查询可以使用索引,但不是很有效。由于必须查看所有索引项,而不只是 “$ne” 指定的索引项,因此基本上必须扫描整个索引。

“$not” 有时能够使用索引,但通常它并不知道要如何使用。它可以对基本的范围(比如将 {“key” : {“$lt” : 7}} 变为 {“key” :{“$gte” : 7}})和正则表达式进行反转。然而,大多数使用"$not" 的查询会退化为全表扫描 1。而 “$nin” 总是使用全表扫描。

如果需要快速执行这些类型的查询,可以尝试看看是否能找到另一个使用索引的语句,将其添加到查询中,这样就可以在MongoDB 进行无索引匹配时先将结果集的文档数量减少到一个比较小的数量。

2、范围
复合索引使 MongoDB 能够高效地执行具有多个子句的查询。当设计基于多个字段的索引时,应该将用于精确匹配的字段(如 “x” : 1)放在最前面,将用于范围匹配的字段(如 “y”:{“$gt” : 3, “$lt” : 5})放在最后面。这样可以使查询先用第一个索引键进行精确匹配,然后再用第二个索引范围在这个结果集内部进行搜索。

3、OR查询
MongoDB 在一次查询中仅能使用一个索引。也就是说,如果在 {“x” : 1} 上有一个索引,在 {“y” : 1} 上有另一个索引,然后在 {“x” : 123, “y” : 456} 上进行查询时,MongoDB 会使用其中一个索引,而不是两个一起使用。唯一的例外是 “ o r " , 每 个 " or",每个 " or""or” 子句都可以使用一个索引,因为实际上 “$or” 是执行两次查询然后将结果集合并。

但是执行两次查询再将结果合并的效率不如单次查询高,因此应该尽可能使用 “$in” 而不是 “$or”。

如果不得不使用 “$or”,则要记住 MongoDB 需要检查两次查询的结果集并从中移除重复的文档(那些被多个 “$or” 子句匹配到的文档)。

除非使用排序,否则在用 “$in” 查询时无法控制返回文档的顺序。例如,{“x” : {“$in” : [1, 2, 3]}} 与 {“x” : {“$in” : [3, 2, 1]}}返回的文档顺序是相同的。

4、索引对象和数组
MongoDB 允许深入文档内部,对内嵌字段和数组创建索引。内嵌对象和数组字段可以和顶级字段一起在复合索引中使用。
创建内嵌文档索引:
>db.test.createIndex({“user.address”:1})
注:对整个子文档创建索引只会提高针对整个子文档进行查询的速度。

对数组创建索引:(phone为数组)
>db.test.createIndex({“user.phones”:1})
注:对数组创建索引实际上就是对数组的每一个元素创建一个索引项。如果用户有2个电话,那么他就有2个索引项。如果数组元素多,这使得数组索引的代价比单值索引要高:对于单次的插入、更新或删除,每一个数组项可能都需要更新(也许会有上千个索引项)。

与内嵌文档不同的是,内嵌文档可以对整个文档建立索引,而对数组创建索引就是对数组中的每个元素创建索引,而不是对数组本身创建索引。

所以数组元素上的索引并不包含任何位置信息:要查找特定位置的数组元素(如 “phones.4”),查询是无法使用索引的。对某个特定的数组项进行索引是可以的,比如:
>db.test.createIndex({“user.10.phones”:1})
这个索引只有在精确匹配第 11 个数组元素的时候才会起作用(数组索引从 0 开始)

5、多键索引的影响
如果一个文档有被索引的数组字段,则该索引会立即被标记为多键索引。可以从 explain 的输出中看到一个索引是否为多键索引:如果使用了多键索引,则 “isMultikey” 字段的值会是true。一旦一个索引被标记为多键,就再也无法变成非多键索引了,即使在该字段中包含数组的所有文档都被删除了也一样。恢复非多键索引的唯一方法是删除并重新创建这个索引。

多键索引可能会比非多键索引慢一些。可能会有许多索引项指向同一个文档,因此 MongoDB 在返回结果之前可能需要做一些删除重复数据的操作。

6、索引基数
基数(cardinality)是指集合中某个字段有多少个不同的值,通常来说,一个字段的基数越高,这个字段上的索引就越有用。这是因为这样的索引能够迅速将搜索范围缩小到一个比较小的结果集。对于基数比较低的字段,索引通常无法排除大量可能的匹配项。
通常来说,应该在基数比较高的键上创建索引,或者至少应该把基数比较高的键放在复合索引的前面(在低基数的键之前)。

6、explain输出

explain 可以为查询提供大量的信息。对于慢查询来说,它是最重要的诊断工具之一。通过查看一个查询的 explain输出,可以了解查询都使用了哪些索引以及是如何使用的。对于任何查询,都可以在末尾添加一个 explain 调用(就像添加sort 或 limit 一样,但是 explain 必须是最后一个调用)。
重要字段的详细介绍:
“isMultiKey” : false
本次查询是否使用了多键索引
“nReturned” : 8449
本次查询返回的文档数量。
“totalDocsExamined” : 8449
MongoDB 按照索引指针在磁盘上查找实际文档的次数。如果查询中包含的查询条件不是索引的一部分,或者请求的字段没有包含在索引中,MongoDB 就必须查找每个索引项所指向的文档。
“totalKeysExamined” : 8449
如果使用了索引,那么这个数字就是查找过的索引条目数量。如果本次查询是一次全表扫描,那么这个数字就表示检查过的文档数量。
“stage” : “IXSCAN”
MongoDB 是否可以使用索引完成本次查询。如果不可以,那么会使用 “COLLSCAN” 表示必须执行集合扫描来完成查询。
“needYield” : 0
为了让写请求顺利进行,本次查询所让步(暂停)的次数。如果有写操作在等待执行,那么查询将定期释放它们的锁以允许写操作执行。在本次查询中,由于并没有写操作在等待,因此查询永远不会进行让步。
“executionTimeMillis” : 15
数据库执行本次查询所花费的毫秒数。这个数字越小越好。“executionTimeMillis” 报告了查询的执行速度,即从服务器接收请求到发出响应的时间。然而,这可能并不总是你希望看到的值。如果 MongoDB 尝试了多个查询计划,那么"executionTimeMillis" 反映的是所有查询计划花费的总运行时间,而不是所选的最优查询计划所花费的时间。
“indexBounds” : {…}
这描述了索引是如何被使用的,并给出了索引的遍历范围。

如果发现 MongoDB 正在使用的索引与自己希望的不一致,则可以用 hint 强制其使用特定的索引。

注:如果查询没有使用你希望其使用的索引,而你使用了 hint强制进行更改,那么应该在部署之前对这个查询执行 explain。如果强制 MongoDB 在它不知道如何使用索引的查询上使用索引,则可能会导致查询效率比不使用索引时还要低。

7、何时不使用索引

索引在提取较小的子数据集时是最高效的,而有些查询在不使用索引时会更快。结果集在原集合中所占的百分比越大,索引就会越低效,因为使用索引需要进行两次查找:一次是查找索引项,一次是根据索引的指针去查找其指向的文档。而全表扫描只需进行一次查找:查找文档。在最坏的情况下(返回集合内的所有文档),使用索引进行查找的次数会是全表扫描的两倍,通常会明显比全表扫描慢。

不幸的是,关于索引什么时候有用以及什么时候有害,并没有一个固定的规则,因为这实际上取决于数据、索引、文档和平均结果集的大小。根据经验,如果查询返回集合中 30% 或更少的文档,则索引通常可以加快速度。然而,这个数字会在 2%~ 60% 变动。下图总结了影响索引效率的属性:
在这里插入图片描述
比如现在有一个收集统计信息的分析系统。应用程序要根据给定的账户去系统中查询所有的文档,以根据从一小时之前到最开始时间的所有数据来生成一个图表:
>db.test.find({“createAt”:{“$lt”:oneHourAgo}})
在 “createdAt” 上创建索引以加快查询速度。

在 “created_at” 上创建索引以加快查询速度。

最初运行时,结果集很小而且可以立即返回。但是几个星期之后,数据开始多起来,而一个月之后,这个查询运行起来就会花费很长时间了。

对于大多数应用程序来说,这很可能就是那个“错误”的查询:你真的需要在查询中返回数据集中的大部分内容吗?大部分应用程序不需要,尤其是那些拥有庞大数据集的应用程序。然而,也有一些合理的情况可能需要获取大部分或者全部的数据。例如,可能需要将这些数据导出到报表系统或在一个批处理任务中使用。在这些情况下,应该尽可能快地返回数据集中的这些内容。

8、索引类型

1、唯一索引
唯一索引确保每个值最多只会在索引中出现一次。如果想保证不同文档的 “firstname” 键拥有不同的值,则可以使用partialFilterExpression 仅为那些有 name 字段的文档创建唯一索引
>db.test.createIndex({“name”:1},{“unique”:true,“partialFilterExpression”:{“name”:{“$exists”:true}}})
partialFilterExpression 仅为那些有 name存在字段的文档创建唯一索引
注:如果一个键不存在,那么索引会将其作为 null 存储。这意味着如果对某个键创建了唯一索引并试图插入多个缺少该索引键的文档,那么会因为集合中已经存在了一个该索引键值为null 的文档而导致插入失败。
在某些情况下,一个值可能不会被索引。索引桶(indexbucket)的大小是有限制的,如果某个索引项超过了它的限制,这个索引项就不会被包含在索引中。这可能会造成一些困惑,因为这会使一个文档对使用此索引的查询“不可见”。在MongoDB 4.2 之前,索引中包含的字段必须小于 1024 字节。在 MongoDB 4.2 及以后的版本中,这个限制被去掉了。如果一个文档的字段由于大小限制不能被索引,那么 MongoDB 就不会返回任何类型的错误或警告。这意味着大小超过 8KB 的键不会受到唯一索引的约束:比如,你可以插入多个相同的 8KB字符串。

2、复合唯一索引
还可以创建复合唯一索引。在复合唯一索引中,单个键可以具有相同的值,但是索引项中所有键值的组合最多只能在索引中出现一次。

当尝试在现有集合中创建唯一索引时,如果存在任何重复值,则会导致创建失败,通常,需要对数据进行处理(可以使用聚合框架),并找出重复的数据,然后想办法解决。

3、部分索引
唯一索引会将 null 作为值,因此无法在多个文档缺少键的情况下使用唯一索引。然而,在很多情况下,你可能希望仅在键存在时才强制执行唯一索引。如果一个字段可能存在也可能不存在,但当其存在时必须是唯一的,那么可以将 “unique” 选项与 “partial” 选项组合在一起使用。

要创建部分索引,需要包含 “partialFilterExpression” 选项。部分索引提供了稀疏索引功能的超集,使用一个文档来表示希望在其上创建索引的过滤器表达式。如果有一个电子邮件地址字段是可选的,但是如果提供了这个字段,那么它的值就必须是唯一的。

部分索引不必是唯一的。要创建非唯一的部分索引,只需去掉"unique" 选项即可。

比如有如下文档
{“id”:0}
{“id”:1,“x”:2}
{“id”:1,“x”:3}
{“id”:1,“x”:4}
>db.test.find({“x”:{“$ne”:2}}) ,此时会返回:
{“id”:0}
{“id”:1,“x”:3}
{“id”:1,“x”:4}
如果在 “x” 上创建一个部分索引,那么 “_id” : 0 的文档将不会被包含在索引中。执行查询会有如下结果:
{“id”:1,“x”:3}
{“id”:1,“x”:4}

9、索引管理

可以使用 createIndex 函数创建新的索引。每个集合只需要创建一次索引。如果再次尝试创建相同的索引,则不会执行任何操作。

关于数据库索引的所有信息都存储在 system.indexes 集合中。这是一个保留集合,因此不能修改其中的文档或从中删除文档。只能通过 createIndex、createIndexes 和 dropIndexes 数据库命令来对它进行操作。

创建一个索引后,可以在 system.indexes 中看到它的元信息。也可以执行 db.collectionName.getIndexes() 来查看给定集合中所有索引的信息
1、标识索引
集合中的每个索引都有一个可用于标识该索引的名称,服务器端用这个名称来对其进行删除或者操作。索引名称的默认形式是 keyname1dir1_keyname2_dir2…_keynameN_dirN,其中keynameX 是索引的键,dirX 是索引的方向(1 或 -1)。如果索引包含两个以上的键,那么这种方式就会很麻烦,因此可以将自己的名称指定为 createIndex 的选项之一:
>db.test.createIndex({“a”:1,“b”:-1},{“name”:“ab_index”})
索引名称是有字符数限制的,因此在创建复杂的索引时可能需要自定义名称。调用 getLastError 就可以知道索引是否创建成功,或者为什么创建失败。
2、修改索引
修改索引的原理是:先删除索引,然后仔重建索引
创建新的索引既费时又耗费资源。在 4.2 版本之前,MongoDB会尽可能快地创建索引,阻塞数据库上的所有读写操作,直到索引创建完成。如果希望数据库对读写保持一定的响应,那么可以在创建索引时使用 “background” 选项。这会迫使索引创建不时地让步于其他操作,但仍可能对应用程序的性能造成严重影响。后台创建索引也会比前台创建索引慢得多。MongoDB 4.2 引入了一种新的方式,即混合索引创建。它只在索引创建的开始和结束时持有排他锁。创建过程的其余部分会交错地让步于读写操作。在 MongoDB 4.2中,这种方式同时替换了前台和后台类型的索引创建。

如果可以选择,在现有文档中创建索引要比先创建索引然后插入所有文档中稍微快一些。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐