mongodb.jpg

索引在查询中占的地位无疑是重中之重,因此建立一个好的索引对查询性能的影响也是立竿见影。来自 10gen 工程师 A. Jesse Jiryu Davis 带来的 MongoDB 上索引的优化方法以及 MongoDB 索引的选择机制,帮助大家缩小索引的选择空间。

A. Jesse Jiryu Davis —— 10gen 工程师,从事 MongoDB、Python 及 Tornado。在 Dzone 上分享了 MongoDB 中组合索引的最佳建立方法以及索引中字段的最优顺序。并通过 explain()输出的结果来验证实际性能,同时还分析了 MongoDB 的查询优化器的索引选择机制。

项目背景

预想中的项目是在 MongoDB 上建立一个类 Disqus 的评论系统(虽然 Disqus 使用的是 Postgres,但是不影响我们讨论)。这里储存的评论可能是上万条,但是我们先从简单的 4 条谈起。每条评论都拥有时间戳(timestamp)、匿名(发送)与否(anonymous)以及质量评价(rating)这三个属性:

1{ timestamp: 1, anonymous: false, rating: 3 }
2{ timestamp: 2, anonymous: false, rating: 5 }
3{ timestamp: 3, anonymous:  true, rating: 1 }
4{ timestamp: 4, anonymous: false, rating: 2 }

这里需要查询的是 anonymous = false 而且 timestamp 在 2 – 4 之间的评论,查询结果通过 rating 进行排序。我们将分 3 步完成查询的优化并且通过 MongoDB 的 explain()对索引进行考量。

范围查询

首先从简单的查询开始 —— timestamps 范围在 2-4 的评论:

1> db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } )

查询的结果很显然是 3 条。然而这里的重点是通过 explain()看 MongoDB 是如何去实现查询的:

1> db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } ).explain()
2{
3    "cursor" : "BasicCursor",
4    "n" : 3,
5    "nscannedObjects" : 4,
6    "nscanned" : 4,
7    "scanAndOrder" : false
8    // ... snipped output ...
9}

先看一下如何读 MongoDB 的查询计划:首先看 cursor 的类型。“BasicCursor”可以称得上一个警告标志:它意味着 MongoDB 将对数据集做一个完全的扫描。当数据集里包含上千万条信息时,这完全是行不通的。所以这里需要在 timestamp 上加一个索引:

1> db.comments.createIndex( { timestamp: 1 } )

现在再看 explain()的输出结果:

1> db.comments.find( { timestamp: { $gte: 2, $lte: 4 } } ).explain()
2{
3    "cursor" : "BtreeCursor timestamp_1",
4    "n" : 3,
5    "nscannedObjects" : 3,
6    "nscanned" : 3,
7    "scanAndOrder" : false
8}

现在 cursor 的类型明显变成了“BtreeCuor timestamp_1”(timestamp_1 为之前定义的索引名称)。nscanned 从 4 降到了 3,因为这里 Mongo 使用了索引跳过了范围外的文档直接指向了需要查询的文档。

164_121109093616_1.jpg

对于定义了索引的查询:nscanned 体现了 Mongo 扫描字段索引的条数,而 nscannedObjects 则为最终结果中查询过的文档数目。n 则表示了返回文档的数目。nscannedObjects 至少包含了所有的返回文档,即使 Mongo 明确了可以通过查看绝对匹配文件的索引。因此可以得出 nscanned >= nscannedObjects >= n。对于简单查询你可能期望 3 个数字是相等的。这意味着你做出了 MongoDB 使用的完美索引。

范围查询的基础上添加等值查询

