乐趣区

MongoDB指南10索引复合索引-简介

上一篇文章:MongoDB 指南 —9、游标与数据库命令
下一篇文章:MongoDB 指南 —11、使用复合索引、$ 操作符如何使用索引、索引对象和数组、索引基数

本章介绍 MongoDB 的索引,索引可以用来优化查询,而且在某些特定类型的查询中,索引是必不可少的。

  • 什么是索引?为什么要用索引?
  • 如何选择需要建立索引的字段?
  • 如何强制使用索引?如何评估索引的效率?
  • 创建索引和删除索引。

为集合选择合适的索引是提高性能的关键。

1、索引简介

数据库索引与书籍的索引类似。有了索引就不需要翻整本书,数据库可以直接在索引中查找,在索引中找到条目以后,就可以直接跳转到目标文档的位置,这能使查找速度提高几个数量级。
不使用索引的查询称为全表扫描(这个术语来自关系型数据库),也就是说,服务器必须查找完一整本书才能找到查询结果。这个处理过程与我们在一本没有索引的书中查找信息很像:从第 1 页开始一直读完整本书。通常来说,应该尽量避免全表扫描,因为对于大集合来说,全表扫描的效率非常低。
来看一个例子,我们创建了一个拥有 1 000 000 个文档的集合(如果你想要 10 000 000 或者 100 000 000 个文档也行,只要你有那个耐心):

> for (i=0; i<1000000; i++) {
...     db.users.insert(
...         {
...             "i" : i,
...             "username" : "user"+i,
...             "age" : Math.floor(Math.random()*120),
...             "created" : new Date()
...         }
...     );
... }

如果在这个集合上做查询,可以使用 explain()函数查看 MongoDB 在执行查询的过程中所做的事情。下面试着查询一个随机的用户名:

> db.users.find({username: "user101"}).explain()
{
    "cursor" : "BasicCursor",
    "nscanned" : 1000000,
    "nscannedObjects" : 1000000,
    "n" : 1,
    "millis" : 721,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "isMultiKey" : false,
    "indexOnly" : false,
    "indexBounds" : {}}

5.2 节会详细介绍输出信息里的这些字段,目前来说可以忽略大多数字段。”nscanned” 是 MongoDB 在完成这个查询的过程中扫描的文档总数。可以看到,这个集合中的每个文档都被扫描过了。也就是说,为了完成这个查询,MongoDB 查看了每一个文档中的每一个字段。这个查询耗费了将近 1 秒的时间才完成:”millis” 字段显示的是这个查询耗费的毫秒数。
字段 ”n” 显示了查询结果的数量,这里是 1,因为这个集合中确实只有一个 username 为 ”user101″ 的文档。注意,由于不知道集合里的 username 字段是唯一的,MongoDB 不得不查看集合中的每一个文档。为了优化查询,将查询结果限制为 1,这样 MongoDB 在找到一个文档之后就会停止了:

> db.users.find({username: "user101"}).limit(1).explain()
{
    "cursor" : "BasicCursor",
    "nscanned" : 102,
    "nscannedObjects" : 102,
    "n" : 1,
    "millis" : 2,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "isMultiKey" : false,
    "indexOnly" : false,
    "indexBounds" : {}}

现在,所扫描的文档数量极大地减少了,而且整个查询几乎是瞬间完成的。但是,这个方案是不现实的:如果要查找的是 user999999 呢?我们仍然不得不遍历整个集合,而且,随着用户的增加,查询会越来越慢。
对于此类查询,索引是一个非常好的解决方案:索引可以根据给定的字段组织数据,让 MongoDB 能够非常快地找到目标文档。下面尝试在 username 字段上创建一个索引:

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

由于机器性能和集合大小的不同,创建索引有可能需要花几分钟时间。如果对 ensureIndex 的调用没能在几秒钟后返回,可以在另一个 shell 中执行 db.currentOp()或者是检查 mongod 的日志来查看索引创建的进度。
索引创建完成之后,再次执行最初的查询:

> db.users.find({"username" : "user101"}).explain()
{
    "cursor" : "BtreeCursor username_1",
    "nscanned" : 1,
    "nscannedObjects" : 1,
    "n" : 1,
    "millis" : 3,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "isMultiKey" : false,
    "indexOnly" : false,
    "indexBounds" : {
        "username" : [
            [
                "user101",
                "user101"
            ]
        ]
    }
}

