MongoDB-性能调优教程-二-

113 阅读1小时+

MongoDB 性能调优教程(二)

原文:MongoDB Performance Tuning

协议:CC BY-NC-SA 4.0

五、索引

索引是一个数据库对象,它有自己的存储,提供了一个快速访问集合的路径。索引的存在主要是为了提高性能,因此在优化 MongoDB 性能时,理解和有效地使用索引是至关重要的。

b 树索引

B 树(“平衡树”)索引是 MongoDB 的默认索引结构。图 5-1 显示了 B 树索引结构的高级概述。

img/499970_1_En_5_Fig1_HTML.png

图 5-1

b 树索引结构

B 树索引具有分层的树结构。树的顶部是标题块。对于任何给定范围的键值,该块包含指向适当分支块的指针。对于更具体的范围,分支块通常指向适当的叶块,或者对于更大的索引,指向另一个分支块。叶块包含一个键值列表和指向磁盘上文档位置的指针。

检查图 5-1 ,让我们想象一下 MongoDB 将如何遍历这个索引。如果我们需要访问“BAKER”的记录,我们将首先查阅标题块。header 块告诉我们,从 A 到 K 开始的键值存储在最左边的分支块中。访问这个分支块,我们发现从 A 到 D 开始的键值存储在最左边的叶块中。参考这个叶块,我们找到值“BAKER”及其相关的磁盘位置,然后我们将使用它来获得相关的文档。

叶块包含到上一个和下一个叶块的链接。这允许我们以升序或降序扫描索引,并允许使用索引处理使用$gt$lt操作符的范围查询。

与其他索引策略相比,b 树索引具有以下优势:

  • 因为每个叶节点都在相同的深度,所以性能是非常可预测的。理论上,集合中的任何文档都不会超过三个或四个 io。

  • b 树为大型集合提供了出色的性能,因为深度最多为四(一个头块、两级分支块和一级叶块)。一般来说,没有一个文档需要四个以上的 io 来定位。事实上,因为头块几乎总是在内存中,而分支块通常在内存中,所以实际的物理磁盘读取次数通常只有一两次。

  • B 树索引支持范围查询和精确查找。这是可能的,因为链接到前一个和下一个叶块。

B 树索引提供了灵活高效的查询性能。然而,在改变数据时维护 B 树可能是昂贵的。例如,考虑将一个带有键值“NIVEN”的文档插入到图 5-1 所示的集合中。要插入文档,我们必须在“L-O”块中添加一个新条目。如果在这个块中有空闲空间,那么成本是相当大的,但可能不会过高。但是如果块中没有空闲空间会发生什么呢?

如果叶块内没有空闲空间用于新条目,则需要索引拆分。分配一个新的块,并将现有块中的一半条目移动到新块中。此外,还需要在分支块中添加一个指向新创建的叶块的新条目。如果分支块中没有空闲空间,那么分支块也必须被分割。

这些索引拆分是一项开销很大的操作:分配新的块,并将索引条目从一个块移动到另一个块。因此,索引会显著降低插入、更新和删除操作的速度。

Caution

索引加速了数据检索,但是增加了插入、更新和删除操作的负担。

索引选择性

索引的选择性是对有多少文档与特定索引键值相关联的度量。如果属性或索引具有大量的唯一值和很少的重复值,则它们是选择性的。例如,dateOfBirth属性将是非常有选择性的(大量的可能值和相对较少的重复值),而gender属性将不是有选择性的(少量的可能值和大量的重复值)。

选择性索引比非选择性索引更有效,因为它们更直接地指向特定的值。因此,MongoDB 将尝试使用最具选择性的索引。

唯一索引

唯一索引是防止组成索引的属性的任何重复值的索引。如果您尝试在包含此类重复值的集合上创建唯一索引,将会收到一个错误。同样,如果您尝试插入包含重复唯一索引键值的文档,也会收到错误。

创建唯一索引通常是为了防止重复值,而不是为了提高性能。然而,唯一索引通常非常有效——它们只指向一个文档,因此非常有选择性。

所有 MongoDB 集合都有一个内置的隐式惟一索引——在“_id”属性上。

索引扫描

除了能够找到特定的值,索引还可以优化部分字符串匹配和数据范围。这些索引扫描是可能的,因为 B 树索引结构包含到前一个和下一个叶块的链接。这些链接允许我们按升序或降序浏览索引。

例如,考虑以下查询,该查询检索两个日期之间出生的所有客户:

db.customers. find({
  $and: [
    { dateOfBirth: { $gt: ISODate('1980-01-01T00:00:00Z') } },
    { dateOfBirth: { $lt: ISODate('1990-01-01T00:00:00Z') } }
  ]
});

如果在dateOfBirth上有一个索引,我们可以使用该索引来查找相关的客户。MongoDB 将导航到较低日期的索引条目,然后扫描整个索引,直到到达一个索引条目,其中dateOfBirth大于较高日期。叶块之间的链接允许这种扫描有效地发生。

如果我们检查这个查询的explain()输出中的IXSCAN步骤,我们可以看到一个indexBounds条目,它显示了如何使用索引在两个值之间进行扫描:

"inputStage" : {
      "keyPattern" : {
      "dateOfBirth" : 1},
      "indexName" : "dateOfBirth_1",
      . . .
      "direction" : "forward",
      "indexBounds" : {
            "dateOfBirth" : [
                  "(new Date(315532800000),
                    new Date(631152000000))"
                        ]
                }
        }

当我们对字符串条件进行部分匹配时,也会执行索引扫描。例如,在下面的查询中,将扫描LastName上的索引,查找名称大于或等于“HARRIS”且小于或等于 HARRIT 的所有条目。实际上,这仅匹配名称 HARRIS 和 HARRISON,但是从 MongoDB 的角度来看,这与在高值和低值之间扫描是一样的。

mongo> var explainObj=db.customers.explain('executionStats')
            .find({LastName:{$regex:/^HARRIS(.*)/}});

mongo> mongoTuning.executionStats(explainObj);

1   IXSCAN ( LastName_1 ms:0 keys:1366)
2  FETCH ( ms:0 docs:1365)

Totals:  ms: 4  keys: 1366  Docs: 1365

索引扫描并不总是一件好事。如果范围很大,那么索引扫描可能比根本不使用索引更糟糕。在图 5-2 中,我们看到如果值的范围很宽(在本例中,与所有可能的值一样宽),那么最好进行集合扫描,而不是索引查找。但是,如果范围较窄,则该索引会提供更好的性能。我们将在第六章中详细讨论优化索引范围扫描。

img/499970_1_En_5_Fig2_HTML.png

图 5-2

索引扫描性能和扫描宽度

不区分大小写的搜索

搜索不确定大小写的文本字符串并不少见。例如,如果我们不知道输入的姓氏是“Smith”还是“SMITH”,我们可以像这样进行不区分大小写的搜索(正则表达式后面的“I”指定不区分大小写的匹配):

mongo> var e=db.customers.explain('executionStats')
               .find({LastName:/^SMITH$/i},{}) ;
mongo> mongoTuning.quickExplain(e);
1   IXSCAN LastName_1
2  FETCH

您可能会惊喜地看到使用了一个索引来解析查询——那么也许 MongoDB 索引可以用于不区分大小写的搜索?唉,不尽然。如果我们得到executionStats,我们看到虽然使用了索引,但是它扫描了所有 410,000 个键。是的,索引用于查找匹配的名称,但是必须扫描整个索引。

mongo> var e=db.customers.explain('executionStats')
               .find({LastName:/^SMITH$/i},{}) ;
mongo> mongoTuning.executionStats(e);

1   IXSCAN ( LastName_1 ms:8 keys:410071)
2  FETCH ( ms:8 docs:711)

Totals:  ms: 293  keys: 410071  Docs: 711

如果您想进行不区分大小写的搜索,那么有一个技巧可以使用。首先,创建一个不区分大小写的排序规则序列的索引。这是通过指定强度为 1 或 2 的归类序列来实现的(级别 1 忽略大小写和音调符号——特殊字符,如元音变音等。):

db.customers.createIndex(
  { LastName: 1 },
  { collation: { locale: 'en', strength: 2 } }
);

现在,如果在查询中也指定了相同的排序规则,查询将返回不区分大小写的结果。例如,对“Smith”的查询现在也返回“SMITH ”:

mongo> db.customers.
...   find({ LastName: 'SMITH' }, { LastName: 1,_id:0 }).
...   collation({ locale: 'en', strength: 2 }).
...   limit(1);
{
  "LastName": "Smith"
}

如果我们查看executionStats,我们会看到索引现在只正确地检索符合条件的文档(在本例中,有 700 多个“Smith”和“Smith”):

 mongo> var e = db.customers.
...   explain('executionStats').
...   find({ LastName: 'SMITH' }).
...   collation({ locale: 'en', strength: 2 });
mongo> mongoTuning.executionStats(e);

1   IXSCAN ( LastName_1 ms:0 keys:711)
2  FETCH ( ms:0 docs:711)

Totals:  ms: 2  keys: 711  Docs: 711

复合索引

一个复合索引就是一个包含不止一个属性的索引。复合索引最显著的优势是,它通常比单一关键索引更具选择性。多个属性的组合将指向比由单一属性组成的索引数量更少的文档。包含所有包含在find()$match子句中的属性的复合索引将特别有效。

如果经常查询集合中的多个属性,那么为这些属性创建一个复合索引是一个很好的主意。例如,我们可以通过LastNameFirstName查询customers集合。在这种情况下,我们可能希望创建一个包含LastNameFirstName的复合索引。

使用这样的索引,我们可以快速找到匹配给定的LastNameFirstName组合的所有customers。这样的索引将远比单独在LastName上的索引或者在LastNameFirstName上的单独索引更有效。

如果复合索引只能在所有键都显示为find()$match时使用,那么复合索引的用途可能会非常有限。幸运的是,如果查询中请求了任何一个初始前导属性,那么复合索引就可以有效地使用。前导属性是那些在索引定义中最早指定的属性。

复合索引性能

一般来说,当您向索引添加更多的属性时,您会看到索引性能的提高——前提是这些属性包含在查询过滤条件中。

例如,考虑以下查询:

db.people.find(
      {
        "LastName" : "HENNING",
        "FirstName" : "ALBERTO",
        dateOfBirth: ISODate("1953-12-23T00:00:00Z")
      },
        { _id: 0, Phone: 1 }
      );

我们通过提供FirstNameLastNamedateOfBirth来检索客户电话号码。

5-3 显示了当我们给索引添加属性时,文档访问次数是如何减少的。如果没有索引,我们必须扫描所有 411,121 个文档。仅索引LastName一项就减少到 6918 个文档——实际上是集合中所有的“HENNING”。添加FirstName将文档数量减少到 15 个。通过添加dateOfBirth,,我们减少了两次访问:一次是读取索引条目,从那里,我们读取集合中的文档以获取电话号码。我们最后的优化是将电话号码(“tel”)属性添加到索引中。现在我们根本不需要访问集合——我们需要的一切都在索引中。

img/499970_1_En_5_Fig3_HTML.png

图 5-3

复合索引表现(对数标度)

复合索引键顺序

复合索引的一个优点是,它们可以支持不包含索引中所有键的查询。如果查询中包含一些主要属性,可以使用复合索引。

例如,指定为{LastName:1, FirstName:1, dateOfBirth:1}的索引可以用来优化对单独的LastName或者对LastNameFirstName的查询。然而,当单独针对FirstName或者针对dateOfBirth优化查询时,这将是无效的。为了使索引有用,查询中必须至少出现第一个或前导键中的一个。

Tip

复合索引可用于加速在索引表达式中包含任何或所有前导(第一个)键的查询。但是,它们无法优化索引表达式中至少不包含第一个键的查询。

复合索引指南

以下准则将有助于决定何时使用复合索引,以及如何确定要包含哪些属性以及包含的顺序:

  • 为集合中出现在find()$match条件下的属性创建复合索引。

  • 如果属性有时在find()$match条件中单独出现,那么将它们放在索引的开头。

  • 如果复合索引还支持未指定所有属性的查询,那么它会更有用。例如,createIndex({"LastName":1,"FirstName":1})createIndex({"FirstName":1,"LastName":1})更有用,因为只针对LastName的查询比只针对FirstName的查询更有可能发生。

  • 属性的选择性越强,它在索引的前端就越有用。但是,请注意,WiredTiger 索引压缩可以从根本上收缩索引。当前导列的选择性小于时,索引压缩最有效。这可能意味着这样的索引更小,因此更可能适合内存。我们将在第 11 章中对此进行更多的讨论。

覆盖索引

覆盖索引的是可用于完全解析查询的索引。类似地,可以完全通过索引解决的查询被称为覆盖查询*。*

我们在图 5-3 中看到了一个覆盖索引的例子。使用关于LastNameFirstNamedateOfBirthPhone的索引来解析查询,而无需从集合中检索数据。覆盖索引是优化查询的强大机制。因为索引通常远小于集合,所以不需要将集合中的文档放入内存的查询具有很高的内存和 IO 效率。

索引合并

前面,我们强调了在查询中的所有条件上创建复合索引通常是最有效的。

例如,在如下查询中:

db.iotData.find({a:1,b:1})

我们可能需要一个关于{a:1,b:1}.的索引,但是,如果这个集合有很多属性,并且查询有很多可能的组合,那么创建我们需要的所有复合索引可能是不切实际的。1

但是,如果我们在 a 上有一个索引,在 b 上有另一个索引,MongoDB 可以执行两个索引的交集。最终的计划如下所示:

1    IXSCAN a_1
2    IXSCAN b_1
3   AND_SORTED
4  FETCH

AND_SORTED 步骤表示已经执行了索引交集。

$and条件的索引交叉点不常见。但是,MongoDB 会频繁地为$or条件执行索引合并。例如,在这个查询中:

db.iotData.find({$or:[{a:100},{b:100}]});

默认情况下,MongoDB 会合并这两个索引:

1     IXSCAN a_1
2     IXSCAN b_1
3    OR
4   FETCH
5  SUBPLAN

ORSUBPLAN步骤表示索引合并。

Note

对于$and条件,复合索引优于索引合并。然而,对于一个$or条件,索引合并通常是最好的解决方案。

部分索引和稀疏索引

