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

上一篇文章: MongoDB指南---十、索引、复合索引 简介
下一篇文章: MongoDB指南---十二、使用explain()和hint()、什么时候不该该使用索引

一、使用复合索引

在多个键上创建的索引就是复合索引,在上面的小节中,已经使用过复合索引。复合索引比单键索引要复杂一些,可是也更强大。本节会更深刻地介绍复合索引。正则表达式

1. 选择键的方向

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

[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}做为索引的查询是不会被优化的:只有可以使用索引前缀的查询才能从中受益。spa

二、$操做符如何使用索引

有一些查询彻底没法使用索引,也有一些查询可以比其余查询更高效地使用索引。本节讲述MongoDB对各类不一样查询操做符的处理。设计

1. 低效率的操做符

有一些查询彻底没法使用索引,好比"$where"查询和检查一个键是否存在的查询({"key" : {"$exists" : true}})。也有其余一些操做不能高效地使用索引。
若是"x"上有一个索引,查询那些不包含"x"键的文档可使用这样的索引({"x" : {"$exists" : false}}。然而,在索引中,不存在的字段和null字段的存储方式是同样的,查询必须遍历每个文档检查这个值是否真的为null仍是根本不存在。若是使用稀疏索引(sparse index),就不能使用{"$exists" : true},也不能使用{"$exists" : false}。
一般来讲,取反的效率是比较低的。"$ne"查询可使用索引,但并非颇有效。由于必需要查看全部的索引条目,而不仅是"$ne"指定的条目,不得不扫描整个索引。例如,这样的查询遍历的索引范围以下:code

> 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以前建立的用户:server

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

这个查询中的字段顺序可有可无,MongoDB会自动找出可使用索引的字段,而无视查询中的字段顺序。对象

2. 范围

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

> 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]}获得的文档顺序是相同的。

 三、索引对象和数组

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在返回结果集时必需要先去除重复的内容。

四、索引基数

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

上一篇文章: MongoDB指南---十、索引、复合索引 简介
下一篇文章: MongoDB指南---十二、使用explain()和hint()、什么时候不该该使用索引
相关文章
相关标签/搜索