乐趣区

MongoDB指南12使用explain和hint何时不应该使用索引

上一篇文章:MongoDB 指南 —11、使用复合索引、$ 操作符如何使用索引、索引对象和数组、索引基数
下一篇文章:MongoDB 指南 —13、索引类型

使用 explain()和 hint()

从上面的内容可以看出,explain()能够提供大量与查询相关的信息。对于速度比较慢的查询来说,这是最重要的诊断工具之一。通过查看一个查询的 explain()输出信息,可以知道查询使用了哪个索引,以及是如何使用的。对于任意查询,都可以在最后添加一个 explain()调用(与调用 sort()或者 limit()一样,不过 explain()必须放在最后)。
最常见的 explain()输出有两种类型:使用索引的查询和没有使用索引的查询。对于特殊类型的索引,生成的查询计划可能会有些许不同,但是大部分字段都是相似的。另外,分片返回的是多个 explain()的聚合(第 13 章会介绍),因为查询会在多个服务器上执行。
不使用索引的查询的 exlpain()是最基本的 explain()类型。如果一个查询不使用索引,是因为它使用了 ”BasicCursor”(基本游标)。反过来说,大部分使用索引的查询使用的是 BtreeCursor(某些特殊类型的索引,比如地理空间索引,使用的是它们自己类型的游标)。
对于使用了复合索引的查询,最简单情况下的 explain()输出如下所示:

> db.users.find({"age" : 42}).explain()
{
    "cursor" : "BtreeCursor age_1_username_1",
    "isMultiKey" : false,
    "n" : 8332,
    "nscannedObjects" : 8332,
    "nscanned" : 8332,
    "nscannedObjectsAllPlans" : 8332,
    "nscannedAllPlans" : 8332,
    "scanAndOrder" : false,
    "indexOnly" : false,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "millis" : 91,
    "indexBounds" : {
        "age" : [
            [
                42,
                42
            ]
        ],
        "username" : [
            [
                {"$minElement" : 1},
                {"$maxElement" : 1}
            ]
        ]
    },
    "server" : "ubuntu:27017"
}

从输出信息中可以看到它使用的索引是 age_1_username_1。”millis” 表明了这个查询的执行速度,时间是从服务器收到请求开始一直到发出响应为止。然而,这个数值不一定真的是你希望看到的值。如果 MongoDB 尝试了多个查询计划,那么 ”millis” 显示的是这些查询计划花费的总时间,而不是最优查询计划所花的时间。
接下来是实际返回的文档数量:”n”。它无法反映出 MongoDB 在执行这个查询的过程中所做的工作:搜索了多少索引条目和文档。索引条目是使用 ”nscanned” 描述的。”nscannedObjects” 字段的值就是所扫描的文档数量。最后,如果要对结果集进行排序,而 MongoDB 无法对排序使用索引,那么 ”scanAndOrder” 的值就会是 true。也就是说,MongoDB 不得不在内存中对结果进行排序,这是非常慢的,而且结果集的数量要比较小。
现在你已经知道这些基础知识了,接下来依次详细介绍这些字段。

  • “cursor” : “BtreeCursor age_1_username_1”

BtreeCursor 表示本次查询使用了索引,具体来说,是使用了 ”age” 和 ”username” 上的索引{“age” : 1,“username” : 1}。如果查询要对结果进行逆序遍历,或者是使用了多键索引,就可以在这个字段中看到 ”reverse” 和 ”multi” 这样的值。

  • “isMultiKey” : false

用于说明本次查询是否使用了多键索引(详见 5.1.4 节)。

  • “n” : 8332

本次查询返回的文档数量。

  • “nscannedObjects” : 8332

这是 MongoDB 按照索引指针去磁盘上查找实际文档的次数。如果查询包含的查询条件不是索引的一部分,或者说要求返回不在索引内的字段,MongoDB 就必须依次查找每个索引条目指向的文档。

  • “nscanned” : 8332