正如我们将在第 11 章中看到的,当所有数据都保存在内存中时,通常会获得最佳的 MongoDB 性能。然而,对于非常大的集合,MongoDB 可能很难在内存中保存所有的索引。在某些情况下,我们只想使用索引来扫描最近的或活动的信息。在这些场景中,我们可能想要创建一个部分稀疏索引。

部分索引

部分索引是只为信息子集维护的索引。例如,假设我们有一个推文数据库,正在寻找我们账户中转发次数最多的推文:

db.tweets.
  find({ 'user.name': 'Mean Magazine Bot' }, { text: 1 }).
  sort({ retweet_count: -1 }).
  limit(1);

一个关于user.nameretweet_count的索引可以达到这个目的,但是它会是一个相当大的索引。由于大多数推文没有被转发,我们可以只对那些被转发的推文创建部分索引:

db.tweets.createIndex(
  { 'user.name': 1, retweet_count: 1 },
  { partialFilterExpression: { retweet_count: { $gt: 0 } } }
);

当我们寻找从未被转发的推文时,这个索引将是无用的,但假设这不是我们试图做的,部分索引将比完整索引小得多,并且更有效地存储。

注意,为了利用这个索引,我们需要在查询中指定一个过滤条件,确保 MongoDB 知道我们需要的所有数据都在索引中。在我们当前的例子中,我们可以在retweet_count上添加一个条件:

db.tweets.find(
    { 'user.name': 'Mean Magazine Bot',
      retweet_count: { $gt: 0 } },
    { text: 1 }
  ).
  sort({ retweet_count: -1 }).
  limit(1);

稀疏索引

稀疏 索引类似于部分索引,因为它们不索引集合中的所有文档。具体来说,稀疏索引不包括不包含索引属性的文档。

大多数时候,稀疏索引和普通索引一样好,而且可能要小得多。但是,稀疏索引不支持对索引属性进行$exists:true搜索:

mongo>   var exp=db.customers.explain()
            .find({updateFlag:{$exists:false}});
mongo>   mongoTuning.quickExplain(exp);
1  COLLSCAN

但是,稀疏索引可以搜索$exists:true:

mongo> var exp=db.customers.explain()
                  .find({updateFlag:{$exists:true}});
mongo> mongoTuning.quickExplain(exp);
1   IXSCAN updateFlag_1
2  FETCH

使用索引进行排序和连接

索引可用于支持按排序顺序返回数据,也可用于支持多个集合之间的连接。

整理

MongoDB 可以使用索引按排序顺序返回数据。因为每个叶节点都包含到后续叶节点的链接,所以 MongoDB 可以按排序的顺序扫描索引条目,返回数据而不必显式地对数据进行排序。我们将在第 6 章中研究如何使用索引来支持排序。

对联接使用索引

MongoDB 可以在聚合框架中使用$lookup$graphLookup操作符连接多个集合中的数据。对于任何非平凡大小的连接,索引查找应该支持这些连接,以避免随着连接大小的增加而出现索引级下降。该主题在第 7 章中有详细介绍。

索引开销

尽管索引可以极大地提高查询性能,但它们确实会降低插入、更新和删除操作的性能。当插入或删除文档时,通常会修改集合的所有索引,并且当更新改变了出现在索引中的任何属性时,也必须修改索引。插入、更新和删除期间的索引维护通常是 MongoDB 在这些操作中必须完成的大部分工作。

因此,我们所有的索引对查询性能都有贡献是很重要的,因为这些索引会不必要地降低插入、更新和删除的性能。特别是,在对频繁更新的属性创建索引时,应该特别小心。一个文档只能插入或删除一次,但可以更新多次。因此,对频繁更新的属性或具有非常高的插入/删除率的集合进行索引将需要特别高的成本。

在第 8 章中,我们将详细介绍索引开销以及识别可能没有发挥其作用的索引的方法。

通配符索引

通配符索引是一种开销特别大的索引类型。

通配符索引是在子文档的每个属性上创建的索引。举例来说,我们有一些类似这样的数据:

{
 "_id" : 1,
 "data" : {
  "a" : 1728,
  "b" : 6740,
  "c" : 6481,
  "d" : 2066,
  "e" : 3173,
  "f" : 1796,
  "g" : 8112
 }
}

可以针对data子文档中的任何一个属性发出查询。此外,应用可能会添加一些我们无法预料的新属性。为了优化性能,我们需要为每个属性创建一个单独的索引:

db.mycollection.createIndex({"data.a":1});
db.mycollection.createIndex({"data.b":1});
db.mycollection.createIndex({"data.c":1});
db.mycollection.createIndex({"data.d":1});
db.mycollection.createIndex({"data.e":1});
db.mycollection.createIndex({"data.f":1});
db.mycollection.createIndex({"data.g":1});

索引太多!但是即使这样也不行,除非我能确定属性是什么。如果创建了属性“h”会发生什么?

在这种情况下,通配符索引会出手相救。 2

顾名思义,我们可以通过在属性表达式中指定通配符占位符来创建通配符索引,例如:

db.mycollection.createIndex({"data.$**":1});

该语句为data文档中的每个属性创建一个索引:即使新属性是在索引创建后由应用创建的。

太好了!但显然,这是有成本的。让我们来看看通配符索引在 insert、find、update 和 delete 语句中的表现

  • 完全没有索引

  • 单一属性的单一索引

  • 所有属性的独立索引

对于查找操作,我们看到通配符索引的性能与单属性索引一样好——不管我们创建了多少个索引,索引都提供了对相关数据的快速访问。图 5-4 说明了结果。

img/499970_1_En_5_Fig4_HTML.png

图 5-4

通配符索引与查找操作的其他方法

尽管通配符索引与常规索引具有相似的配置文件,但是当我们研究更新、删除和插入操作时,它们具有非常不同的开销。

5-5 显示了当我们有通配符索引、每个属性有单独的索引、单个属性有单个索引或者根本没有索引时,执行插入、更新和删除操作所花费的时间。

img/499970_1_En_5_Fig5_HTML.png

图 5-5

通配符索引与传统索引相比的开销

正如我们所料,我们看到多个索引的开销比单个索引高得多。然而,我们也看到通配符索引带来的开销至少与为每个属性创建单独的索引一样大。

Warning

不要出于懒惰而创建通配符索引。通配符索引的开销很高,只有在没有替代策略时才应该使用它们。

如果一些属性从来没有被搜索过,那么通配符索引将会增加不值得的开销。和往常一样,只创建必要的索引:所有索引都会影响性能,通配符索引更是如此。

通配符索引对您的索引库是一个非常有用的补充。但是,不要把它们仅仅作为编程的捷径:它们会给插入、更新和删除性能带来很大的开销,只有当要索引的属性不可预测时才应该使用它们。

文本索引

在现代应用中,允许用户执行自由形式的项目搜索已经成为标准,比如电影、购物项目或租赁物业的列表。用户不想填写复杂的表单来指定要搜索哪些属性,当然也不想学习 MongoDB find()语法。

要构建这类应用,您可能需要一些搜索词,然后在成千上万个文档中搜索大型文本字段,以找到最佳匹配。这就是文本索引有用的地方。

在调优或创建文本索引时,理解 MongoDB 将如何解释该索引以及该索引将如何影响查询非常重要。

MongoDB 使用一种叫做后缀词干的方法来构建搜索索引。

后缀词干包括在构成搜索树的根的每个单词的开头找到一个公共元素(前缀)。每一个不同的后缀都“派生”到它自己的节点上,这个节点可以进一步派生。这个过程创建了一个树,可以有效地从根(最常见的共享元素)向下搜索到叶节点,从根到叶节点的路径构成了一个完整的单词。

例如,假设我们在文档的某个地方有单词" finderfindingfindable "。通过使用后缀词干,我们可以在这些单词中找到一个共同的词根" find ," er , ingable。

img/499970_1_En_5_Figa_HTML.jpg

MongoDB 使用同样的方法。当您在给定字段上创建文本索引时,MongoDB 将解析该字段中包含的文本,并为给定文档中生成的每个唯一词干术语创建一个索引条目。这将对每个索引字段和每个文档重复,直到该集合中的所有指定字段都有完整的文本索引。

理解这个理论很好,但是有时理解文本索引如何工作的最好方法是开始与它们交互,所以让我们创建一个新的文本索引。该命令非常简单,使用与创建任何其他类型的索引相同的语法。您只需指定要为其创建索引的字段,索引的类型指定为"text":

> db.listingsAndReviews.createIndex({description: "text"})
{
        "createdCollectionAutomatically" : false,
        "numIndexesBefore" : 4,
        "numIndexesAfter" : 5,
        "ok" : 1
}

创建文本索引就像这样简单。与我们的其他索引一样,我们可以在多个属性上创建一个文本索引:

> db.listingsAndReviews.createIndex({summary: "text", space: "text"})
{
        "createdCollectionAutomatically" : false,
        "numIndexesBefore" : 4,
        "numIndexesAfter" : 5,
        "ok" : 1
}

尽管可以在多个索引上创建文本索引,但在任何集合上只能有一个文本索引。因此,如果您一个接一个地运行上面的两个命令,您将会收到一个错误。

Note

每个收藏只能有文本索引。因此,要创建新的文本索引或包含文本索引的复合索引,必须先使用db.collection. dropIndex ("index_name")删除旧索引。

我们还可以创建复合索引,包括文本和传统索引的混合:

> db.listingsAndReviews.createIndex({summary: "text", beds: 1})
{
        "createdCollectionAutomatically" : false,
        "numIndexesBefore" : 4,
        "numIndexesAfter" : 5,
        "ok" : 1
}

创建文本索引时的另一个重要方面是为每个字段指定权重。字段的权重是指该字段相对于其他索引字段的重要性。MongoDB 将在确定返回哪些结果以在$text查询中使用时使用它。创建文本索引时,可以将权重指定为一个选项。

> db.listingsAndReviews.createIndex({summary: "text", description: "text"}, {weights: {summary: 3, description: 2}})
{
        "createdCollectionAutomatically" : false,
        "numIndexesBefore" : 4,
        "numIndexesAfter" : 5,
        "ok" : 1
}

现在我们的集合上有了一个文本索引,我们可以使用$ text操作符来访问它。$text接受一个$search操作符,该操作符接受一个单词列表(通常由空格分隔):

> db.listingsAndReviews.findOne({$text: {$search: "oven kettle and microwave"}}, {summary: 1})
{
        "_id" : "6785160",
        "summary" : "Large home with that includes a bedroom with TV , hanging and shelf space for clothing, comfortable double bed and air conditioning. Additional private sitting room includes sofa, kettle, bar fridge and toaster. Exclusive use of large bathroom with shower, bath, double sinks and toilet. LGBTQI friendly"
}

当使用文本索引来投影在文本搜索期间生成的给定文档的得分时,这通常是有用的。您可以使用显示textScore字段的$ meta projection来完成此操作。您通常也希望对这个投影进行排序,以确保您首先获得最相关的搜索结果。

mongo> db.listingsAndReviews.
...   find(
...     { $text: { $search: 'oven kettle and microwave' } },
...     { score: { $meta: 'textScore' }, summary: 1 }
...   ).
...   sort({ score: { $meta: 'textScore' } }).
...   limit(3);
{
  "_id": "25701117",
  "summary": "Totally refurbished penthouse apartment ...",
  "score": 3.5587606837606836
}
{
  "_id": "13324467",
  "summary": "Everything, absolutely EVERYTHING NEW and ... ",
  "score": 3.5549853372434015
}

搜索文本索引时,您可能希望使用的另外两种重要方法是排除精确匹配。排除项用–符号标记,完全匹配项用双引号标记。例如,查询

> db.listingsAndReviews.find(
      {$text: {$search:
            "\"luggage storage\" kettle and -microwave"}})

将在索引中搜索短语“行李寄存的精确匹配,以及排除短语“微波炉”的文档。“以这种方式使用文本索引非常强大,尤其是在大量文本的数据集上。但是,要记住文本索引有一些限制:

  • 为文本索引指定sparse没有任何效果。文本索引总是稀疏的

  • 如果您的复合索引包含一个文本索引,它不能包含多键地理空间字段。您必须为这些特殊的索引类型创建单独的索引。

  • 正如前面创建文本索引的例子中提到的,每个集合只能创建一个文本索引。额外的文本索引创建将引发错误。

文本索引性能

使用传统索引,您可以使用集合扫描而不是索引来解析查询。但是,如果没有文本索引,就根本无法执行全文搜索。因此,对于全文索引的使用,您没有太多的选择。

但是,您应该记住全文索引的一些性能特征。首先,您应该知道 MongoDB 将对搜索标准中的每个术语执行索引扫描。例如,这里我们搜索五个唯一的单词,并因此执行五次文本索引扫描:

mongo> var exp = db.bigEnron.
...   explain('executionStats').
...   find( { $text: { $search:
      'Confirmation Rooms Credit card tax email ' } },
...   { score: { $meta: 'textScore' }, body: 1 } ).
...   sort({ score: { $meta: 'textScore' } }).
...   limit(3);

mongo> mongoTuning.executionStats(exp);

1        IXSCAN ( body_text ms:229 keys:53068)
2        IXSCAN ( body_text ms:764 keys:217480)
3        IXSCAN ( body_text ms:748 keys:229382)
4        IXSCAN ( body_text ms:1376 keys:398325)
5        IXSCAN ( body_text ms:362 keys:108996)
6        IXSCAN ( body_text ms:181 keys:93970)
7       TEXT_OR ( ms:494636 docs:843437)
8      TEXT_MATCH ( ms:494709)
9     TEXT ( body_text ms:494746)
10    SORT_KEY_GENERATOR ( ms:494795)
11   SORT ( ms:495015)
12  PROJECTION_DEFAULT ( ms:495072)

因此,如图 5-6 所示,我们拥有的搜索词越多,文本搜索所需的时间就越长。

img/499970_1_En_5_Fig6_HTML.png

图 5-6

文本索引性能与搜索词数量的关系

Note

MongoDB 文本索引性能与搜索中的术语数量成正比。必要时,限制搜索词的数量,以保持响应时间可控。

请注意,即使您搜索一个精确的短语,您仍将对短语中的每个单词执行一次扫描,因为索引本身不知道单词是如何按顺序使用的。如果您正在搜索一个很长的精确文本短语,您最好执行正则表达式查询和完全集合扫描。例如,此查询查找文本“你今晚会去看比赛吗”:

mongo> var exp = db.bigEnron.
...   explain('executionStats').
      find( { $text: {
         $search: '"are you going to be at the game tonight"' } });
mongo>   mongoTuning.executionStats(exp);

1      IXSCAN ( body_text ms:354 keys:62838)
2      IXSCAN ( body_text ms:2136 keys:515760)
3      IXSCAN ( body_text ms:146 keys:39721)
4     OR ( ms:2767)
5    FETCH ( ms:379793 docs:563201)
6   TEXT_MATCH ( ms:383409)

7  TEXT ( body_text ms:383517)

Totals:  ms: 414690  keys: 618319  Docs: 563201

MongoDB 执行三次索引扫描(只有单词“game”、“going”和“tonight”被认为值得扫描)。在不到一半的运行时间内完成了完全集合扫描:

mongo> var exp = db.bigEnron.
...   explain('executionStats').
      find({body:/are you going to be at the game tonight/});
mongo>   mongoTuning.executionStats(exp);

1  COLLSCAN ( ms:102289 docs:2897816)

Totals:  ms: 145925  keys: 0  Docs: 2897816

Tip

如果您正在搜索一个精确的短语,您可能最好进行基于集合扫描的常规查询——MongoDB 文本索引不是为高效的多词短语搜索而设计的。

以下是关于文本索引的一些额外的重要性能注意事项:

  • 由于前面描述的词干方法,文本索引可能非常大,并且可能需要很长时间来创建。

  • MongoDB 建议您的系统拥有足够的内存来保存文本索引,因为否则在搜索过程中可能会涉及大量的 IO。

在查询中使用排序时,您将无法利用文本索引来确定顺序,即使在复合文本索引中也是如此。在对文本查询结果进行排序时,请记住这一点。

文本索引非常强大,可以服务于各种各样的现代应用,但是在依赖它们时要小心,否则您会发现您的$text查询变成了一个恼人的性能瓶颈。

MongoDB Atlas 提供了使用流行的 Lucene 平台进行文本搜索的能力。与 MongoDB 的内部文本搜索功能相比,这个工具有很多优点。

地理空间索引

今天的位置感知应用通常需要在地图数据中执行搜索。这些搜索可能包括搜索某个地区的租赁物业,查找附近的场地,甚至按拍摄地点对照片进行分类。如今,无论我们走到哪里,许多设备都在被动捕捉大量的位置数据。这通常被称为地理空间数据:关于地球上位置的数据。

MongoDB 既提供了查询这些数据的方法,也提供了特定的索引类型来优化查询。

以下是一些地理空间数据的示例:

{
        "_id" : ObjectId("578f6fa2df35c7fbdbaed8c4"),
        "recrd" : "",
        "vesslterms" : "",
        "feature_type" : "Wrecks - Visible",
        "chart" : "US,U1,graph,DNC H1409860",
        "latdec" : 9.3547792,
        "londec" : -79.9081268,
        "gp_quality" : "",
        "depth" : "",
        "sounding_type" : "",
        "history" : "",
        "quasou" : "",
        "watlev" : "always dry",
        "coordinates" : [
                -79.9081268,
                9.3547792
        ]
}

数据本身可能非常简单,尽管您可能会将大量元数据与坐标一起存储。前面的数据采用传统格式,数据表示为简单的坐标对。MongoDB 还支持 GeoJSON 格式。

{
        "_id" : ObjectId("578f6fa2df35c7fbdbaed8c4"),
        "recrd" : "",
        "vesslterms" : "",
        "feature_type" : "Wrecks - Visible",
        "chart" : "US,U1,graph,DNC H1409860",
        "latdec" : 9.3547792,
        "londec" : -79.9081268,
        "gp_quality" : "",
        "depth" : "",
        "sounding_type" : "",
        "history" : "",
        "quasou" : "",
        "watlev" : "always dry",
        "location" : {
                "type" : "Point",
                "coordinates" : [
                        -79.9081268,
                        9.3547792
                ]
        }
}

GeoJSON 格式指定了数据类型以及值本身,可以是单个点,也可以是许多坐标对的数组。GeoJSON 允许您定义更复杂的空间信息,如线和面,但出于本章的目的,我们将重点关注传统格式的简单点数据。

下面是一个地理空间查询,可以使用$near操作符在目标点的某个半径范围内查找文档:

> db.shipwrecks.find(
...    {
...      coordinates:
...        { $near :
...           {
...             $geometry: { type: "Point",
                         coordinates: [ -79.908, 9.354 ] },
...             $minDistance: 1000,
...             $maxDistance: 10000
...           }
...        }
...    }
... ).limit(1).pretty();
{
        "_id" : ObjectId("578f6fa2df35c7fbdbaed8c8"),
        "recrd" : "",
        "vesslterms" : "",
        "feature_type" : "Wrecks - Submerged, dangerous",
        "chart" : "US,U1,graph,DNC H1409860",
        "latdec" : 9.3418808,
        "londec" : -79.9103851,
        "gp_quality" : "",
        "depth" : "",
        "sounding_type" : "",
        "history" : "",
        "quasou" : "depth unknown",
        "watlev" : "always under water/submerged",
        "coordinates" : [
                -79.9103851,
                9.3418808
        ]
}

在前面的示例中,此查询的匹配地理空间索引已经存在。在使用$near操作符的情况下,运行查询需要地理空间索引。如果您试图在没有索引的情况下运行这个查询,MongoDB 将返回一个错误:

Error: error: {
        "ok" : 0,
        "errmsg" : "error processing query: ns=sample_geospatial.shipwrecks limit=1Tree: GEONEAR  field=coordinates maxdist=10000 isNearSphere=0\nSort: {}\nProj: {}\n planner returned error :: caused by :: unable to find index for $geoNear query",
        "code" : 291,
        "codeName" : "NoQueryExecutionPlans"
}

事实上,几乎所有的地理空间操作者都需要一个合适的地理空间索引。

在该查询的执行计划中,我们将第一次看到以下阶段—“GEO_NEAR_2DSPHERE”:

mongo> var exp=db.shipwrecks.explain('executionStats').
...   find(
...     {
...       coordinates:
...         { $near :
...            {
...              $geometry: { type: "Point",
                        coordinates: [ -79.908, 9.354 ] },
...              $minDistance: 1000,
...              $maxDistance: 10000
...            }
...         }
...     }
...  ).limit(1);
mongo> mongoTuning.executionStats(exp);

1     IXSCAN ( coordinates_2dsphere ms:0 keys:12)
2    FETCH ( ms:0 docs:0)
3     IXSCAN ( coordinates_2dsphere ms:0 keys:18)
4    FETCH ( ms:0 docs:1)
5   GEO_NEAR_2DSPHERE ( coordinates_2dsphere ms:0)
6  LIMIT ( ms:0)

Totals:  ms: 0  keys: 30  Docs: 1

这表明我们正在使用一个2dsphere索引来帮助我们查询这些地理空间数据。

您可以在 MongoDB 中创建两种不同类型的地理空间索引:

  • 2dsphere :用于索引存在于类似地球的球体上的数据

  • 2d :用于索引像传统地图一样存在于二维平面上的数据

您选择使用哪个索引将取决于数据本身的上下文。选择索引类型时要小心。你可以在球形数据上使用一个2d索引;但是,结果会被扭曲。想想地图上相对两侧的两个点的例子;这两点在球面上可能很近,但在二维平面上可能很远。

要创建地理空间索引,只需指定2dsphere2d索引类型作为值,关键字是包含位置数据的字段,可以是传统坐标数据或 GeoJSON 数据:

> db.shipwrecks.createIndex({"coordinates" : "2dsphere"})

Warning

如果试图在不包含 GeoJSON 对象或坐标对形式的适当数据的字段上创建地理空间索引,MongoDB 将返回错误。因此,在创建这个索引之前,请检查您的数据。

地理空间索引性能

当讨论确保索引提高性能的方法时,地理空间索引是一个例外。因为您必须拥有这些索引(除了$geoWithin操作符之外),它们不一定像允许它们运行那样给查询带来性能提升。这使得提高地理空间查询性能成为一项更具挑战性的任务,而不是创建或调整匹配索引;关于地理空间索引,您可以考虑以下几个方面:

  • 与其他地理空间操作符不同,$geoWithin可以在没有地理空间索引的情况下使用。添加匹配索引是提高$geoWithin性能最简单的方法。

  • $near$nearSphere将自动按照距离(从最近到最远)对结果进行排序,所以如果您在查询中添加一个sort()操作,那么最初的排序就被浪费了。如果您计划对结果进行排序,您可以通过使用$geoWithin$geoNear聚合阶段来提高性能,这不会自动对结果进行排序。

  • 当使用$near$nearSphere$geoNear操作符时,尽可能利用minDistancemaxDistance参数。这将限制 MongoDB 检查的文档数量。对于附近有许多数据点的查询,这可能不会影响性能。然而,如果附近没有匹配的值,在maxDistance中的查询可能会搜索整个世界!

地理空间元数据正被添加到越来越多的数据中,从图像到浏览器日志。越来越有可能在生产数据集中的某个地方,您可能有一些地理空间数据。与其他索引类型一样,您仍然应该考虑维护索引的开销是否值得提高性能。如果您不希望应用查询地理空间数据,那么地理空间索引可能没有好处。

地理空间索引限制

对于2dsphere2d两种索引类型,不可能创建一个覆盖的查询。由于地理空间操作符的性质,必须检查文档以满足查询,所以不要期望仅仅通过创建地理空间索引来创建覆盖的查询。

此外,当使用分片的集合时(将在第 14 章第 14 章中介绍),地理空间索引不能用作分片键,您将无法通过 GeoJSON 或坐标数据进行分片。但是,如果您希望在一个分片集合上有一个地理空间索引,您仍然可以创建它,因为分片键引用了一个不同于索引的字段。同样值得注意的是,和文本索引一样,2d2dsphere索引总是稀疏的

2d索引类型不能用于更高级的 GeoJSON 数据;只有支持传统坐标对。

MongoDB 允许在一个集合上创建多个地理空间索引。但是,创建后续地理空间索引时要小心,因为这将影响地理空间聚合的行为,甚至可能破坏现有的应用代码。例如,如果使用$geoNear聚合管道阶段的查询存在多个地理空间索引,则必须指定您希望使用的键。如果集合中存在多个2dsphere2d索引,并且没有指定键,那么聚合将无法确定使用哪个索引,从而导致聚合失败。

Note

如果您最多有一个2d索引和一个2dsphere索引,您将不会收到错误。相反,查询将尝试使用2d索引,如果它存在的话;如果没有找到2d索引,它将尝试使用2dsphere索引。

实际上,不太可能在一个集合中创建许多不同的地理空间索引。和往常一样,在创建索引之前,请仔细考虑您可能会遇到哪些查询。

摘要

在这一章中,我们学习了什么是索引,它们是如何工作的,以及为什么它们是至关重要的。很多时候,正确地识别和创建与查询匹配的索引会给你带来最大的“性价比”,从而提高性能。此外,我们还学习了一些更具体的索引来帮助地理空间或文本查询。

然而,正如我们在本章中了解到的,索引并不是解决所有性能问题的通用创可贴。在某些情况下,使用不当的索引会降低性能。在决定实现哪种索引之前,考虑来自应用或用户的预期负载和数据结构是至关重要的。

索引可能是您提高 MongoDB 性能的最健壮的方法之一,但是在创建它们的时候不要偷懒;花一点时间在正确的索引上可以节省你很多时间来调整曲目。

