MongoDB指南---11、使用复合索引、$操作符如何使用索引、索引对象和数组、索引基数

上一篇文章:MongoDB指南---10、索引、复合索引 简介
下一篇文章:MongoDB指南---12、使用explain()和hint()、何时不应该使用索引

1、使用复合索引

在多个键上建立的索引就是复合索引,在上面的小节中,已经使用过复合索引。复合索引比单键索引要复杂一些,但是也更强大。本节会更深入地介绍复合索引。

1. 选择键的方向

到目前为止,我们的所有索引都是升序的(或者是从最小到最大)。但是,如果需要在两个(或者更多)查询条件上进行排序,可能需要让索引键的方向不同。例如,假设我们要根据年龄从小到大,用户名从Z到A对上面的集合进行排序。对于这个问题,之前的索引变得不再高效:每一个年龄分组内都是按照"username"升序排列的,是A到Z,不是Z到A。对于按"age"升序排列按"username"降序排列这样的需求来说,用上面的索引得到的数据的顺序没什么用。
为了在不同方向上优化这个复合排序,需要使用与方向相匹配的索引。在这个例子中,可以使用{"age" : 1, "username" : -1},它会以下面的方式组织数据:

[21, "user999977"] -> 0xe57bf737
[21, "user999954"] -> 0x8bffa512
[21, "user999902"] -> 0x9e1447d1
[21, "user999900"] -> 0x3a6a8426
[21, "user999874"] -> 0xc353ee06
...
[30, "user999936"] -> 0x7f39a81a
[30, "user999850"] -> 0xa979e136
[30, "user999775"] -> 0x5de6b77a
...
[30, "user100324"] -> 0xe14f8e4d
[30, "user100140"] -> 0x0f34d446
[30, "user100050"] -> 0x223c35b1

年龄按照从年轻到年长顺序排列,在每一个年龄分组中,用户名是从Z到A排列的(对于我们的用户名来说,也可以说是按照"9"到"0"排列的)。
如果应用程序同时需要按照{"age" : 1, "username" : 1}优化排序,我们还需要创建一个这个方向上的索引。至于索引使用的方向,与排序方向相同就可以了。注意,相互反转(在每个方向都乘以-1)的索引是等价的:{"age" : 1, "user name" : -1}适用的查询与{"age" : -1, "username" : 1}是完全一样的。
只有基于多个查询条件进行排序时,索引方向才是比较重要的。如果只是基于单一键进行排序,MongoDB可以简单地从相反方向读取索引。例如,如果有一个基于{"age" : -1}的排序和一个基于{"age" : 1}的索引,MongoDB会在使用索引时进行优化,就如同存在一个{"age" : -1}索引一样(所以不要创建两个这样的索引!)。只有在基于多键排序时,方向才变得重要。

2. 使用覆盖索引(covered index)

在上面的例子中,查询只是用来查找正确的文档,然后按照指示获取实际的文档。然后,如果你的查询只需要查找索引中包含的字段,那就根本没必要获取实际的文档。当一个索引包含用户请求的所有字段,可以认为这个索引覆盖了本次查询。在实际中,应该优先使用覆盖索引,而不是去获取实际的文档。这样可以保证工作集比较小,尤其与右平衡索引一起使用时。
为了确保查询只使用索引就可以完成,应该使用投射(详见4.1.1节)来指定不要返回"_id"字段(除非它是索引的一部分)。可能还需要对不需要查询的字段做索引,因此需要在编写时就在所需的查询速度和这种方式带来的开销之间做好权衡。
如果在覆盖索引上执行explain(),"indexOnly"字段的值要为true。
如果在一个含有数组的字段上做索引,这个索引永远也无法覆盖查询(因为数组是被保存在索引中的,5.1.4节会深入介绍)。即便将数组字段从需要返回的字段中剔除,这样的索引仍然无法覆盖查询。

3. 隐式索引

复合索引具有双重功能,而且对不同的查询可以表现为不同的索引。如果有一个{"age" : 1, "username" : 1}索引,"age"字段会被自动排序,就好像有一个{"age" : 1}索引一样。因此,这个复合索引可以当作{"age" : 1}索引一样使用。
这个可以根据需要推广到尽可能多的键:如果有一个拥有N个键的索引,那么你同时“免费”得到了所有这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}作为索引的查询是不会被优化的:只有能够使用索引前缀的查询才能从中受益。