然而什么情况下 nscanned 会大于 n ?很显然当 Mongo 需要检验一些指向不匹配查询的文档的字段索引。举个例子,我需要过滤出 anonymous = true 的文档:

 1> db.comments.find(
 2...     { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
 3... ).explain()
 4{
 5    "cursor" : "BtreeCursor timestamp_1",
 6    "n" : 2,
 7    "nscannedObjects" : 3,
 8    "nscanned" : 3,
 9    "scanAndOrder" : false
10}

从 explain()输出结果上来看:虽然 n 从 3 降到了 2,但是 nscanned 和 nscannedObjects 的值仍然为 3。Mongo 扫描了 timestamp 从 2 到 4 的索引,这就包含了 anonymous = true/false 的所有情况。在文件检查完之前,更不会去滤掉下一个。

164_121109093837_1.jpg

那么如何才能回到完美的 nscanned = nscannedObjects = n 上来?这里尝试一个在 timestamp 和 anonymous 上的组合索引:

 1> db.comments.createIndex( { timestamp:1, anonymous:1 } )
 2> db.comments.find(
 3...     { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
 4... ).explain()
 5{
 6    "cursor" : "BtreeCursor timestamp_1_anonymous_1",
 7    "n" : 2,
 8    "nscannedObjects" : 2,
 9    "nscanned" : 3,
10    "scanAndOrder" : false
11}

这次的情况好了一点:nscannedObjects 从 3 降到了 2。但是 nscanned 仍然为 3!Mongo 还是做了 timestamp 2 到 4 上索引的全扫描。当然当检查 anonymous 索引发现其值为 true 时,Mongo 选择了直接跳过而没有进行文档扫描。因此这也是为什么只有 nscanned 的值仍为 2 的原因。

164_121109100553_1.jpg

那么是否可以改善这个情况让 nscanned 也降到 2?你可能已经注意到这点了:定义索引的次序存在问题。是的,这里应该是“anonymous,timestamp”而不是“timestamp,anonymous”:

 1> db.comments.createIndex( { anonymous:1, timestamp:1 } )
 2> db.comments.find(
 3...     { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
 4... ).explain()
 5{
 6    "cursor" : "BtreeCursor anonymous_1_timestamp_1",
 7    "n" : 2,
 8    "nscannedObjects" : 2,
 9    "nscanned" : 2,
10    "scanAndOrder" : false
11}

对于 MongoDB 组合索引的关键字顺序问题和其他数据库都是一样的。假如使用 anonymous 作为索引的第一个关键字,Mongo 则会直接调至 anonymous = false 文档做 timestamp 2 到 4 的范围扫描。

164_121109101255_1.jpg

这里结束了探索的第一部分,简单的了解了一下 MongoDB 组合索引的优化思想。然而事实上这种情况只存在于理想之中。

不防设想一下索引中包含“anonymous”是否物有所值。打个比方:我们现在的系统拥上千万条的评论并且天查询量也上千万,那么缩减 nscanned 必将大幅度的提升系统的吞吐量。但是如果 anonymous 部分在索引中很少用到,那么显而易见的可以把它从索引中剔除为经常用到的字段节省空间。另一方面:双字段索引肯定比单字段索引占更多的内存,因此单字段的索引在内存的开销上无疑也是更胜一筹。而在这里的情况就是:只有 anounymous = true 占很大比重的时候才会在全方面中得利。既然要全面考虑,那么我们还必须看一下 MongoDB 索引的选择机制。

MongoDB 的索引选择机制

首先来看一个比较有趣的事情:在先前的例子中我们并没有删除索引,这样的话在我们建立的 3 个索引中 MongoDB 总是会择优而取。为什么会出现这种情况?

MongoDB 的优化程序会在对比中选择更优秀的索引。首先,它会给查询做一个初步的“最佳索引”;其次,假如这个最佳索引不存在它会做尝试来选出表现最好的索引;最后优化器还会记住所有类似查询的选择(只到大规模文件变动或者索引上的变动)。

那么优化器是如何定义查询的“最佳索引”。最佳索引必须包含查询中所有可以做过滤及需要排序的字段。此外任何用于范围扫描的字段以及排序字段都必须排在做等值查询的字段之后。如果存在不同的最佳索引,那么 Mongo 将随机选择。在这个例子中“anonymous,timestamp”明显是最佳索引,所以很迅速的就做出了选择。

鉴于这样表述很苍白,下面来详细的看一下第二部分是如何工作的。当优化器需要在一堆没有特别优势的索引中选择一个时,它会收集所有相关的索引进行相关的查询,并选出最先完成的索引。

举个例子下面是个查询语句:

1db.comments.find({ timestamp: { $gte: 2, $lte: 4 }, anonymous: false })

全部的 3 个索引都是相关的,所以 MongoDB 将 3 条索引以任意的顺序连接起来并标注了每条索引依次进入的入口:

164_121109094453_1.jpg

所有索性都返回了如下结果:

1{ timestamp: 2, anonymous: false, rating: 5 }

首先。在第二步,左边和中间的索引都返回了:

1{ timestamp: 3, anonymous:  true, rating: 1 }

而右边的索引明显胜于其他的两条索引:

1{ timestamp: 4, anonymous: false, rating: 2 }

在这个竞赛中,在右方的索引明显比其他的两个先完成查询。那么在下一次比赛前,它会一直作为最佳索引存在。简而言之:存在多条索引的情况下,MongoDB 首选 nscanned 值最低的索引。

等值、范围查询及排序

既然我们拥有了 timestamps 在 2 到 4 之间的完美索引,那么我们的最后一步是进行排序。先从降序开始:

 1> db.comments.find(
 2...     { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
 3... ).sort( { rating: -1 } ).explain()
 4{
 5    "cursor" : "BtreeCursor anonymous_1_timestamp_1",
 6    "n" : 2,
 7    "nscannedObjects" : 2,
 8    "nscanned" : 2,
 9    "scanAndOrder" : true
10}

在之前通常都是这么做的,现在同样很好:nscanned = nscannedObjects = n。但是千万别忽略这条:scanAndOrder = true。这就意味着 MongoDB 会把所有查询出来的结果放进内存,然后进行排序,接着一次性输出结果。然而我们必须考虑:这将占用服务器大量的 CPU 和 RAM。取代将结果分批次的输出,Mongo 把他们全部放进内存并一起输出将大量争用应用程序服务器的资源。最终 Mongo 会强行给数据做一个 32MB 的限制,然后在内存里给他们排序。虽然我们现在讨论中只有 4 条评论,但是我们设计的是上千万条的系统!

那这里该如何处理 scanAndOrder = true 这个情况?我们需要加一个索引,让 Mongo 可以直接转到 anonyous = false 部分,并且要求的顺序扫描这个部分:

1> db.comments.createIndex( { anonymous: 1, rating: 1 } )

Mongo 会使用这个索引吗?当然不会,因为这条索引在比赛中赢不了拥有最小 nscanned 的索引。优化器无法识别哪条索引会有益于排序。

所以需要使用 hint 来强制 Mongo 的选择:

 1> db.comments.find(
 2...     { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
 3... ).sort( { rating: -1 }
 4... ).hint( { anonymous: 1, rating: 1 } ).explain()
 5{
 6    "cursor" : "BtreeCursor anonymous_1_rating_1 reverse",
 7    "n" : 2,
 8    "nscannedObjects" : 3,
 9    "nscanned" : 3,
10    "scanAndOrder" : false
11}

语句 hint 中存在争议和 CreateIndex 是差不多的。现在 nscanned = 3 但是 scanAndOrder = false。现在 Mongo 将反过来查询“anonymous,rating”索引,获得拥有正确顺序的评论,然后再检查每个文件的 timestamp 是否在范围内。

164_121109095058_1.jpg

这也是优化器为什么不会选择这条索引的而去执行这个拥有低 nscanned 但是完全在内存排序的旧“anonymous,timestamp”索引的原因。

我们以牺牲 nscanned 的代价解决了 scanAndOrder = true 的问题;既然 nscanned 已不可减少,那么我们是否可以减少 nscannedObjects?我们向索引中添加 timestamp,这样一来 Mongo 就不用去从每个文件中获取了:

1> db.comments.createIndex( { anonymous: 1, rating: 1, timestamp: 1 } )

同样优化器不会赞成这条索引我们必须 hint 它:

 1> db.comments.find(
 2...     { timestamp: { $gte: 2, $lte: 4 }, anonymous: false }
 3... ).sort( { rating: -1 }
 4... ).hint( { anonymous: 1, rating: 1, timestamp: 1 } ).explain()
 5{
 6    "cursor" : "BtreeCursor anonymous_1_rating_1_timestamp_1 reverse",
 7    "n" : 2,
 8    "nscannedObjects" : 2,
 9    "nscanned" : 3,
10    "scanAndOrder" : false,
11}

终于尽善尽美了。Mongo 遵循了类似之前的计划,并且 nscannedObjects 也降到了 2。

164_121109095344_1.jpg

当然必须得考虑给索引加入 timestamp 是否是值得的,因为 timestamp 给内存带来的附加空间可能会让你得不偿失。

最终方案

最后综合一下给出包含了等值测试、排序及范围过滤查询的索引建立方法:

  1. 等值测试

    在索引中加入所有需要做等值测试的字段,任意顺序。

  2. 排序字段(多排序字段的升/降序问题 )

    根据查询的顺序有序的向索引中添加字段。

  3. 范围过滤

    以字段的基数(Collection 中字段的不同值的数量)从低到高的向索引中添加范围过滤字段。

当然这里还有一个规则:如果索引中的等值或者范围查询字段不能过滤出 Collection 中 90%以上的文档,那么把它移除索引估计会更好一些。并且如果你在一个 Collection 上有多个索引,那么必须 hint Mongos。

对于组合索引的建立,有很多的因素去决定。虽然本文不能让你直接确定出一个最优的索引,但是无疑可以让你缩小索引建立时的选择。

本文转自:CSDN - 10gen 工程师谈 MongoDB 组合索引的优化

原文链接:Optimizing MongoDB Compound Indexes