如果有使用索引,那么这个数字就是查找过的索引条目数量。如果本次查询是一次全表扫描,那么这个数字就表示检查过的文档数量。

  • “scanAndOrder” : false

MongoDB 是否在内存中对结果集进行了排序。

  • “indexOnly” : false

MongoDB 是否只使用索引就能完成此次查询(详见“覆盖索引”部分)。
在本例中,MongoDB 只使用索引就找到了全部的匹配文档,从 ”nscanned” 和 ”n” 相等就可以看出来。然而,本次查询要求返回匹配文档中的所有字段,而索引只包含 ”age” 和 ”username” 两个字段。如果将本次查询修改为({“_id” : 0,“age” : 1,“username” : 1}),那么本次查询就可以被索引覆盖了,”indexOnly” 的值就会是 true。

  • “nYields” : 0

为了让写入请求能够顺利执行,本次查询暂停的次数。如果有写入请求需要处理,查询会周期性地释放它们的锁,以便写入能够顺利执行。然而,在本次查询中,没有写入请求,因为查询没有暂停过。

  • “millis” : 91

数据库执行本次查询所耗费的毫秒数。这个数字越小,说明查询效率越高。

  • “indexBounds” : {…}

这个字段描述了索引的使用情况,给出了索引的遍历范围。由于查询中的第一个语句是精确匹配,因此索引只需要查找 42 这个值就可以了。本次查询没有指定第二个索引键,因此这个索引键上没有限制,数据库会在 ”age” 为 42 的条目中将用户名介于负无穷(”$minElement” : 1)和正无穷(”$maxElement” : 1)的条目都找出来。
再来看一个稍微复杂点的例子:假如有一个 {“user name” : 1,“age” : 1} 上的索引和一个 {“age” : 1,“username” : 1}上的索引。同时查询 ”username” 和 ”age” 时,会发生什么情况?呃,这取决于具体的查询:

> db.c.find({age : {$gt : 10}, username : "sally"}).explain()
{
    "cursor" : "BtreeCursor username_1_age_1",
    "indexBounds" : [
        [
            {
                "username" : "sally",
                "age" : 10
            },
            {
                "username" : "sally",
                "age" : 1.7976931348623157e+308
            }
        ]
    ],
    "nscanned" : 13,
    "nscannedObjects" : 13,
    "n" : 13,
    "millis" : 5
}

由于在要在 ”username” 上执行精确匹配,在 ”age” 上进行范围查询,因此,数据库选择使用 {“username” : 1,“age” : 1} 索引,这与查询语句的顺序相反。另一方面来说,如果需要对 ”age” 精确匹配而对 ”username” 进行范围查询,MongoDB 就会使用另一个索引:

> db.c.find({"age" : 14, "username" : /.*/}).explain()
{
    "cursor" : "BtreeCursor age_1_username_1 multi",
    "indexBounds" : [
        [
            {
                "age" : 14,
                "username" : ""
            },
            {
                "age" : 14,
                "username" : {}}
        ],
        [
            {
                "age" : 14,
                "username" : /.*/
            },
            {
                "age" : 14,
                "username" : /.*/
            }
        ]
    ],
    "nscanned" : 2,
    "nscannedObjects" : 2,
    "n" : 2,
    "millis" : 2
}

如果发现 MongoDB 使用的索引与自己希望它使用的索引不一致,可以使用 hit()强制 MongoDB 使用特定的索引。例如,如果希望 MongoDB 在上个例子的查询中使用 {“username” : 1,“age” : 1} 索引,可以这么做:

> db.c.find({"age" : 14, "username" : /.*/}).hint({"username" : 1, "age" : 1})

如果查询没有使用你希望它使用的索引,于是你使用 hint 强制 MongoDB 使用某个索引,那么应该在应用程序部署之前在所指定的索引上执行 explain()。如果强制 MongoDB 在某个查询上使用索引,而这个查询不知道如何使用这个索引,这样会导致查询效率降低,还不如不使用索引来得快。