2、$操作符如何使用索引

有一些查询完全无法使用索引,也有一些查询能够比其他查询更高效地使用索引。本节讲述MongoDB对各种不同查询操作符的处理。

1. 低效率的操作符

有一些查询完全无法使用索引,比如"$where"查询和检查一个键是否存在的查询({"key" : {"$exists" : true}})。也有其他一些操作不能高效地使用索引。
如果"x"上有一个索引,查询那些不包含"x"键的文档可以使用这样的索引({"x" : {"$exists" : false}}。然而,在索引中,不存在的字段和null字段的存储方式是一样的,查询必须遍历每一个文档检查这个值是否真的为null还是根本不存在。如果使用稀疏索引(sparse index),就不能使用{"$exists" : true},也不能使用{"$exists" : false}。
通常来说,取反的效率是比较低的。"$ne"查询可以使用索引,但并不是很有效。因为必须要查看所有的索引条目,而不只是"$ne"指定的条目,不得不扫描整个索引。例如,这样的查询遍历的索引范围如下:

> db.example.find({"i" : {"$ne" : 3}}).explain()
{
    "cursor" : "BtreeCursor i_1 multi",
    ...,
    "indexBounds" : {
        "i" : [
            [
                {
                    "$minElement" : 1
                },
                3
            ],
            [
                3,
                {
                    "$maxElement" : 1
                }
            ]
        ]
    },
    ...
}

这个查询查找了所有小于3和大于3的索引条目。如果索引中值为3的条目非常多,那么这个查询的效率是很不错的,否则的话,这个查询就不得不检查几乎所有的索引条目。
"$not"有时能够使用索引,但是通常它并不知道要如何使用索引。它能够对基本的范围(比如将{"key" : {"$lt" : 7}} 变成 {"key" : {"$gte" : 7}})和正则表达式进行反转。然而,大多数使用"$not"的查询都会退化为进行全表扫描。"$nin"就总是进行全表扫描。
如果需要快速执行一个这些类型的查询,可以试着找到另一个能够使用索引的语句,将其添加到查询中,这样就可以在MongoDB进行无索引匹配(non-indexed matching)时先将结果集的文档数量减到一个比较小的水平。
假如我们要找出所有没有"birthday"字段的用户。如果我们知道从3月20开始,程序会为每一个新用户添加生日字段,那么就可以只查询3月20之前创建的用户:

> db.users.find({"birthday" : {"$exists" : false}, "_id" : {"$lt" : march20Id}})

这个查询中的字段顺序无关紧要,MongoDB会自动找出可以使用索引的字段,而无视查询中的字段顺序。

2. 范围

复合索引使MongoDB能够高效地执行拥有多个语句的查询。设计基于多个字段的索引时,应该将会用于精确匹配的字段(比如 "x" : "foo")放在索引的前面,将用于范围匹配的字段(比如"y" : {"$gt" : 3, "$lt" : 5})放在最后。这样,查询就可以先使用第一个索引键进行精确匹配,然后再使用第二个索引范围在这个结果集内部进行搜索。假设要使用{"age" : 1, "username" : 1}索引查询特定年龄和用户名范围内的文档,可以精确指定索引边界值:

> db.users.find({"age" : 47,
... "username" : {"$gt" : "user5", "$lt" : "user8"}}).explain()
{
    "cursor" : "BtreeCursor age_1_username_1",
    "n" : 2788,
    "nscanned" : 2788,
    ...,
    "indexBounds" : {
        "age" : [
            [
                47,
                47
            ]
        ],
        "username" : [
            [
                "user5",
                "user8"
            ]
        ]
    },
    ...
}

这个查询会直接定位到"age"为47的索引条目,然后在其中搜索用户名介于"user5"和"user8"的条目。
反过来,假如使用{"username" : 1, "age" : 1}索引,这样就改变了查询计划(query plan),查询必须先找到介于"user5"和"user8"之间的所有用户,然后再从中挑选"age"等于47的用户。

> db.users.find({"age" : 47,
... "username" : {"$gt" : "user5", "$lt" : "user8"}}).explain()
{
    "cursor" : "BtreeCursor username_1_age_1",
    "n" : 2788,
    "nscanned" : 319499,
    ...,
    "indexBounds" : {
        "username" : [
            [
                "user5",
                "user8"
            ]
        ],
        "age" : [
            [
                47,
                47
            ]
        ]
    },
    "server" : "spock:27017"
}

本次查询中MongoDB扫描的索引条目数量是前一个查询的10倍!在一次查询中使用两个范围通常会导致低效的查询计划。

3. OR查询

写作本书时,MongoDB在一次查询中只能使用一个索引。如果你在{"x" : 1}上有一个索引,在{"y" : 1}上也有一个索引,在{"x" : 123, "y" : 456}上进行查询时,MongoDB会使用其中的一个索引,而不是两个一起用。"$or"是个例外,"$or"可以对每个子句都使用索引,因为"$or"实际上是执行两次查询然后将结果集合并。

> db.foo.find({"$or" : [{"x" : 123}, {"y" : 456}]}).explain()
 {
      "clauses" : [
        {
            "cursor" : "BtreeCursor x_1",
            "isMultiKey" : false,
            "n" : 1,
            "nscannedObjects" : 1,
            "nscanned" : 1,
            "nscannedObjectsAllPlans" : 1,
            "nscannedAllPlans" : 1,
            "scanAndOrder" : false,
            "indexOnly" : false,
            "nYields" : 0,
            "nChunkSkips" : 0,
            "millis" : 0,
            "indexBounds" : {
                "x" : [
                    [
                        123,
                        123
                    ]
            ]
        }
    },
    {
        "cursor" : "BtreeCursor y_1",
        "isMultiKey" : false,
        "n" : 1,
        "nscannedObjects" : 1,
        "nscanned" : 1,
        "nscannedObjectsAllPlans" : 1,
        "nscannedAllPlans" : 1,
        "scanAndOrder" : false,
        "indexOnly" : false,
        "nYields" : 0,
        "nChunkSkips" : 0,
        "millis" : 0,
        "indexBounds" : {
            "y" : [
                    [
                        456,
                        456
                    ]
                ]
            }
        }
    ],
    "n" : 2,
    "nscannedObjects" : 2,
    "nscanned" : 2,
    "nscannedObjectsAllPlans" : 2,
    "nscannedAllPlans" : 2,
    "millis" : 0,
    "server" : "spock:27017"
}

可以看到,这次的explain()输出结果由两次独立的查询组成。通常来说,执行两次查询再将结果合并的效率不如单次查询高,因此,应该尽可能使用"$in"而不是"$or"。
如果不得不使用"$or",记住,MongoDB需要检查每次查询的结果集并且从中移除重复的文档(有些文档可能会被多个"$or"子句匹配到)。
使用"$in"查询时无法控制返回文档的顺序(除非进行排序)。例如,使用{"x" : [1, 2, 3]}与使用{"x" : [3, 2, 1]}得到的文档顺序是相同的。

 3、索引对象和数组

MongoDB允许深入文档内部,对嵌套字段和数组建立索引。嵌套对象和数组字段可以与复合索引中的顶级字段一起使用,虽然它们比较特殊,但是大多数情况下与“正常”索引字段的行为是一致的。

1. 索引嵌套文档

可以在嵌套文档的键上建立索引,方式与正常的键一样。如果有这样一个集合,其中的第一个文档表示一个用户,可能需要使用嵌套文档来表示每个用户的位置:

{
    "username" : "sid",
    "loc" : {
        "ip" : "1.2.3.4",
        "city" : "Springfield",
        "state" : "NY"
    }
}

需要在"loc"的某一个子字段(比如"loc.city")上建立索引,以便提高这个字段的查询速度:

> db.users.ensureIndex({"loc.city" : 1})

可以用这种方式对任意深层次的字段建立索引,比如你可以在"x.y.z.w.a.b.c"上建立索引。
注意,对嵌套文档本身("loc")建立索引,与对嵌套文档的某个字段("loc.city")建立索引是不同的。对整个子文档建立索引,只会提高整个子文档的查询速度。在上面的例子中,只有在进行与子文档字段顺序完全匹配的子文档查询时(比如db.users.find({"loc" : {"ip" : "123.456.789.000", "city" : "Shelbyville", "state" : "NY"}}})),查询优化器才会使用"loc"上的索引。无法对形如db.users.find({"loc.city" : "Shelbyville"})的查询使用索引。