这次 explain()的输出内容比之前复杂一些,但是目前我们只需要注意 ”n”、”nscanned” 和 ”millis” 这几个字段,可以忽略其他字段。可以看到,这个查询现在几乎是瞬间完成的(甚至可以更好),而且对于任意 username 的查询,所耗费的时间基本一致:

> db.users.find({username: "user999999"}).explain().millis
1

可以看到,使用了索引的查询几乎可以瞬间完成,这是非常激动人心的。然而,使用索引是有代价的:对于添加的每一个索引,每次写操作(插入、更新、删除)都将耗费更多的时间。这是因为,当数据发生变动时,MongoDB 不仅要更新文档,还要更新集合上的所有索引。因此,MongoDB 限制每个集合上最多只能有 64 个索引。通常,在一个特定的集合上,不应该拥有两个以上的索引。于是,挑选合适的字段建立索引非常重要。

MongoDB 的索引几乎与传统的关系型数据库索引一模一样,所以如果已经掌握了那些技巧,则可以跳过本节的语法说明。后面会介绍一些索引的基础知识,但一定要记住这里涉及的只是冰山一角。绝大多数优化 MySQL/Oracle/SQLite 索引的技巧同样也适用于 MongoDB(包括“Use the Index,Luke”上的教程 http://use-the-index-luke.com)。

为了选择合适的键来建立索引,可以查看常用的查询,以及那些需要被优化的查询,从中找出一组常用的键。例如,在上面的例子中,查询是在 ”username” 上进行的。如果这是一个非常通用的查询,或者这个查询造成了性能瓶颈,那么在 ”username” 上建立索引会是非常好的选择。然而,如果这只是一个很少用到的查询,或者只是给管理员用的查询(管理员并不需要太在意查询耗费的时间),那就不应该对 ”username” 建立索引。

2、复合索引简介

索引的值是按一定顺序排列的,因此,使用索引键对文档进行排序非常快。然而,只有在首先使用索引键进行排序时,索引才有用。例如,在下面的排序里,”username” 上的索引没什么作用:

> db.users.find().sort({"age" : 1, "username" : 1})

这里先根据 ”age” 排序再根据 ”username” 排序,所以 ”username” 在这里发挥的作用并不大。为了优化这个排序,可能需要在 ”age” 和 ”username” 上建立索引:

> db.users.ensureIndex({"age" : 1, "username" : 1})

这样就建立了一个复合索引(compound index)。如果查询中有多个排序方向或者查询条件中有多个键,这个索引就会非常有用。复合索引就是一个建立在多个字段上的索引。
假如我们有一个 users 集合(如下所示),如果在这个集合上执行一个不排序(称为自然顺序)的查询:

> db.users.find({}, {"_id" : 0, "i" : 0, "created" : 0})
{"username" : "user0", "age" : 69}
{"username" : "user1", "age" : 50}
{"username" : "user2", "age" : 88}
{"username" : "user3", "age" : 52}
{"username" : "user4", "age" : 74}
{"username" : "user5", "age" : 104}
{"username" : "user6", "age" : 59}
{"username" : "user7", "age" : 102}
{"username" : "user8", "age" : 94}
{"username" : "user9", "age" : 7}
{"username" : "user10", "age" : 80}
...

如果使用 {“age” : 1,“username” : 1} 建立索引,这个索引大致会是这个样子:

[0, "user100309"] -> 0x0c965148
[0, "user100334"] -> 0xf51f818e
[0, "user100479"] -> 0x00fd7934
...
[0, "user99985"] -> 0xd246648f
[1, "user100156"] -> 0xf78d5bdd
[1, "user100187"] -> 0x68ab28bd
[1, "user100192"] -> 0x5c7fb621
...
[1, "user999920"] -> 0x67ded4b7
[2, "user100141"] -> 0x3996dd46
[2, "user100149"] -> 0xfce68412
[2, "user100223"] -> 0x91106e23
...

每一个索引条目都包含一个 ”age” 字段和一个 ”username” 字段,并且指向文档在磁盘上的存储位置(这里使用十六进制数字表示,可以忽略)。注意,这里的 ”age” 字段是严格升序排列的,”age” 相同的条目按照 ”username” 升序排列。每个 ”age” 都有大约 8000 个对应的 ”username”,这里只是挑选了少量数据用于传达大概的信息。
MongoDB 对这个索引的使用方式取决于查询的类型。下面是三种主要的方式。

  • db.users.find({“age” : 21}).sort({“username” : -1})

这是一个点查询(point query),用于查找单个值(尽管包含这个值的文档可能有多个)。由于索引中的第二个字段,查询结果已经是有序的了:MongoDB 可以从 {“age” : 21} 匹配的最后一个索引开始,逆序依次遍历索引:

[21, "user999977"] -> 0x9b3160cf
[21, "user999954"] -> 0xfe039231
[21, "user999902"] -> 0x719996aa
...

这种类型的查询是非常高效的:MongoDB 能够直接定位到正确的年龄,而且不需要对结果进行排序(因为只需要对数据进行逆序遍历就可以得到正确的顺序了)。
注意,排序方向并不重要:MongoDB 可以在任意方向上对索引进行遍历。

  • db.users.find({“age” : {“$gte” : 21,“$lte” : 30}})

这是一个多值查询(multi-value query),查找到多个值相匹配的文档(在本例中,年龄必须介于 21 到 30 之间)。MongoDB 会使用索引中的第一个键 ”age” 得到匹配的文档,如下所示:

[21, "user100000"] -> 0x37555a81
[21, "user100069"] -> 0x6951d16f
[21, "user1001"] ->   0x9a1f5e0c
[21, "user100253"] -> 0xd54bd959
[21, "user100409"] -> 0x824fef6c
[21, "user100469"] -> 0x5fba778b
...
[30, "user999775"] -> 0x45182d8c
[30, "user999850"] -> 0x1df279e9
[30, "user999936"] -> 0x525caa57

通常来说,如果 MongoDB 使用索引进行查询,那么查询结果文档通常是按照索引顺序排列的。

  • db.users.find({“age” : {“$gte” : 21,“$lte” : 30}}).sort({“username”:1})

这是一个多值查询,与上一个类似,只是这次需要对查询结果进行排序。跟之前一样,MongoDB 会使用索引来匹配查询条件:

[21, "user100000"] -> 0x37555a81
[21, "user100069"] -> 0x6951d16f
[21, "user1001"] ->   0x9a1f5e0c 
[21, "user100253"] -> 0xd54bd959
...
[22, "user100004"] -> 0x81e862c5
[22, "user100328"] -> 0x83376384
[22, "user100335"] -> 0x55932943
[22, "user100405"] -> 0x20e7e664
...

然而,使用这个索引得到的结果集中 ”username” 是无序的,而查询要求结果以 ”username” 升序排列,所以 MongoDB 需要先在内存中对结果进行排序,然后才能返回。因此,这个查询通常不如上一个高效。
当然,查询速度取决于有多少个文档与查询条件匹配:如果结果集中只有少数几个文档,MongoDB 对这些文档进行排序并不需要耗费多少时间。如果结果集中的文档数量比较多,查询速度就会比较慢,甚至根本不能用:如果结果集的大小超过 32 MB,MongoDB 就会出错,拒绝对如此多的数据进行排序:

Mon Oct 29 16:25:26 uncaught exception: error: {"$err" : "too much data for sort() with no index. add an index or
        specify a smaller limit","code" : 10128
}

最后一个例子中,还可以使用另一个索引(同样的键,但是顺序调换了):{“username” : 1,“age” : 1}。MongoDB 会反转所有的索引条目,但是会以你期望的顺序返回。MongoDB 会根据索引中的 ”age” 部分挑选出匹配的文档:

["user0", 69]
["user1", 50]
["user10", 80]
["user100", 48]
["user1000", 111]
["user10000", 98]
["user100000", 21] -> 0x73f0b48d
["user100001", 60]
["user100002", 82]
["user100003", 27] -> 0x0078f55f
["user100004", 22] -> 0x5f0d3088
["user100005", 95]
...

这样非常好,因为不需要在内存中对大量数据进行排序。但是,MongoDB 不得不扫描整个索引以便找到所有匹配的文档。因此,如果对查询结果的范围做了限制,那么 MongoDB 在几次匹配之后就可以不再扫描索引,在这种情况下,将排序键放在第一位是一个非常好的策略。
可以通过 explain()来查看 MongoDB 对 db.users.find({“age” : {“$gte” : 21,“$lte” : 30}}).sort({“username” : 1})的默认行为:

 > db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).