Footnotes [1](#Fn1_source)

这可能是我们在第 4 章中讨论的“属性”模式的工作。

  2

这也是在第 4 章中介绍的属性模式可能被指出的情况。

 

六、查询调优

在几乎所有的应用中,大部分数据库时间都花在数据检索上。一个文档只能被插入或删除一次,但在两次更新之间通常会被多次读取,即使是更新也必须在执行其工作之前检索数据。因此,我们大部分的 MongoDB 调优工作都集中在查找数据上,特别是find()语句,它是 MongoDB 数据检索的主力。

缓存结果

回到 Guy 主要处理基于 SQL 的数据库的黑暗日子,一位智者曾经告诉他“最快的 SQL 语句是你永远不会发送到数据库的语句。”换句话说,如果可以避免,就不要向数据库发送请求。即使是最简单的请求也需要一次网络往返,可能还需要一次 IO——所以除非万不得已,否则不要与数据库交互。

这个原则同样适用于 MongoDB。我们经常不止一次地向数据库请求相同的信息——即使我们知道信息没有改变。

例如,考虑下面的简单函数:

function recordView(customerId,filmId) {
  let filmTitle=db.films.findOne({_id:filmId},{Title:1}).Title;
  db.customers.update({_id:customerId},
      {$push:{views:{filmId,title:filmTitle,
                     viewDate:new ISODate()}}});
}

我们在电影收藏中查找电影名称——很公平。但是电影的名字从来不会变,而且在任何一天,有些电影都会被看很多遍。那么,为什么要回到数据库去获取我们已经处理过的电影的片名呢?

这个公认更复杂的代码将电影标题缓存在本地内存中。我们再也不会向数据库查询电影片名了:

var cacheDemo={};
cacheDemo.filmCache={};

cacheDemo.getFilmId=function(filmId) {
  if (filmId in cacheDemo.filmCache) {
    return(cacheDemo.filmCache[filmId]);
  }
  else
    {
      let filmTitle=db.films.findOne({_id:filmId},
                        {Title:1}).Title;
      cacheDemo.filmCache[filmId]=filmTitle;
      return(filmTitle);
    }
};

cacheDemo.recordView= function(customerId,filmId) {
  let filmTitle=cacheDemo.getFilmId(filmId);
  db.customers.update({_id:customerId},
                    {$push:{views:{filmId,title:filmTitle,
                              viewDate:new ISODate()}}});
}

缓存的实现要快得多。图 6-1 显示了在随机输入的情况下执行每个功能 1000 次所用的时间。

img/499970_1_En_6_Fig1_HTML.png

图 6-1

简单缓存带来的性能提升

缓存特别适合包含静态“查找”值的小型、频繁访问的集合。

以下是实现缓存时需要记住的一些注意事项:

  • 缓存消耗客户端程序的内存。在许多环境中,内存是充足的,考虑缓存的表相对较小。但是,对于大型集合和内存受限的环境,缓存策略的实现可能会导致应用层或客户端内存不足,从而降低性能。

  • 当缓存相对较小时,顺序扫描(即从第一个条目到最后一个条目检查缓存中的每个条目)可能会产生足够的性能。但是,如果缓存较大,顺序扫描可能会降低性能。为了保持良好的性能,可能有必要实现高级搜索技术,如哈希或二进制斩波。在我们前面的例子中,高速缓存实际上是通过电影 ID 索引的,因此,无论涉及多少部电影,高速缓存都将保持高效。

  • 如果正在缓存的集合在程序执行期间被更新,那么这些更改可能不会反映在您的缓存中,除非您实现一些复杂的同步机制。因此,本地缓存最好在静态集合上执行。

Tip

缓存中小型静态集合中频繁访问的数据对于提高程序性能非常有效。但是,要注意内存利用和程序复杂性问题。

优化网络往返

数据库通常是应用中最慢的部分,原因之一是它们必须通过网络链接移动数据。每次应用从数据库中访问一些数据时,这些数据都必须通过网络传输。在极端情况下(比如当你的数据库在另一个大洲的云服务器上时),这个距离可能是几千英里。

网络传输需要时间——通常比 CPU 周期花费的时间要多得多。因此,减少网络传输——或网络往返——是减少查询时间的基础。

我们喜欢把网络传输想象成过河的划艇。我们有一定数量的人在河的一边,我们想让他们用船渡到对岸。每次渡河时,我们能让船上的人越多,往返的次数就越少,我们就能越快让他们全部渡河。如果人代表文档,船代表单个网络包,那么同样的逻辑适用于数据库网络流量:我们的目标是将最大数量的文档打包到每个网络包中。

有两种将文档打包成网络包的基本方法:

  • 通过使每个文档尽可能小

  • 通过确保网络数据包没有空白空间

预测

投影允许我们指定应该包含在查询结果中的属性。MongoDB 程序员通常不会费心指定投影,因为应用通常会丢弃不需要的数据。但是对网络往返的影响可能是巨大的。考虑以下查询:

db.customers.find().forEach((customer)=>{
    if (customer.LastName in results )
      results[customer.LastName]++;
    else
      results[customer.LastName]=1;
});

我们正在统计顾客的姓氏。注意,我们使用的来自customers集合的唯一属性是LastName。因此,我们可以添加一个投影,以确保结果中只包含姓氏:

db.customers.find({},{LastName:1,_id:0}).forEach((customer)=>{
    if (customer.LastName in results )
      results[customer.LastName]++;
    else
      results[customer.LastName]=1;
});

在慢速网络上,性能差异是惊人的-投影将吞吐量提高了 10 倍。即使我们在与数据库服务器相同的主机上运行查询(因此减少了往返时间),性能差异仍然很大。图 6-2 展示了通过增加一个投影所获得的性能提升。

img/499970_1_En_6_Fig2_HTML.png

图 6-2

使用投影减少网络开销

Tip

每当获取批量数据时,在find()操作中包含投影。预测减少了 MongoDB 需要通过网络传输的数据量,因此可以减少网络往返。

成批处理

MongoDB 自动管理响应查询的每个网络包中包含的文档数量。批处理被限制为 16MB 的 BSON 文档大小,但是因为网络数据包比这个小得多,所以这个限制通常不重要。然而,默认情况下,MongoDB 在初始批次中只返回 101 个文档,这意味着有时数据可能会被分成两个网络传输,而一个网络传输就足够了。

当使用游标检索数据时,可以使用batchSize子句指定每个操作中提取的行数。例如,下面我们有一个游标,其中变量batchSize控制每个网络请求中从 MongoDB 数据库检索的文档数量:

  var myCursor=db.millions.find({},{n:1,_id:0})
                          .batchSize(batchsize);
  while (myCursor.hasNext()) {
    myCursor.next();
   count+=1;
  }

注意,batchSize操作符实际上并不改变返回给程序的数据量——它只是控制每次网络往返中检索到的文档数量。从你的程序的角度来看,这一切都发生在“幕后”。

修改batchSize的有效性很大程度上取决于底层驱动程序的实现。在 MongoDB shell 中,默认的batchSize已经被设置得尽可能高了。但是,在 NodeJS 驱动程序中,batchSize被设置为默认值 1000。因此,在 NodeJS 程序中调整batchSize可能会提高性能。

在图 6-3 中,我们看到了使用 NodeJS 驱动程序为从远程数据库中检索行的查询操作batchSize的效果。低于 1000 的batchSize设置会使性能变差——有时甚至更差!但是大于 1000 的设置确实提高了性能。

img/499970_1_En_6_Fig3_HTML.png

图 6-3

更改batchSize对 NodeJS 中查询性能的影响

请注意,如果您使用 MongoDB shell 重复这个实验,您将不会看到随着您增加batchSize而带来的性能提升。每个驱动程序和客户端实现batchSize都有些不同。节点驱动程序使用默认大小 1000,而 Mongo shell 使用更高的值。

Warning

调整batchSize很可能会降低性能,而不是提高性能。只有当您通过慢速网络拉取大量小文档时,才增加batchSize,并始终进行测试以确保您获得了性能提升。

在代码中避免过多的网络往返

batchSize()帮助我们在 MongoDB 驱动中透明地减少网络开销。但是有时优化网络往返的唯一方法是调整应用逻辑。例如,考虑以下逻辑:

for (i = 1; i < max; i++) {
    //console.log(i);
    if ((i % 100) == 0) {
        cursor = useDb.collection(mycollection).find({
            _id: i
        });
        const doc = await cursor.next();
        counter++;
    }
}

我们从 MongoDB 集合中取出每一百个文档。如果收集量很大,那么将会有很多网络往返。此外,这些请求中的每一个都将通过索引查找来满足,并且所有这些索引查找的总和将会很高。

或者,我们可以在一次操作中获取整个集合,然后提取我们想要的文档。

const cursor = useDb.collection(mycollection).find()
                    .batchSize(10000);
for (let doc = await cursor.next();
         doc != null;
         doc = await cursor.next()) {
    if (doc._id % divisor === 0) {
        counter++;
    }
}

直觉上,你可能认为第二种方法需要更长的时间。毕竟,我们现在要从 MongoDB 中检索 100 多倍的文档,对吗?但是因为光标在每一批数以千计的文档中移动(在引擎盖下),第二种方法实际上对网络的占用要少得多。如果数据库位于慢速网络上,那么第二种方法会快得多。

在图 6-4 中,我们看到了本地服务器(例如,Guy 的笔记本电脑)与远程(Atlas)服务器的两种方法的性能。当 Mongo 服务器在 Guy 的笔记本电脑上时,第一种方法要快一点。但是当服务器是远程的时候,在一次操作中获取所有数据要快得多。

img/499970_1_En_6_Fig4_HTML.png

图 6-4

在客户端代码中优化网络往返

批量插入

正如我们希望批量从 MongoDB 中取出数据一样,我们也希望批量插入数据——至少在我们有大量数据要插入的情况下。虽然优化原理是一样的,但是实现却大不相同。由于 MongoDB 服务器或驱动程序不可能知道您将要插入多少个文档,所以由您来组织您的代码以显式地批量插入。我们将在第 8 章中探讨批量插入的原则和实践。

应用架构

还记得我们对划艇和河流的类比吗?确保划艇满载是我们减少过河次数的方法。但是,河的宽度是我们平时控制不了的。但是在应用中,我们必须移动的距离是我们可以控制的。应用服务器和数据库服务器之间的“距离”是决定每次网络往返所用时间的主要因素。

因此,应用代码离数据库服务器越近,消耗在网络开销上的时间就越少。只要有可能,您应该努力将应用服务器与数据库服务器放在同一个数据中心,甚至放在同一个网络机架上。

Tip

让您的应用代码尽可能靠近数据库服务器。两者之间的距离越远,数据库请求的平均网络延迟就越高。

当我们利用基于云的 MongoDB Atlas 服务器时,优化应用代码的位置可能会有问题。然而,我们确实对 Atlas 数据库的位置有很多控制,我们将在第 13 章中详细讨论这一点。

选择索引还是扫描

到目前为止,我们已经了解了如何减少网络流量消耗的时间。现在让我们看看如何减少 MongoDB 服务器本身所需的工作量。

我们拥有的用于查询调优的最重要的工具是索引。第 5 章专门讨论索引,我们在那一章花了很多时间学习如何创建最好的索引。

但是,索引可能并不总是查询的最佳选择。

如果你正在阅读一整本书,你不会先跳到索引,然后在每个索引条目和它所指的书的章节之间切换。那将是愚蠢的,而且极其浪费时间。你从第一页开始读一本书,然后按顺序读后面的几页。如果你想在一本书里找到一个特定的条目,这时你就要使用索引。

同样的逻辑也适用于 MongoDB 查询——如果您正在读取整个集合,那么您不希望使用索引。如果您正在阅读少量的文档,那么索引是首选。但是在什么情况下索引会比集合扫描更有效呢?例如,如果我正在阅读一半的收藏,我应该使用索引吗?

不幸的是,答案是视情况而定。影响索引检索的“盈亏平衡点”的一些因素有

  • 缓存效果:索引检索在 WiredTiger 缓存中往往会获得非常好的命中率,而全收集扫描通常会获得很差的命中率。但是,如果所有集合都在缓存中,那么集合扫描的执行速度将接近索引速度。

  • 文档大小:大多数情况下,文档将在单个 IO 中被检索,因此文档的大小对索引性能没有太大影响。但是,较大的文档意味着较大的集合,这将增加集合扫描所需的 IO 量。

  • 数据分布:如果集合中的文档按照索引属性的顺序存储(如果文档按照键的顺序插入,就会发生这种情况),那么索引可能需要访问更少的块来检索给定键值的所有文档,从而获得更高的命中率。按排序顺序存储的数据有时被称为高度聚集的

6-5 显示了聚集数据和非聚集数据的索引扫描和集合扫描所用的时间,相对于正在检索的集合的百分比绘制。在一个测试中,数据按排序顺序加载到集合中,有利于索引查找。在另一项测试中,数据实际上是随机排列的。

img/499970_1_En_6_Fig5_HTML.png

图 6-5

索引和集合扫描性能相对于被访问的集合百分比绘制(对数标度)

对于随机分布的数据,如果检索到超过 8%的集合,则集合扫描比索引扫描完成得更快。但是,如果数据是高度聚集的,索引扫描的性能会超过集合扫描,达到 95%的水平。

尽管不可能为索引检索指定一个“一刀切”的截止点,但以下陈述通常是有效的:

  • 如果需要访问集合中的所有文档或大部分文档,那么全集合扫描将是最快的访问路径。

  • 如果要从一个大集合中检索单个文档,那么基于该属性的索引将提供更快的检索路径。

  • 这两个极端之间,可能很难预测哪条访问路径会更快。

Note

对于索引扫描访问和集合扫描访问,不存在“一刀切”的平衡点。如果只有几个文档被访问,那么索引将是首选。如果几乎所有的文档都被访问,那么全集合扫描将是首选。在这两个极端之间,你的“里程”会有所不同。

用提示覆盖优化器

在决定最佳访问路径时,MongoDB 优化器使用启发式规则和“实验”的组合。在为特定的查询“形状”确定一个计划之前,它通常会尝试一些不同的计划。然而,当索引存在时,优化器偏向于使用索引。例如,以下查询检索集合中的每个文档,因为没有出生于 19 世纪的客户!然而,尽管所有的文档都被检索,MongoDB 还是选择了一个索引路径。

mongo> var exp=db.customers.explain('executionStats').
           find({dateOfBirth:{
                $gt:new Date("1900-01-01T00:00:00.000Z")}});
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( dateOfBirth_1 ms:16 keys:411121)
2  FETCH ( ms:53 docs:411121)

Totals:  ms: 805  keys: 411121  Docs: 411121

执行计划显示,IXSCAN 步骤检索集合的所有 411,121 行:在这种情况下使用索引并不理想。

我们可以通过添加一个提示来改变 force 这个查询使用集合扫描。如果我们追加. hint ({$ natural :1}),我们指示 MongoDB 执行集合扫描来解析查询:

mongo> var exp=db.customers.explain('executionStats').
...   find({dateOfBirth:{
            $gt:new Date("1900-01-01T00:00:00.000Z")}}).
...   hint({$natural:1});
mongo> mongoTuning.executionStats(exp);

1  COLLSCAN ( ms:16 docs:411121)

Totals:  ms: 383  keys: 0  Docs: 411121

我们还可以使用一个提示来指定我们希望 MongoDB 使用的索引。例如,在这个查询中,我们看到 MongoDB 选择了一个关于国家的索引:

mongo> var exp=db.customers.explain('executionStats').
...   find({Country:'India',
        dateOfBirth:{$gt:new Date("1990-01-01T00:00:00.000Z") }});

mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( Country_1 ms:0 keys:41180)
2  FETCH ( ms:7 docs:41180)

Totals:  ms: 78  keys: 41180  Docs: 41180

如果我们认为 MongoDB 选择了错误的索引,那么我们可以在提示中指定希望 MongoDB 使用的索引键。这里,我们在dateOfBirth上强制使用一个索引:

mongo> var exp=db.customers.explain('executionStats').
...   find({Country:'India',
           dateOfBirth:{$gt:new Date("1990-01-01T00:00:00.000Z") }}).hint({dateOfBirth:1});

mongo>
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( dateOfBirth_1 ms:6 keys:63921)
2  FETCH ( ms:13 docs:63921)

Totals:  ms: 143  keys: 63921  Docs: 63921

在应用代码中使用提示不是最佳做法。一个提示可能会阻止查询利用添加到数据库中的新索引,并且可能会阻止 MongoDB 在引入新版本的服务器时引入的优化。但是,如果所有其他方法都失败了,提示可能是强制 MongoDB 使用正确索引或强制 MongoDB 使用集合扫描的唯一方法。

Warning

考虑在查询中使用提示作为最后的手段。一个提示可能会阻止 MongoDB 利用新索引或响应数据分布的变化。

优化排序操作

如果一个查询包含一个排序指令,而排序后的属性上没有索引,那么 MongoDB 必须获取所有数据,然后在内存中对结果数据进行排序。在对所有行进行排序之前,查询中的第一行无法返回——因为在对所有文档进行排序之前,我们无法识别排序结果中的第一个文档。因此,非索引排序通常被称为阻塞排序

如果您需要整个数据集的排序,那么块排序实际上可能比索引排序更快。但是,使用索引几乎可以立即获得前几个文档,而且在许多应用中,用户希望快速看到排序数据的第一页,而可能永远不会翻阅整个集合。在这些情况下,索引排序是非常理想的。

此外,如果内存不足,阻塞排序将会失败。对于阻塞排序 1 ,您可能会得到这样的错误:

Executor error during find command: OperationFailed: Sort operation used more than the maximum 33554432 bytes of RAM. Add an index, or specify a smaller limit.

指定了sort()选项并执行阻塞排序的find()操作将显示执行计划中的SORT_KEY_GENERATOR步骤,随后是SORT步骤:

mongo> var plan=db.customers.explain()
                  .find().sort({dateOfBirth:1});
mongo> mongoTuning.quickExplain(plan);

1    COLLSCAN
2   SORT_KEY_GENERATOR
3  SORT

如果我们根据排序标准创建一个索引,那么我们只会看到一个 IXSCAN 和 FETCH:

mongo> var plan=db.customers.explain()
            .find().sort({dateOfBirth:1});
mongo> mongoTuning.quickExplain(plan);

1   IXSCAN dateOfBirth_1
2  FETCH

如果我们有一个先执行过滤然后执行排序的查询,那么我们将需要在过滤条件和排序条件上都有一个索引——按照这个顺序。

例如,如果我们有这样一个查询:

Mongo> db.customers.find({Country:'Japan'})
            .sort({dateOfBirth:1});

最初,我们可能会很高兴看到该计划使用该索引得到解决:

mongo> var plan=db.customers.explain()
      .find({Country:'Japan'}).sort({dateOfBirth:1});

mongo> mongoTuning.quickExplain(plan);

1   IXSCAN dateOfBirth_1
2  FETCH

然而,该索引仅支持排序。如果我们希望索引支持排序和查询过滤器,那么我们需要创建一个这样的索引:

db.customers.createIndex({Country:1,dateOfBirth:1});

Tip

要创建同时支持筛选和排序的索引,首先创建带有筛选条件的索引,然后创建排序属性。

使用索引以特定顺序返回文档并不总是最佳选择。如果你正在寻找前个文档,那么索引会比分块排序更好。但是,如果您需要所有按排序顺序返回的文档,那么阻塞排序可能更好。

6-6 显示了索引如何从根本上减少检索第一个排序文档的响应时间,但实际上降低了获取集合中最后一个排序文档所需的时间。

img/499970_1_En_6_Fig6_HTML.png

图 6-6

检索所有文档或仅检索第一个文档时,索引对排序的影响(注意对数标度)

Tip

如果您只对排序中的前几个文档感兴趣,使用索引来优化排序是一个好策略。当您需要按排序顺序返回所有文档时,分块(非索引)排序通常会更快。

如果要对大量数据进行分块排序,可能需要为排序分配更多的内存。您可以通过调整内部参数internalQueryExecMaxBlockingSortBytes来实现。例如,要将排序内存大小设置为 100MB,可以发出以下命令:

db.getSiblingDB("admin").
   runCommand({ setParameter: 1, internalQueryExecMaxBlockingSortBytes: 1001048576 });

但是要注意,增加这个限制将允许 MongoDB 将那么多的额外数据加载到内存中,从而利用更多的系统资源。如果服务器没有足够的可用内存,查询本身也可能需要更长的时间来执行。这将在第 11 章中进一步讨论。

挑选或创建正确的索引

正如我们在上一章和本章前面所看到的,可能最有效的查询优化工具是索引。当查看一个查询时——至少是一个没有获取全部或大部分集合的查询——我们的第一个问题通常是“我有支持这个查询的正确索引吗?”

正如我们所见,索引可以对查询执行三个级别的优化:

  1. 索引可以快速定位符合过滤条件的匹配文档。

  2. 索引可以避免阻塞排序。

  3. 覆盖索引的可以解析一个查询,而根本不涉及任何集合访问。

因此,任何查询的理想索引应该是

  1. 包括过滤条件的所有属性

  2. 然后包括sort()标准的属性

  3. 然后——可选——投影子句中的所有属性

当然,在一个投影中添加所有属性只有在只有几个属性被投影的情况下才是可行的。

Tip

一个完美的查询索引将包含来自过滤条件的所有属性、来自任何排序条件的所有属性,以及(如果可行的话)包含在查询投影中的属性。

如果您有这样一个完美的索引,您将在执行计划中看到一个IXSCAN后跟PROJECTION_COVERED。下面是一个包含索引支持排序的完全覆盖查询的示例:

mongo>db.customers.createIndex(
      {Country:1,'views.title':1,LastName:1,Phone:1},
      {name:'CntTitleLastPhone_ix'});

mongo> var exp = db.customers.
...   explain('executionStats').
...   find(
...     { Country: 'Japan', 'views.title': 'MUSKETEERS WAIT' },
...     { Phone: 1, _id: 0 }
...   ).
...   sort({ LastName: 1 });

mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( CntTitleLastPhone_ix ms:0 keys:770)
2  PROJECTION_COVERED ( ms:0)

在下面的例子中,查询中没有指定投影,所以我们不能期望看到PROJECTION_COVERED。相反,我们有一个FETCH操作——但是请注意,FETCH中处理的行数与IXSCAN中的文档数完全相同——这表明索引检索到了我们需要的所有文档。

mongo> var exp = db.customers.
...   explain('executionStats').
...   find(
...     { Country: 'Japan', 'views.title': 'MUSKETEERS WAIT' }
...   ).
...   sort({ LastName: 1 });
mongo>
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( CntTitleLastPhone_ix ms:0 keys:770)
2  FETCH ( ms:0 docs:770)

Totals:  ms: 3  keys: 770  Docs: 770

Tip

如果在FETCH步骤中处理的文档数量与在IXSCAN,步骤中处理的文档数量相同,则索引成功检索到所有需要的文档。

过滤策略

在这一节中,我们将讨论一些特定过滤场景的策略,比如那些涉及“不等于”和范围查询的场景。

不等于条件

有时,您会发布基于$ne(不等于)条件的过滤条件。最初,您可能会高兴地发现 MongoDB 将使用索引来解决这类查询。例如,在下面的查询中,我们检索除来自“Eric Bass”的电子邮件之外的所有电子邮件:

mongo> var exp = db.enron_messages.
...   explain('executionStats').
...   find({ 'headers.From': { $ne: 'eric.bass@enron.com' } });

mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( headers.From_1 ms:251 keys:481269)
2  FETCH ( ms:4863 docs:481268)

Totals:  ms: 6432  keys: 481269  Docs: 481268

MongoDB 可以使用索引来满足不等于条件。如果我们查看原始的执行计划,我们可以看到 MongoDB 是如何使用索引的。indexBounds部分显示,我们从最低键值扫描到所需的值,然后从该值扫描到索引中的最大键值。

mongo> exp.queryPlanner.winningPlan;
{
  "stage": "FETCH",
  "inputStage": {
    "stage": "IXSCAN",
    "keyPattern": {
      "headers.From": 1
    },
    "indexName": "headers.From_1",
    . . .
    "direction": "forward",
    "indexBounds": {
      "headers.From": [
        "[MinKey, \"eric.bass@enron.com\")",
        "(\"eric.bass@enron.com\", MaxKey]"
      ]
    }
  }
}

如果不等于条件匹配集合的一小部分,这种“不等于”索引扫描可能是有效的,但如果不匹配,那么我们可能会使用索引来检索集合的大部分。正如我们之前看到的,这可能是非常无效的。事实上,对于我们刚刚检查的查询,我们最好进行集合扫描:

mongo> var exp = db.enron_messages.
...   explain('executionStats').
...   find({'headers.From': {$ne:'eric.bass@enron.com'}}).
...   hint({ $natural: 1 });
mongo> var exp = exp.next();

mongo> mongoTuning.executionStats(exp);

1  COLLSCAN ( ms:9 docs:481908)

Totals:  ms: 377  keys: 0  Docs: 481908

6-7 比较了不存在索引扫描和集合扫描的性能。请记住,您的结果将取决于不等于值在您的集合中出现的频率。但是,您可能经常会发现,MongoDB 选择了一个索引,而集合扫描是首选。

img/499970_1_En_6_Fig7_HTML.png

图 6-7

有时,索引扫描可能比集合扫描差得多

Hint

当心支持索引的$ne查询。它们解析为多个索引范围扫描,这可能不如集合扫描有效。

范围查询

我们之前看到了如何通过索引范围扫描解决$ne条件。B 树索引就是为了支持这种扫描而设计的,只要有可能,MongoDB 就会乐意使用这种索引扫描。但是,如果范围覆盖了索引中的大部分数据,这可能不是最佳解决方案。

在下面的例子中,iotData集合有 1,000,000 个文档,属性“a”取 0 到 1000 之间的值。即使我们构建了一个查找所有文档的范围查询,MongoDB 也会默认使用一个索引:

mongo> var exp=db.iotData.explain('executionStats').
      find({a:{$gt:0}});
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( a_1 ms:83 keys:1000000)
2  FETCH ( ms:193 docs:1000000)

Totals:  ms: 2197  keys: 1000000  Docs: 1000000

当扫描如此广泛的范围时,我们最好使用集合扫描:

mongo> var exp=db.iotData.explain('executionStats').
      find({a:{$gt:990}}).hint({$natural:1});
mongo> mongoTuning.executionStats(exp);

1  COLLSCAN ( ms:1 docs:1000000)

Totals:  ms: 465  keys: 0  Docs: 1000000

但是,如果该范围包含的值较少,则索引是最佳选择:

mongo> var exp=db.iotData.explain('executionStats').
      find({a:{$gt:990}});
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( a_1 ms:0 keys:10434)
2  FETCH ( ms:1 docs:10434)

Totals:  ms: 23  keys: 10434  Docs: 10434

6-8 说明了这些结果。当范围扫描覆盖所有或大部分数据时,集合扫描将比索引扫描快。但是,对于狭窄范围的数据,索引扫描更优越。

img/499970_1_En_6_Fig8_HTML.png

图 6-8

索引范围扫描性能

Tip

仅对相对较窄范围的收集数据扫描使用索引。如果集合的大部分正在被访问,请使用集合扫描。

在运营中

针对单个索引属性的$or查询将以与$in查询相同的方式解析。例如,这两个查询实际上是等价的:

db.enron_messages.
  find({ 'headers.To': { $in: ['ebass@enron.com',
                               'eric.bass@enron.com']
  } });

db.enron_messages.find({
  $or: [
    { 'headers.To': 'ebass@enron.com' },
    { 'headers.To': 'eric.bass@enron.com' }
  ]
});

然而,当一个$or条件引用多个属性时,事情就变得更有趣了。如果所有条件都被索引,那么 MongoDB 通常会对每个相关的索引执行索引扫描,然后合并结果:

mongo> var exp=db.enron_messages.explain('executionStats').
   find({
...   $or: [
...     { 'headers.To': 'eric.bass@enron.com' },
...     { 'headers.From': 'eric.bass@enron.com' }
...   ]
... });
mongo> mongoTuning.executionStats(exp);

1     IXSCAN ( headers.From_1 ms:0 keys:640)
2     IXSCAN ( headers.To_1 ms:0 keys:832)
3    OR ( ms:0)
4   FETCH ( ms:0 docs:1472)
5  SUBPLAN ( ms:0)

Totals:  ms: 3  keys: 1472  Docs: 1472

MongoDB 从两次索引扫描中检索数据,然后在执行计划的OR阶段组合它们(消除重复)。

然而,这只有在所有属性都被索引的情况下才有效。如果我们向$or添加一个未索引的条件,MongoDB 将恢复到集合扫描:

mongo> var exp=db.enron_messages.explain('executionStats').
      find({
...   $or: [
...     { 'headers.To': 'eric.bass@enron.com' },
...     { 'headers.From': 'eric.bass@enron.com' },
...     {"X-To": "EBASS@ENRON.COM"}
...   ]
... });

mongo> mongoTuning.executionStats(exp);

1   COLLSCAN ( ms:69 docs:481908)
2  SUBPLAN ( ms:69)

Totals:  ms: 873  keys: 0  Docs: 481908

Tip

要完全优化一个$or查询,需要索引$or 数组中的所有属性。

$nor操作符返回不满足任何一个条件的文档,通常不会利用索引。

数组查询

MongoDB 提供了针对数组元素的丰富查询操作,这些操作能够通过索引有效地解析。例如,以下查询查找发给 Jim Schwieger 和 Thomas Martin2的电子邮件:

mongo> var exp = db.enron_messages.explain('executionStats').find({
...   'headers.To': {
...     $eq: ['jim.schwieger@enron.com',
              'thomas.martin@enron.com']
...   }
... });
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( headers.To_1 ms:0 keys:2130)
2  FETCH ( ms:1 docs:2128)
Totals:  ms: 10  keys: 2130  Docs: 2128

相同的索引可以支持该查询,该查询查找 Thomas 和 Jim 是收件人的所有电子邮件,包括具有其他收件人的电子邮件:

mongo> var exp = db.enron_messages.
...   find({
...     'headers.To': {
...       $all: ['jim.schwieger@enron.com',
           'thomas.martin@enron.com']
...     }
...   }).
...   explain('executionStats');
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( headers.To_1 ms:0 keys:2128)
2  FETCH ( ms:1 docs:2128)

Totals:  ms: 11  keys: 2128  Docs: 2128

同一个索引可以支持 elemMatch查询。然而,elemMatch** 查询。然而, **size 操作符查找具有特定数量元素的数组,并不能从数组的索引中获益:

mongo> var exp = db.enron_messages.
...   explain('executionStats').
...   find({
...     'headers.To': { $size: 1 }});
mongo> mongoTuning.executionStats(exp);

1  COLLSCAN ( ms:788 docs:481908)

Note

MongoDB 索引可以用来搜索数组的元素。

正则表达式

正则表达式允许我们对字符串执行高级匹配。例如,以下查询使用正则表达式来查找姓氏中包含字符串“HARRIS”的客户:

mongo> var exp=db.customers.explain('executionStats').
      find({LastName:/HARRIS/});
mongo>
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( LastName_1 ms:9 keys:410071)
2  FETCH ( ms:12 docs:1365)

Totals:  ms: 273  keys: 410071  Docs: 1365

虽然这个查询很有用,但是效率不高。我们实际上扫描了所有 410,000 个索引条目,因为正则表达式理论上可以包含姓氏,如“MACHARRISON”。如果我们实际上要做的是只匹配以 HARRIS 开头的名字(比如 HARRIS 和 HARRISON),那么我们应该使用“^”正则表达式来表示该字符串要匹配目标的第一个字符。如果我们这样做,那么索引扫描是有效的——只扫描 1366 个索引条目:

mongo> var exp=db.customers.explain('executionStats').
      find({LastName:/^HARRIS/});
mongo>
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( LastName_1 ms:0 keys:1366)
2  FETCH ( ms:0 docs:1365)

Totals:  ms: 3  keys: 1366  Docs: 1365

Tip

要执行有效的支持索引的正则表达式搜索,请确保正则表达式使用“^”操作符锚定在目标字符串的开头。

正则表达式通常用于执行不区分大小写的搜索。例如,该查询搜索姓氏“Harris ”,而不管它如何拼写。正则表达式中尾随的“I”指定不区分大小写的搜索:

mongo> var e = db.customers.
...   explain('executionStats').
...   find({ LastName: /^Harris$/i }, {});

mongo> mongoTuning.executionStats(e);

1   IXSCAN ( LastName_1 ms:4 keys:410071)
2  FETCH ( ms:6 docs:635)

Totals:  ms: 282  keys: 410071  Docs: 635

正如我们在第 5 章中所解释的,这种不区分大小写的查询只有在所涉及的索引不区分大小写的情况下才是有效的——参见第 5 章中关于不区分大小写的索引的部分了解更多细节。

Tip

为了执行有效的不区分大小写的索引搜索,您必须创建一个不区分大小写的索引,如第 5 章所述。

$exists 查询

使用$exists操作的查询可以利用索引:

mongo> var exp=db.customers.explain('executionStats').
      find({updateFlag: {$exists:true}});

mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( updateFlag_1 ms:11 keys:411121)
2  FETCH ( ms:32 docs:411121)

Totals:  ms: 525  keys: 411121  Docs: 411121

但是,请注意,这可能是一个特别昂贵的操作,因为 MongoDB 将扫描整个索引,以找到包含该键的所有条目:

"indexBounds": {
      "updateFlag": [
        "[MinKey, MaxKey]"
      ]
    }

您最好为该列寻找一个特定的值:

mongo> var exp=db.customers.explain('executionStats').
      find({updateFlag:true});

mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( updateFlag_1 ms:0 keys:1)
2  FETCH ( ms:0 docs:1)

Totals:  ms: 0  keys: 1  Docs: 1

或者,您可以考虑创建一个稀疏索引,只对存在值的文档进行索引:

mongo> db.customers.createIndex({updateFlag:1},{sparse:true});
{
  "createdCollectionAutomatically": false,
  "numIndexesBefore": 1,
  "numIndexesAfter": 2,
  "ok": 1
}
mongo> var exp=db.customers.explain('executionStats').find({
...   updateFlag: {$exists:true}});
mongo>
mongo> mongoTuning.executionStats(exp);

1   IXSCAN ( updateFlag_1 ms:0 keys:1)
2  FETCH ( ms:0 docs:1)

Totals:  ms: 0  keys: 1  Docs: 1

稀疏索引的缺点是它不能用于查找属性不存在的文档:

mongo> var exp=db.customers.explain('executionStats').
      find({updateFlag: {$exists:false}});
mongo> mongoTuning.executionStats(exp);

1  COLLSCAN ( ms:10 docs:411121)

Totals:  ms: 295  keys: 0  Docs: 411121

Tip

可以通过相关属性的稀疏索引来优化$exists:true查找。然而,这样的索引不能优化一个$exists:false查询。

优化集合扫描

我们在 MongoDB 查询调优中对索引的强调倾向于扭曲我们的思维——我们有陷入思维陷阱的风险,即执行查询的唯一好方法是通过索引查找。

然而,在这一章中,我们已经看到了许多例子,在这些例子中,索引访问不如集合扫描有效。因此,如果集合扫描不可避免,有没有优化这些扫描的选项?

答案是肯定的!如果您发现您有一个不可避免的集合扫描,并且您需要提高扫描的性能,那么主要的技术是使集合变得更小。

减小集合大小的一种方法是将大型的、不常访问的元素移到另一个集合中。我们在第 4 章中看到了这种垂直分区技术。

对集合进行分片可以通过允许多个集群协作进行扫描来提高集合扫描的性能。我们将在第 14 章讨论分片性能的各个方面。

随着时间的推移,经过大量更新和删除的集合也可能变得臃肿。MongoDB 将尝试重用当文档被删除或大小收缩时创建的空空间,但它不会释放分配回磁盘的空间,并且您的集合可能比它需要的要大。一般来说,WiredTiger 可以有效地重用空间,但是在某些极端情况下,您可以考虑运行 compact 命令来恢复浪费的空间。

请注意,compact 命令会阻止对包含相关集合的数据库的操作,因此您只能在停机时间内发出 compact 命令。

摘要

在这一章中,我们已经了解了如何优化涉及到find()命令的 MongoDB 查询,这是 MongoDB 数据访问的主力。

避免数据访问开销的最佳方式是避免不必要的数据访问——我们讨论了如何在客户端缓存数据来实现这一点。

可以通过使用投影、利用批处理以及在代码中避免不必要的网络往返来减少网络开销。

索引在查询优化中非常有效,但主要是在检索集合数据的子集时。我们研究了如何使用提示来强制 MongoDB 使用您选择的索引或执行集合扫描。

索引可用于优化排序操作,尤其是当您试图优化排序中的第一个文档时。如果您试图优化整个排序结果集,则可能需要进行集合扫描。

集合扫描的性能最终取决于集合的大小,如果集合扫描不可避免,我们研究了一些缩小集合的策略。

Footnotes [1](#Fn1_source)

你可以使用internalQueryExecMaxBlockingSortBytes为排序分配更多的内存——我们将在第 7 章讨论这个参数。从 MongoDB 4.4 开始,您还可以通过在查询中添加allowDiskUse()修饰符来执行“磁盘排序”。

  2

这可能不是一个非常聪明的查询,因为电子邮件地址必须严格按照指定的顺序出现。

 

七、优化聚合管道

当开始使用 MongoDB 时,大多数开发者将从他们熟悉的来自其他数据库的基本 CRUD 操作(创建-读取-更新-删除)开始。insertfindupdatedelete操作确实将构成大多数应用的主干。然而,在几乎所有的应用中,复杂的数据检索和操作需求将会存在,这超出了基本 MongoDB 命令的能力范围。

MongoDB find()命令功能多样且易于使用,但是聚合框架允许您将其提升到下一个级别。聚合管道可以做任何find()操作可以做的事情,甚至更多。正如 MongoDB 自己喜欢在博客、营销材料甚至 t 恤上说的那样:聚合是新发现

聚合管道通过减少可能需要多次查找操作和复杂数据操作的逻辑来简化应用代码。如果利用得当,单个聚合可以取代许多查询及其相关的网络往返时间。

您可能还记得前面的章节,调优应用的一个重要部分是确保尽可能多的工作发生在数据库上。聚合允许您将通常位于应用中的数据转换逻辑移动到数据库中。因此,经过适当调整的聚合管道可以大大超越替代解决方案。

然而,尽管使用聚合带来了诸多好处,但它也带来了一系列新的调优挑战。在本章中,我们将确保您掌握利用和调整聚合管道所需的所有知识。

优化聚合管道

为了有效地优化聚合管道,我们必须首先能够有效地确定哪些聚合需要优化,以及哪些方面可以改进。与find()查询一样,explain()命令是我们最好的朋友。您可能还记得前面的章节,为了检查查询的执行计划,我们在集合名称后添加了.explain()方法。例如,为了解释一个find(),我们可以使用下面的命令:

db.customers.
  explain().
  find(
    { Country: 'Japan', LastName: 'Smith' },
    { _id: 0, FirstName: 1, LastName: 1 }
  ).
  sort({ FirstName: -1 }).
  limit(3);

我们可以用同样的方式来解释聚合管道:

db.customers.explain().aggregate([
  { $match: {
      Country: 'Japan',
      LastName: 'Smith',
    } },
  {  $project: {
      _id: 0,
      FirstName: 1,
      LastName: 1,
    } },
  { $sort: {
      FirstName: -1,
    } },
  { $limit: 3 } ] );

然而,来自find()命令的执行计划和来自aggregate()命令的执行计划有很大的不同。

当针对标准的 find 命令运行explain()时,我们可以通过查看queryPlanner.winningPlan对象来查看关于执行的信息。

聚合管道的explain()输出是相似的,但也有很大的不同。首先,我们以前使用的queryPlanner对象现在驻留在一个新对象中,这个新对象驻留在一个名为stages的数组中。stages数组包含作为单独对象的每个聚合阶段。例如,我们前面看到的聚合将具有以下简化的解释输出:

{
  "stages": [
    {"$cursor": {
        "queryPlanner": {
          // . . .
          "winningPlan": {
            "stage": "PROJECTION_SIMPLE",
            //      . . .
            "inputStage": {
              "stage": "FETCH",
              //    . . .
              "inputStage": {
                "stage": "IXSCAN",
                . . .
              } } },
          "rejectedPlans": []
        } } },
    { "$sort": {
        "sortKey": {
          "FirstName": -1
        },
        "limit": 3
      } } ],
 . . .
}

在聚合管道的执行计划中,queryPlanner阶段揭示了将数据引入管道所需的初始数据访问操作。这通常代表支持初始$match操作的操作,或者——如果没有指定$match条件——从集合中检索所有数据的集合扫描。

stages数组显示了聚合管道中每个后续步骤的信息。请注意,MongoDB 可以在执行期间合并和重新排序聚合阶段,因此这些阶段可能与原始管道定义中的阶段不匹配——下一节将详细介绍。

我们已经编写了一个助手脚本来简化优化脚本包中聚合执行计划的解释。 1 方法mongoTuning.aggregationExecutionStats()将提供每一步所用时间的顶级汇总。这里有一个使用aggregationExecutionSteps的例子:

mongo> var exp = db.customers.explain('executionStats').aggregate([
...   { $match:{
...         "Country":{ $eq:"Japan" }}
...   },
...   { $group:{ _id:{ "City":"$City"  },
...              "count":{$sum:1} }
...   },
...   { $sort:{  "_id.City":-1 }},
...   { $limit:  10 },
... ] );

mongo>  mongoTuning.aggregationExecutionStats(exp);

1  IXSCAN ( Country_1_LastName_1 ms:0 keys:21368 nReturned:21368)
2  FETCH ( ms:13 docsExamined:21368 nReturned:21368)
3  PROJECTION_SIMPLE ( ms:15 nReturned:21368)
4  $GROUP ( ms:70 returned:31)
5  $SORT ( ms:70 returned:10)

Totals:  ms: 72  keys: 21368  Docs: 21368

优化聚合排序

聚合是由一系列阶段构成的,由一组文档表示,这些文档按照从第一个到最后一个的顺序执行。每个阶段的输出被传递到下一个阶段进行处理,初始输入是整个集合。

这些阶段的顺序性质是聚合被称为管道的原因:数据通过管道流动,在每个阶段被过滤和转换,直到最终退出管道。优化这些管道最简单的方法是尽早减少数据量;这将减少每个后续步骤所做的工作量。从逻辑上讲,聚合中执行最多工作的阶段应该对尽可能少的数据进行操作,并在早期阶段执行尽可能多的筛选。

Tip

构建聚合管道时,早过滤,勤过滤!越早从管道中移除数据,MongoDB 的总体数据处理负载就越低。

MongoDB 将自动对管道中的操作顺序进行重新排序,以优化性能——我们将在下一节看到一些优化的示例。但是,对于复杂的管道,您可能需要自己设置顺序。

自动重新排序不可能的一种情况是使用$lookup进行聚合。$lookup阶段允许您加入两个系列。如果您要连接两个集合,您可以选择在连接之前或之后进行过滤,在这种情况下,在连接操作之前尝试减少数据的大小是非常重要的,因为对于传递给查找操作的每个文档,MongoDB 必须尝试在单独的集合中找到匹配的文档。我们可以在 lookout 之前过滤掉的每个文档都将减少需要进行的查找次数。这是一个明显但关键的优化。

让我们看一个生成“前 5 名”产品购买列表的聚合示例:

db.lineitems.aggregate([
  { $group:{ _id:{ "orderId":"$orderId" ,"prodId":"$prodId"  },
             "itemCount-sum":{$sum:"$itemCount"} } },
  { $lookup:
     { from:         "orders",  localField:"_id.orderId",
       foreignField: "_id",             as:"orders"
     }  },
  { $lookup:
     { from:         "customers", localField:"orders.customerId",
       foreignField: "_id",               as:"customers"
     }  },
  { $lookup:
     { from:         "products",  localField:"_id.prodId",
       foreignField: "_id",               as:"products"
     }  },
  { $sort:{  "count":-1 }},
  { $limit:  5 },
],{allowDiskUse: true});

这是一个相当大的聚合管道。实际上,如果没有allowDiskUse:true标志,就会产生内存不足错误;我们将在本章的后面解释为什么会出现这个错误。

注意,我们在之前加入了orderscustomers,products、??,对结果进行排序并限制输出。因此,我们必须为每个行项目执行所有三个连接查找。我们可以——也应该——在$group操作之后直接定位$sort$limit:

db.lineitems.aggregate([
  { $group:{ _id:{ "orderId":"$orderId" ,"prodId":"$prodId"  },
             "itemCount-sum":{$sum:"$itemCount"} } },
  { $sort:{  "count":-1 }},
  { $limit:  5 },
  { $lookup:
     { from:         "orders",  localField:"_id.orderId",
       foreignField: "_id",             as:"orders"
     }  },
  { $lookup:
     { from:         "customers", localField:"orders.customerId",
       foreignField: "_id",               as:"customers"
     }  },
  { $lookup:
     { from:         "products",  localField:"_id.prodId",
       foreignField: "_id",               as:"products"
     }  }
],{allowDiskUse: true});

性能上的差异是惊人的。通过将$sort$limit提前几行,我们已经创建了一个更加高效和可伸缩的解决方案。图 7-1 展示了通过在管道中提前移动$limit获得的性能提升。

img/499970_1_En_7_Fig1_HTML.png

图 7-1

$lookup管道中提前移动限制子句的效果

Tip

注意对聚合管道进行排序,以尽早而不是推迟删除文档。越早从管道中消除数据,后面管道中需要的工作就越少。

自动流水线优化

MongoDB 将对聚合管道进行一些优化,以提高性能。具体的优化因版本而异,当通过驱动程序或 MongoDB shell 运行聚合时,没有明显的迹象表明优化已经发生。事实上,唯一确定的方法是使用explain()检查查询计划。如果您惊讶地发现您的聚合解释与您刚刚发送到 MongoDB 的内容不匹配,不要惊慌。这是优化器的工作。

让我们运行一些聚合,观察 MongoDB 如何决定使用explain()来改进管道。这是一个非常糟糕的聚合管道:

> var explain = db.listingsAndReviews.explain("executionStats").
   aggregate([
    {$match: {"property_type" : "House"}},
    {$match: {"bedrooms" : 3}},
    {$limit: 100},
    {$limit: 5},
    {$skip: 3},
    {$skip: 2}
]);

你大概能猜到这里会发生什么。多个$match$limit$skip阶段,当一个接一个地放置时,可以合并成单个阶段而不改变结果。使用$and可以合并两个$match阶段。两个$limit阶段的结果总是较小的极限值,两个$skips的效果是$skip值之和。尽管来自管道的结果没有改变,但是我们可以观察到查询计划中的优化效果。下面是从我们的mongoTuning.aggregationExecutionStats命令输出的合并阶段的简化视图:

1  COLLSCAN ( ms:0 docsExamined:525 nReturned:5)
2  LIMIT ( ms:0 nReturned:5)
3  $SKIP ( ms:0 returned:0)

Totals:  ms: 1  keys: 0  Docs: 525

如您所见,MongoDB 将我们管道中的六个步骤合并成了三个操作。

MongoDB 还可以代表您执行其他一些智能合并。如果您有一个$lookup阶段,在那里您立即$unwind连接的文档,MongoDB 会将$unwind合并到$lookup中。例如,此聚合将用户与其博客评论结合在一起:

> var explain = db.users.explain("executionStats").aggregate([
 { $lookup: {
     from: "comments",
     as: "comments",
     localField: "email",
     foreignField: "email"
 }},
 { $unwind: "$comments"}
]);

$lookup$unwind将成为执行中的单个阶段,这将消除创建大型连接文档,这些文档将立即展开为较小的文档。执行计划将类似于下面的代码片段:

> mongoTuning.aggregationExecutionStats(explain);

1  COLLSCAN ( ms:9 docsExamined:183 nReturned:183)
2  $LOOKUP ( ms:4470 returned:50146)

Totals:  ms: 4479  keys: 0  Docs: 183

类似地,$sort$limit阶段将被合并,允许$sort只维护有限数量的文档,而不是它的全部输入。下面是这种优化的一个例子。该查询

> var explain = db.users.explain("executionStats").
 aggregate([
  { $sort: {year: -1}},
  { $limit: 1}
 ]);
> mongoTuning.aggregationExecutionStats(explain);

将在解释输出中产生一个阶段:

1  COLLSCAN ( ms:0 docsExamined:183 nReturned:183)
2  SORT ( ms:0 nReturned:1)

Totals:  ms: 0  keys: 0  Docs: 183

还有另一个重要的优化,它不涉及合并或移动管道中的阶段。如果您的聚合只需要文档属性的子集,MongoDB 可能会添加一个投影来删除所有未使用的字段。这减少了通过管道的数据集的大小。例如,以下聚合实际上只使用了两个字段-国家和城市:

mongo> var exp = db.customers.
...   explain('executionStats').
...   aggregate([
...     { $match: { Country: 'Japan' } },
...     { $group: { _id: { City: '$City' } } }
...   ]);

MongoDB 在执行计划中插入一个投影,以消除所有不需要的属性:

mongo> mongoTuning.aggregationExecutionStats(exp);

1  IXSCAN ( Country_1_LastName_1 ms:4 keys:21368 nReturned:21368)
2  FETCH ( ms:12 docsExamined:21368 nReturned:21368)
3  PROJECTION_SIMPLE ( ms:12 nReturned:21368)
4  $GROUP ( ms:61 returned:31)

Totals:  ms: 68  keys: 21368  Docs: 21368

因此,我们现在知道 MongoDB 将有效地添加和合并阶段,以改善您的管道。在某些情况下,优化器会对您的阶段重新排序。其中最重要的是$match操作的重新排序。

如果一个管道在一个将把新字段投射到文档中的阶段(例如$group$project$unset$addFields$set)之后包含一个$match,并且如果$match阶段不需要投射的字段,MongoDB 将把那个$match阶段移到管道的前面。这减少了后期必须处理的文档数量。

例如,考虑以下聚合:

var exp=db.customers.explain("executionStats").aggregate([
  { '$group': {
      '_id': '$Country',
      'numCustomers': {
        '$sum': 1
      } } },
  { '$match': {
      '$or': [
        { '_id': 'Netherlands' },
        { '_id': 'Sudan' },
        { '_id': 'Argentina' } ] } }
]);

在 MongoDB 4.0 之前,MongoDB 将执行管道中指定的确切步骤——执行一个$group操作,然后使用$match来排除指定国家以外的国家。这是一种浪费,特别是因为我们有一个国家索引,可以用来快速找到所需的文件。

然而,在 MongoDB 的现代版本中,$match操作将被重新定位在$group操作之前,减少了需要分组的文档数量,并允许使用索引。以下是生成的执行计划:

mongo> mongoTuning.aggregationExecutionStats(exp);

1  IXSCAN ( Country_1_LastName_1 ms:1 keys:13720 nReturned:13717)
2  PROJECTION_COVERED ( ms:1 nReturned:13717)
3  SUBPLAN ( ms:1 nReturned:13717)
4  $GROUP ( ms:20 returned:3)

MongoDB 自动优化是最近 MongoDB 版本中的无名英雄之一,它提高了性能,而不需要您做任何工作。了解这些优化是如何工作的,将使您能够在创建聚合时做出正确的决策,并了解执行计划中的异常情况

有关任何给定 MongoDB 版本的优化中会发生什么的更多信息,请参考位于 http://bit.ly/MongoAggregatePerf 的官方文档。

优化多集合联接

聚合框架提供的真正重要的功能之一是合并来自多个集合的数据的能力。最重要和最成熟的功能是在$lookup操作符中,它允许两个集合之间的连接。

在第 4 章中,我们试验了一些可选的模式设计,其中一些经常需要连接来组装信息。例如,我们创建了一个模式,其中客户和订单保存在不同的集合中。在这种情况下,我们将使用$lookup来连接客户数据和订单数据,如下所示:

db.customers.aggregate([
  { $lookup:
     { from:         "orders",
       localField:   "_id",
       foreignField: "customerId",
       as:           "orders"
     }
  },
]);

该语句在每个客户文档中嵌入了一组订单。客户文档中的_id属性与orders集合中的customerId属性相匹配。

使用$lookup构造一个连接并不太困难,但是有一些关于连接性能的潜在问题。因为对源数据中的每个文档执行一次$lookup函数,所以$lookup必须快速。实际上,这意味着$lookup应该由一个索引来支持。在前面的例子中,我们需要确保在orders集合中的customerId属性上有一个索引。

不幸的是,explain()命令不能帮助我们确定连接是否有效或者是否使用了索引。例如,下面是前面操作的解释输出(使用mongoTuning.aggregationExecutionStats):

1  COLLSCAN ( ms:10 docsExamined:411121 nReturned:411121)
2  $LOOKUP ( ms:5475 returned:411121)

explain 输出告诉我们,我们使用了集合扫描来执行客户的初始检索,但是没有显示我们是否在$lookup阶段使用了索引。

但是,如果您没有一个支持索引,您几乎肯定会注意到由此导致的性能下降。图 7-2 显示了当越来越多的文档参与到一个连接中时,性能是如何下降的。有了索引,连接性能就变得高效且可预测。如果没有索引,随着更多的文档添加到连接中,连接性能会急剧下降。

img/499970_1_En_7_Fig2_HTML.png

图 7-2

$lookup绩效-索引化与非索引化

Tip

总是在一个$lookup中的foreignField属性上创建一个索引,除非集合很小。

加入订单

当加入集合时,我们有时可以选择加入的顺序。例如,该查询将来自客户连接到订单:

db.customers.aggregate([
  { $lookup:
     { from:         "orders",
       localField:   "_id",
       foreignField: "customerId",
       as:           "orders"
     }
  },
  { $unwind:  "$orders" },
  { $count: "count" },
]);

以下查询返回相同的数据,但是将来自订单连接到客户:

db.orders.aggregate([
  { $lookup:
     { from:         "customers",
       localField:   "customerId",
       foreignField: "_id",
       as:           "customer"
     }
  },
  { $count: "count" },
] );

这两个查询具有非常不同的性能特征。尽管每个查询中都有支持$lookup操作的索引,但是从订单到客户的连接会导致更多的$lookup调用——仅仅因为订单比客户多。因此,从订单到客户的连接比反过来需要更长的时间。图 7-3 显示了相对性能。

img/499970_1_En_7_Fig3_HTML.png

图 7-3

加入顺序和$lookup性能

决定连接顺序时,请遵循以下准则:

  1. 在连接之前,您应该尽可能地减少要连接的数据量。因此,如果要过滤其中一个集合,该集合应该在连接顺序中排在第一位。

  2. 如果您只有一个索引来支持两个连接顺序中的一个,那么您应该使用具有支持索引的连接顺序。

  3. 如果两个连接顺序都满足前面的两个条件,那么您应该尝试从最小的集合连接到最大的集合。

    提示在其他条件相同的情况下,从小集合加入到大集合,而不是从大集合加入到小集合。

优化图形查找

Neo4J 等图形数据库专门遍历关系图——就像你可能在社交网络中找到的那些关系图。许多非图形数据库已经整合了图形计算引擎来执行类似的任务。使用旧版本的 MongoDB,您可能不得不通过网络获取大量的图形数据,并在应用级别上运行一些计算。这个过程将会是缓慢而繁琐的。幸运的是,从 MongoDB 3.4 开始,我们可以使用$graphLookup聚合框架阶段执行简单的图遍历。

假设您在 MongoDB 中存储了代表社交网络的数据。在这个网络中,单个用户作为朋友连接到大量其他用户。这类网络是图形数据库的常见用途。让我们用下面的样本数据来看一个例子:

db.getSiblingDB("GraphTest").socialGraph.findOne();
{
      "_id" : ObjectId("5a739cda0c31c5f5afcff87f"),
      "person" : 561596,
      "name" : "User# 561596",
      "friends" : [
            94230,
            224410,
            387968,
            406744,
            707890,
            965522,
            1189677,
            1208173
      ]
}

使用带有$graphLookup阶段的聚合管道,我们可以为个人用户扩展我们的社交网络。下面是一个管道示例:

db.socialGraph.aggregate([
  {$match:{person:1476767}},
  {$graphLookup: {
      from: "socialGraph",
      startWith: [1476767],
      connectFromField: "friends",
      connectToField: "person",
      maxDepth: 2,
      depthField: "Depth",
      as: "GraphOutput"
    }
  },{$unwind:"$GraphOutput"}
], {allowDiskUse: true});

我们在这里做的是从 person 1476767开始,然后沿着 friends 数组的元素到两个层次,本质上是寻找“朋友的朋友”

增加maxDepth字段的值会成倍增加我们必须处理的数据量。你可以认为每一层深度都需要某种自我连接到集合中。对于初始数据集中的每个文档,我们读取集合来寻找朋友;然后,对于数据集中的每个文档,读取集合以找到那些朋友;等等。一旦达到maxDepth个连接,我们就停止。

很明显,如果每个自连接都需要一次集合扫描,那么随着网络深度的增加,性能将会迅速下降。因此,在遍历连接时,确保有一个索引可供 MongoDB 使用是很重要的。该索引应该在connectToField属性上。

7-4 显示了有和没有步进的$graphLookup操作的性能。如果没有索引,随着操作深度的增加,性能会迅速下降。有了索引,图形查找的可伸缩性和效率都大大提高了。

img/499970_1_En_7_Fig4_HTML.png

图 7-4

$graphLookup带或不带索引的性能

Tip

当执行$graphLookup操作时,确保在connectToField属性上有一个索引。

聚合内存利用率

在 MongoDB 中执行聚合时,有两个重要的限制需要记住,这两个限制适用于所有聚合,不管管道是从哪个阶段构建的。除此之外,在调优您的应用时,还需要考虑一些特定的限制。您必须牢记的两个限制是文档大小限制和内存使用限制。

在 MongoDB 中,单个文档的大小限制是 16MB。对于聚合也是如此。执行聚合时,如果任何输出文档超过此限制,将会引发错误。当执行简单的聚合时,这可能不是问题。但是,在对多个集合中的文档进行分组、操作、展开和连接时,您必须考虑输出文档不断增长的大小。这里一个重要的区别是,这个限制只适用于结果中的文档。例如,如果一个文档在管道中超过了这个限制,但是在结束之前又减少到这个限制以下,那么就不会抛出错误。此外,MongoDB 在内部结合了一些操作来避免限制。例如,如果一个$lookup返回一个大于限制的数组,但是$lookup后面紧跟着一个$unwind,那么就不会出现文档大小错误。

要记住的第二个限制是内存使用限制。在聚合管道的每个阶段,默认情况下都有 100MB 的内存限制。如果超过这个限制,MongoDB 将产生一个错误。

MongoDB 确实提供了一种在聚合期间绕过这个限制的方法。allowDiskUse选项可用于取消几乎所有阶段的限制。正如您可能已经猜到的,当设置为 true 时,这允许 MongoDB 在磁盘上创建一个临时文件来保存聚合时的一些数据,绕过内存限制。在前面的一些例子中,您可能已经注意到了这一点。以下是在我们之前的一个聚合中将此限制设置为 true 的示例:

db.customers.aggregate([
  { '$group': {
      '_id': '$Country',
      'numCustomers': {
        '$sum': 1
      } } },
  { '$match': {
      '$or': [
        { '_id': 'Netherlands' },
        { '_id': 'Sudan' },
        { '_id': 'Argentina' } ] } }
],{allowDiskUse:true});

正如我们所说的,allowDiskUse选项将绕过几乎所有阶段的限制。不幸的是,即使allowDiskUse设置为真,仍然有几个阶段被限制在 100MB。两个累加器$addToSet$push不会溢出到磁盘,因为如果不进行适当的优化,这些累加器会将大量数据添加到下一阶段。

对于这三个有限的阶段,目前没有明显的解决办法,这意味着您必须优化查询和管道本身,以确保您不会遇到这个限制并从 MongoDB 收到错误。

为了避免触及这些内存限制,您应该考虑实际需要获取多少数据。问问自己是否使用了查询返回的所有字段,数据是否可以更简洁地表示?从中间文档中删除不必要的属性是减少内存使用的一种简单而有效的方法。

如果所有这些都失败了,或者如果您想避免数据溢出到磁盘时的性能下降,您可以尝试增加这些操作的内部内存限制。这些内存限制由“internal*Bytes”形式的未记录参数控制。其中最重要的三个是

  • internalQueryMaxBlockingSortMemoryUsageBytes:a$sort可用的最大内存(详见下一节)

  • internalLookupStageIntermediateDocumentMaxSizeBytes:一个$lookup操作可用的最大内存

  • internalDocumentSourceGroupMaxMemoryBytes:一个$group操作可用的最大内存

您可以使用setParameter命令调整这些参数。例如,要增加排序内存,您可以发出以下命令:

db.getSiblingDB("admin").
    runCommand({ setParameter: 1,
    internalQueryMaxBlockingSortMemoryUsageBytes: 1048576000 });

我们将在下一节的排序优化中进一步讨论这一点。但是,在调整内存限制时要非常小心,因为如果超过了服务器的内存容量,可能会损害 MongoDB 集群的整体性能。

聚合管道中的排序

我们在第 6 章中看到了在find()操作中优化排序。聚合管道中的排序在几个重要方面不同于排序:

  1. 通过执行“磁盘排序”,聚合可以超过阻塞排序的内存限制在磁盘排序中,在排序操作过程中,多余的数据被写入磁盘或从磁盘中取出。

  2. 聚合可能无法利用索引排序选项,除非排序在管道中处于非常早期的位置。

索引聚合排序

find()类似,聚合能够使用索引来解析排序,从而避免高内存利用率或磁盘排序。然而,这通常只有在$sort足够早地出现在流水线中以滚动到初始数据访问操作时才会发生。

例如,考虑这个操作,其中我们对一些数据进行排序并添加一个字段:

mongo> var exp=db.baseCollection.explain('executionStats').
...     aggregate([
...        { $sort:{  d:1 }},
...        {$addFields:{x:0}}
...     ],{allowDiskUse: true});
mongo> mongoTuning.aggregationExecutionStats(exp);

1  IXSCAN ( d_1 ms:97 keys:1000000 nReturned:1000000)
2  FETCH ( ms:500 docsExamined:1000000 nReturned:1000000)
3  $ADDFIELDS ( ms:3316 returned:1000000)

Totals:  ms: 3358  keys: 1000000  Docs: 1000000

排序后的属性有一个索引,我们可以使用这个索引来优化排序。但是,如果我们在排序之前移动$addFields操作,那么聚集就不能利用索引,并且会发生代价高昂的“磁盘排序”:

mongo> var exp=db.baseCollection.explain('executionStats').
...     aggregate([
...        {$addFields:{x:0}},
...        { $sort:{  d:1 }},
... ],{allowDiskUse: true});

mongo> mongoTuning.aggregationExecutionStats(exp);

1  COLLSCAN ( ms:16 docsExamined:1000000 nReturned:1000000)
2  $ADDFIELDS ( ms:1164 returned:1000000)
3  $SORT ( ms:12125 returned:1000000)

Totals:  ms: 12498  keys: 0  Docs: 1000000

7-5 比较了两种聚合的性能。通过将排序移到聚合管道的开始,避免了成本高昂的磁盘排序,并显著减少了运行时间。

img/499970_1_En_7_Fig5_HTML.png

图 7-5

聚合管道中的磁盘排序与索引排序

Tip

在聚合管道中尽可能早地移动具有支持索引的排序,以避免昂贵的磁盘排序。

磁盘排序

如果没有支持排序的索引,并且排序超过了 100MB 的限制,那么您将收到一个QueryExceededMemoryLimitNoDiskUseAllowed错误:

mongo>var exp=db.baseCollection.
...     aggregate([
...        { $sort:{  d:1 }},
...        {$addFields:{x:0}}
...     ],{allowDiskUse: false});

2020-08-22T15:36:01.890+1000 E  QUERY    [js] uncaught exception: Error: command failed: {
        "operationTime" : Timestamp(1598074560, 3),
        "ok" : 0,
        "errmsg" : "Error in $cursor stage :: caused by :: Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting.",
        "code" : 292,
        "codeName" : "QueryExceededMemoryLimitNoDiskUseAllowed",

如果有可能使用索引来支持这种排序,如前一节所述,那么这通常是最好的解决方案。然而,在复杂的聚合管道中,这并不总是可能的,因为要排序的数据可能是先前管道阶段的结果。在这种情况下,我们有两个选择:

  1. 通过指定allowDiskUse:true使用“磁盘排序”。

  2. 通过更改internalQueryMaxBlockingSortMemoryUsageBytes参数增加阻塞排序的全局限制。

更改 MongoDB 默认内存参数应该非常小心,因为存在导致服务器内存不足的风险,这会使全局性能更差。然而,在当今世界,100MB 并不是很大的内存,因此增加该参数可能是最好的选择。这里,我们将最大排序内存增加到 1GB:

mongo>db.getSiblingDB("admin").
...    runCommand({ setParameter: 1,
internalQueryMaxBlockingSortMemoryUsageBytes: 1048576000 });
{
        "was" : 104857600,
        "ok" : 1,
…

7-6 显示了当我们增加internalQueryMaxBlockingSortMemoryUsageBytes来避免磁盘排序时,示例查询的性能是如何提高的。

img/499970_1_En_7_Fig6_HTML.png

图 7-6

聚合中的磁盘排序与内存排序

使用磁盘排序要考虑的另一个问题是可伸缩性。如果您设置了diskUsage:true,那么您可以放心,即使没有足够的内存来完成排序,您的查询也会运行。但是,当查询从内存排序切换到磁盘排序时,性能会突然下降。在生产环境中,您的应用可能会突然“碰壁”

7-7 显示了当有足够的内存支持排序时,与相对线性的趋势相比,切换到磁盘排序如何导致执行时间的突然增加。

img/499970_1_En_7_Fig7_HTML.png

图 7-7

聚合中的磁盘排序如何影响可伸缩性

Tip

聚合管道中的磁盘排序既昂贵又缓慢。如果希望提高大型聚合排序的性能,您可能希望增加聚合排序的默认内存限制。

优化视图

如果您以前使用过 SQL 数据库,您可能对视图的概念很熟悉。在 MongoDB 中,视图是一种包含聚合管道结果的合成集合。从查询的角度来看,视图看起来和感觉上就像一个普通的集合,只是它们是只读的。

创建视图的主要优点是通过在数据库中存储复杂的管道定义来简化和统一应用逻辑。

就性能而言,重要的是理解当创建一个视图时,结果不会存储在内存中或复制到一个新的集合中。查询视图时,您仍然在查询原始集合。MongoDB 将获取为视图定义的聚合管道,然后附加您的附加查询参数,创建一个新管道。这看起来像是在查询视图,但实际上,复杂的聚合管道仍然在发布。

因此,与执行定义视图的管道相比,创建视图不会给您带来性能优势。

因为一个视图本质上只是一个集合的集合,所以我们优化视图的方法与任何集合都是一样的。如果您的视图性能不佳,请使用本章前面介绍的技术优化定义视图的管道。

针对视图编写查询时,请记住,针对视图执行查询时,通常不能利用基础集合上的索引。例如,考虑这个视图,它按订单计数汇总产品代码:

db.createView('productTotals', 'lineitems', [
  { $group: {
      _id: { prodId: '$prodId' },
      'itemCount-sum': { $sum: '$itemCount' }
    }
  },
  { $project: {
      ProdId: '$_id.prodId',
      OrderCount: '$itemCount-sum',
      _id: 0
    }
  }]);

我们可以使用此视图查找特定产品代码的总数:

mongo> db.productTotals.find({ ProdId: 83 });
{
  "ProdId": 83,
  "OrderCount": 460051
}

然而,即使在lineItems集合中的prodId上有一个索引,从视图中查询时也不会使用该索引。即使我们只要求一个产品代码,MongoDB 也会在返回结果之前聚合所有产品的数据。

虽然这要繁琐得多,但是这个聚合管道将使用ProdId上的索引,因此返回数据的速度会快得多:

db.lineitems.aggregate(
  [ {  $match: { prodId: 83 }},
    {  $group: {
        _id: { prodId: '$prodId' },
        'itemCount-sum': { $sum: '$itemCount' }  }
    },
    { $project: {
        ProdId: '$_id.prodId',
        OrderCount: '$itemCount-sum',
        _id: 0
      }
    }
  ] );

Tip

在解析查询时,视图不能总是利用基础集合上的索引。如果从视图中查询在基础集合中编入索引的属性,绕过视图直接查询基础集合可能会获得更好的性能。

物化视图

正如我们已经讨论过的,MongoDB 视图不会提高查询性能,在某些情况下,可能会因为抑制索引而损害性能。即使视图只包含几个文档,查询仍然需要很长时间,因为每次查询视图时都需要重新构建数据。

物化视图在这里提供了一个解决方案——特别是当一个视图从大量的源集合中返回少量的聚合信息时。实体化视图是一个集合,它包含由视图定义返回的文档,但是将视图结果存储在数据库中,以便不必在每次读取数据时都执行视图。

在 MongoDB 中,我们可以使用$merge$out聚合操作符来创建一个物化视图。$out用聚合的结果完全替换目标集合。$merge提供了一种对现有集合的“向上插入”,允许对目标进行增量更改。我们会在第八章中更多地关注$merge

要创建一个物化视图,我们只需运行一个通常用于定义视图的聚合管道,但是,作为聚合的最后一步,我们使用$merge将结果文档输出到一个集合中。通过运行这个聚合管道,我们可以创建一个新的集合,在执行时反映另一个集合中的数据聚合。然而,与视图不同的是,这个集合可能要小得多,从而可以提高性能。

让我们来看一个例子。下面是一个复杂的管道,它按产品和城市创建了一个销售汇总:

db.customers.aggregate([
  { $lookup:
     { from:         "orders",
       localField:   "_id",
       foreignField: "customerId",
       as:           "orders"  } },
  { $unwind:  "$orders" },
  { $lookup:
     { from:         "lineitems",
       localField:   "orders._id",
       foreignField: "orderId",
       as:           "lineItems"  }  },
  { $unwind:  "$lineItems" },
  { $group:{ _id:{ "City":"$City" ,
                   "lineItems_prodId":"$lineItems.prodId"  },
             "count":{$sum:1},
             "lineItems_itemCount-sum":{$sum:"$lineItems.itemCount"} } },
  { $project: {
          "CityName": "$_id.City"  ,
          "ProductId": "$_id.lineItems_prodId"  ,
          "OrderCount": "$lineItems_itemCount-sum"  ,
          "_id": 0
         }  } ] );

如果我们将下面的$merge操作添加到管道中,那么我们将创建一个集合salesByCityMV,它包含聚合的输出: 2

{$merge:
    {       into:"salesByCityMV"}}

7-8 显示了物化视图查询与普通视图查询的执行时间对比。如您所见,物化视图的性能要优越得多。这是因为在发送最终的 find 查询时,大部分工作已经完成。

img/499970_1_En_7_Fig8_HTML.png

图 7-8

物化视图与直接视图

这种方法有一个明显的缺点:当原始集合中的第二个数据发生变化时,物化视图就会过时。应用或数据库管理员有责任确保物化视图以有意义的时间间隔刷新。例如,实体化视图可能包含前一天的 access 销售记录。聚合可以在每晚午夜运行,确保每天的数据都是正确的。

Tip

对于查询速度比绝对时间点准确性更重要的复杂聚合,物化视图提供了一种强大的方法来提供对聚合输出的快速访问。

创建实体化视图时,请确保视图的刷新不会比该视图上的查询运行得更频繁。数据库仍然需要使用资源来创建视图,所以没有理由每小时刷新一次物化视图,因为物化视图一天只查询一次。

如果源表很少更新,可以安排在检测到更新时自动刷新实体化视图。MongoDB 变更流工具允许您监听集合中的变更。当收到更改通知时,您可以触发实体化视图的重建。

我们将在第 8 章中看到$merge操作符的更多用法。

摘要

MongoDB 创建了一个非常强大的方法来用聚合框架构造复杂的查询。多年来,他们扩展了这个框架,以支持更广泛的用例,甚至负责一些以前可能发生在应用级别的数据转换。如果从过去可以看出,aggregate命令将随着时间的推移而增长,以适应越来越复杂的功能。记住所有这些,如果您希望创建一个高级的高性能 MongoDB 应用,您应该利用aggregate所提供的一切。

但是,随着聚合管道的强大功能,确保管道得到优化的责任也随之而来。在本章中,我们概述了在创建聚合时需要牢记的一些关键性能问题。

过滤和阶段排序将允许您最大限度地减少流经管道的数据。索引$lookup$graphLookup的相关字段将确保快速检索相关文件。您还需要确保在获取大型结果时使用allowDiskUse选项,以避免触及内存限制,或者更改这些内存限制,以避免昂贵的“磁盘排序”

在下一章中,我们将讨论 CRUD 的 C、U 和 D——创建、更新和删除——并考虑数据操作语句的优化,如insertupdatedelete

Footnotes [1](#Fn1_source)

有关如何使用调整包的信息,请参见简介。

  2

Merge 有很多额外的选项来处理现有数据,我们将在第 8 章中讨论。