2. 索引数组

也可以对数组建立索引,这样就可以高效地搜索数组中的特定元素。
假如有一个博客文章的集合,其中每个文档表示一篇文章。每篇文章都有一个"comments"字段,这是一个数组,其中每个元素都是一个评论子文档。如果想要找出最近被评论次数最多的博客文章,可以在博客文章集合中嵌套的"comments"数组的"date"键上建立索引:

> db.blog.ensureIndex({"comments.date" : 1})

对数组建立索引,实际上是对数组的每一个元素建立一个索引条目,所以如果一篇文章有20条评论,那么它就拥有20个索引条目。因此数组索引的代价比单值索引高:对于单次插入、更新或者删除,每一个数组条目可能都需要更新(可能有上千个索引条目)。
与上一节中"loc"的例子不同,无法将整个数组作为一个实体建立索引:对数组建立索引,实际上是对数组中的每个元素建立索引,而不是对数组本身建立索引。
在数组上建立的索引并不包含任何位置信息:无法使用数组索引查找特定位置的数组元素,比如"comments.4"。
少数特殊情况下,可以对某个特定的数组条目进行索引,比如:

> db.blog.ensureIndex({"comments.10.votes": 1})

然而,只有在精确匹配第11个数组元素时这个索引才有用(数组下标从0开始)。
一个索引中的数组字段最多只能有一个。这是为了避免在多键索引中索引条目爆炸性增长:每一对可能的元素都要被索引,这样导致每个文档拥有n*m个索引条目。假如有一个{"x" : 1, "y" : 1}上的索引:

> // x是一个数组—— 这是合法的
> db.multi.insert({"x" : [1, 2, 3], "y" : 1})
>
> // y是一个数组——这也是合法的
> db.multi.insert({"x" : 1, "y" : [4, 5, 6]})
>
> // x和y都是数组——这是非法的!
> db.multi.insert({"x" : [1, 2, 3], "y" : [4, 5, 6]})
cannot index parallel arrays [y] [x]

如果MongoDB要为上面的最后一个例子创建索引,它必须要创建这么多索引条目:{"x" : 1, "y" : 4}、{"x" : 1, "y" : 5}、{"x" : 1, "y" : 6}、{"x" : 2, "y" : 4}、{"x" : 2, "y" : 5},{"x" : 2, "y" : 6}、{"x" : 3, "y" : 4}、{"x" : 3, "y" : 5}和{"x" : 3, "y" : 6}。尽管这些数组只有3个元素。

3. 多键索引

对于某个索引的键,如果这个键在某个文档中是一个数组,那么这个索引就会被标记为多键索引(multikey index)。可以从explain()的输出中看到一个索引是否为多键索引:如果使用了多键索引,"isMultikey"字段的值会是true。索引只要被标记为多键索引,就无法再变成非多键索引了,即使这个字段为数组的所有文档都从集合中删除。要将多键索引恢复为非多键索引,唯一的方法就是删除再重建这个索引。
多键索引可能会比非多键索引慢一些。可能会有多个索引条目指向同一个文档,因此MongoDB在返回结果集时必须要先去除重复的内容。

4、索引基数

基数(cardinality)就是集合中某个字段拥有不同值的数量。有一些字段,比如"gender"或者"newsletter opt-out",可能只拥有两个可能的值,这种键的基数就是非常低的。另外一些字段,比如"username"或者"email",可能集合中的每个文档都拥有一个不同的值,这类键的基数是非常高的。当然也有一些介于两者之间的字段,比如"age"或者"zip code"。
通常,一个字段的基数越高,这个键上的索引就越有用。这是因为索引能够迅速将搜索范围缩小到一个比较小的结果集。对于低基数的字段,索引通常无法排除掉大量可能的匹配。
假设我们在"gender"上有一个索引,需要查找名为Susan的女性用户。通过这个索引,只能将搜索空间缩小到大约50%,然后要在每个单独的文档中查找"name"为"Susan"的用户。反过来,如果在"name"上建立索引,就能立即将结果集缩小到名为"Susan"的用户,这样的结果集非常小,然后就可以根据性别从中迅速地找到匹配的文档了。
一般说来,应该在基数比较高的键上建立索引,或者至少应该把基数较高的键放在复合索引的前面(低基数的键之前)。

上一篇文章:MongoDB指南---10、索引、复合索引 简介
下一篇文章:MongoDB指南---12、使用explain()和hint()、何时不应该使用索引

相关推荐