... sort({"username" : 1}).
... explain()
{
    "cursor" : "BtreeCursor age_1_username_1",
    "isMultiKey" : false,
    "n" : 83484,
    "nscannedObjects" : 83484,
    "nscanned" : 83484,
    "nscannedObjectsAllPlans" : 83484,
    "nscannedAllPlans" : 83484,
    "scanAndOrder" : true,
    "indexOnly" : false,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "millis" : 2766,
    "indexBounds" : {
        "age" : [
            [
                21,
                30
            ]
        ],
        "username" : [
            [
                {"$minElement" : 1},
                {"$maxElement" : 1}
            ]
        ]
    },
    "server" : "spock:27017"
}

可以忽略大部分字段,后面会有相关介绍。注意,”cursor” 字段说明这次查询使用的索引是 {“age” : 1,“user name” : 1},而且只查找了不到 1 /10 的文档(”nscanned” 只有 83484),但是这个查询耗费了差不多 3 秒的时间(”millis” 字段显示的是毫秒数)。这里的 ”scanAndOrder” 字段的值是 true:说明 MongoDB 必须在内存中对数据进行排序,如之前所述。
可以通过 hint 来强制 MongoDB 使用某个特定的索引,再次执行这个查询,但是这次使用 {“username” : 1,“age” : 1} 作为索引。这个查询扫描的文档比较多,但是不需要在内存中对数据排序:

> db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).
... sort({"username" : 1}).
... hint({"username" : 1, "age" : 1}).
... explain()
{
    "cursor" : "BtreeCursor username_1_age_1",
    "isMultiKey" : false,
    "n" : 83484,
    "nscannedObjects" : 83484,
    "nscanned" : 984434,
    "nscannedObjectsAllPlans" : 83484,
    "nscannedAllPlans" : 984434,
    "scanAndOrder" : false,
    "indexOnly" : false,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "millis" : 14820,
    "indexBounds" : {
        "username" : [
            [
                {"$minElement" : 1},
                {"$maxElement" : 1}
            ]
        ],
        "age" : [
            [
                21,
                30
            ]
        ]
    },
    "server" : "spock:27017"
}

注意,这次查询耗费了将近 15 秒才完成。对比鲜明,第一个索引速度更快。然而,如果限制每次查询的结果数量,新的赢家产生了:

> db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).
... sort({"username" : 1}).
... limit(1000).
... hint({"age" : 1, "username" : 1}).
... explain()['millis']
2031
> db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).
... sort({"username" : 1}).
... limit(1000).
... hint({"username" : 1, "age" : 1}).
... explain()['millis']
181

第一个查询耗费的时间仍然介于 2 秒到 3 秒之间,但是第二个查询只用了不到 1 / 5 秒!因此,应该就在应用程序使用的查询上执行 explain()。排除掉那些可能会导致 explain()输出信息不准确的选项。
在实际的应用程序中,{“sortKey” : 1,“queryCriteria” : 1}索引通常是很有用的,因为大多数应用程序在一次查询中只需要得到查询结果最前面的少数结果,而不是所有可能的结果。而且,由于索引在内部的组织形式,这种方式非常易于扩展。索引本质上是树,最小的值在最左边的叶子上,最大的值在最右边的叶子上。如果有一个日期类型的 ”sortKey”(或是其他能够随时间增加的值),当从左向右遍历这棵树时,你实际上也花费了时间。因此,如果应用程序需要使用最近数据的机会多于较老的数据,那么 MongoDB 只需在内存中保留这棵树最右侧的分支(最近的数据),而不必将整棵树留在内存中。类似这样的索引是右平衡的(right balanced),应该尽可能让索引是右平衡的。”_id” 索引就是一个典型的右平衡索引。

上一篇文章:MongoDB 指南 —9、游标与数据库命令
下一篇文章:MongoDB 指南 —11、使用复合索引、$ 操作符如何使用索引、索引对象和数组、索引基数

退出移动版