查询优化器

MongoDB 的查询优化器与其他数据库稍有不同。基本来说,如果一个索引能够精确匹配一个查询(要查询 ”x”,刚好在 ”x” 上有一个索引),那么查询优化器就会使用这个索引。不然的话,可能会有几个索引都适合你的查询。MongoDB 会从这些可能的索引子集中为每次查询计划选择一个,这些查询计划是并行执行的。最早返回 100 个结果的就是胜者,其他的查询计划就会被中止。
这个查询计划会被缓存,这个查询接下来都会使用它,直到集合数据发生了比较大的变动。如果在最初的计划评估之后集合发生了比较大的数据变动,查询优化器就会重新挑选可行的查询计划。建立索引时,或者是每执行 1000 次查询之后,查询优化器都会重新评估查询计划。
explain()输出信息里的 ”allPlans” 字段显示了本次查询尝试过的每个查询计划。

何时不应该使用索引

提取较小的子数据集时,索引非常高效。也有一些查询不使用索引会更快。结果集在原集合中所占的比例越大,索引的速度就越慢,因为使用索引需要进行两次查找:一次是查找索引条目,一次是根据索引指针去查找相应的文档。而全表扫描只需要进行一次查找:查找文档。在最坏的情况下(返回集合内的所有文档),使用索引进行的查找次数会是全表扫描的两倍,效率会明显比全表扫描低很多。
可惜,并没有一个严格的规则可以告诉我们,如何根据数据大小、索引大小、文档大小以及结果集的平均大小来判断什么时候索引很有用,什么时候索引会降低查询速度(如表 5 - 1 所示)。一般来说,如果查询需要返回集合内 30% 的文档(或者更多),那就应该对索引和全表扫描的速度进行比较。然而,这个数字可能会在 2%~60% 之间变动。
表 5 -1 影响索引效率的属性

索引通常适用的情况 全表扫描通常适用的情况
集合较大 集合较小
文档较大 文档较小
选择性查询 非选择性查询

假如我们有一个收集统计信息的分析系统。应用程序要根据给定账户去系统中查询所有文档,根据从初始一直到一小时之前的数据生成图表:

> db.entries.find({"created_at" : {"$lt" : hourAgo}})

我们在 ”created_at” 上创建索引以提高查询速度。
最初运行时,结果集非常小,可以立即返回。几个星期过去以后,数据开始多起来了,一个月之后,这个查询耗费的时间越来越长。
对于大部分应用程序来说,这很可能就是那个“错误的”查询:真的需要在查询中返回数据集中的大部分内容吗?大部分应用程序(尤其是拥有非常大的数据集的应用程序)都不需要。然而,也有一些合理的情况,可能需要得到大部分或者全部的数据:也许需要将这些数据导出到报表系统,或者是放在批量任务中。在这些情况下,应该尽可能快地返回数据集中的内容。
可以用 {“$natural” : 1} 强制数据库做全表扫描。6.1 节会介绍 $natural,它可以指定文档按照磁盘上的顺序排列。特别地,$natural 可以强制 MongoDB 做全表扫描:

> db.entries.find({"created_at" : {"$lt" : hourAgo}}).hint({"$natural" : 1})

使用 ”$natural” 排序有一个副作用:返回的结果是按照磁盘上的顺序排列的。对于一个活跃的集合来说,这是没有意义的:随着文档体积的增加或者缩小,文档会在磁盘上进行移动,新的文档会被写入到这些文档留下的空白位置。但是,对于只需要进行插入的工作来说,如果要得到最新的(或者最早的)文档,使用 $natural 就非常有用了。

上一篇文章:MongoDB 指南 —11、使用复合索引、$ 操作符如何使用索引、索引对象和数组、索引基数
下一篇文章:MongoDB 指南 —13、索引类型

退出移动版