NoSQL学习——文档数据库MongoDB(三)聚合与索引

11/18/2021 NoSQLMongoDB

# 聚合是什么

查询文档中一些隐藏的性质,例如某类用户的平均消费水平,需要使用高级的查询或是客户端编程。为了减少这种情况带来的开发难度,在之前的MongoDB中,官方提供给了MapReduce的方案,但该方案由于固定的Map和Reduce两阶段不够灵活,现已经较少使用,转而使用聚合。聚合是一种面向文档的查询方式,通过对文档进行分组、应用计算函数等操作实现更加高级的查询。由于聚合是在MongoDB内部的引擎中进行的,其编写速度相较于在应用程序内会有一定的优势。但另一方面,聚合的编写相较于查询通常更为复杂。

聚合使用一种基于流水线的技术,按照阶段进行。因而具有两方面的特质,其一是聚合后一阶段的文档仅来自于前一阶段,其二是后一阶段的结果不会影响前一阶段。因此,聚合可以很方便地通过流的方式生成新的collection,用于提供数据的视图。

# 聚合的编写

# 聚合的各种处理阶段

在聚合执行之前,MongoDB会自动通过一个inputStage将collection内的数据读入,形成第一阶段的结果集。然后,就可以使用编写的处理阶段了。常见的聚合包含以下几种处理阶段:

  • $match:查找符合条件的文档,使用与find类似的criteria object进行查询。
  • $group:根据指定的_id对文档进行聚合,同时使用额外的域指定聚合函数用于获取聚合出的文档的统计。使用操作符可以进行计算。
    • _id:用于进行分组的域,需要注意的是要使用$符号开头才能表示,否则将会是一个固定值。_id可以是单一域,也可以是域的组合。
    • 其他域:用于按分组执行相关的操作,常见的有统计类如$sum, 选取类如$first, 最值类如$min,聚合结果类如$addToSet等。
  • $project:类似find中的projection,可用于更改名称,限定文档内容,增加新的域。使用操作符可以进行计算。
  • $unwind:用于处理Array,将其展开为单一元素并对应到原始文档上。类似于Spark的explode。
  • $sort:根据多个域排序,与find类似。
  • $skip$limit$count:与find的modifier类似。
  • $sample:随机选取部分文档返回,成功与否可能与内存限制有关。
  • $out:将聚合返回的结果输出到一个给定的collection。
  • $lookup:用于实现类似SQL中Join的操作,将两组collection通过指定的域关联起来。$lookup实现的是一种left join的方式,对于所有主文档(即来自上一阶段输入的文档),都会查找另一边的文档,然后放置在一个主文档的Array中。在这一过程中,即使没有与主文档相同的键,文档依然会被输出,这时它会包含一个空的Array。因此,lookup阶段输出的文档数量会与上一阶段相同。

除此之外,MongoDB也提供了一种面向图的聚合阶段$graphLookup。这类聚合通常使用文档的一个Array域作为查找的依据,该域的值是其他文档的id。

  • $graphLookup:与$lookup类似,使用from字段表示查找的collection,通过startWith指定表示初始的查询所关联的域,connectFromField是源文档中的键,connectToField是被查询文档的键,此外可通过maxDepth限制查询的深度。

# 一些典型的聚合例子

在建立了对各阶段初始认识的基础上,我们可以通过一些例子熟悉聚合。在下面的例子中,我们将使用$match$lookup$project$count来进行一项查询某类文档的数量的任务。

db.tweets_v2.aggregate([
  { $match: { replyto_id: { $exists: false }, retweet_id: { $exists: false } } }, // Find the original tweets
  {
    $lookup: { // Find the tweets reply to the original tweets
      from: 'tweets_v2',
      localField: 'id',
      foreignField: 'replyto_id',
      as: 'reply'
    }
  },
  { $project: { id: '$id', reply: { $first: '$reply' } } }, // discard other fields, flatten to remain one reply
  { $match: { reply: { $exists: true } } }, // filter out original tweets without replies
  {
    $lookup: { // find the retweets
      from: 'tweets_v2',
      localField: 'id',
      foreignField: 'retweet_id',
      as: 'retweet'
    }
  },
  {
    $project: { // flatten to remain one retweet
      'id': 1,
      'reply': 1,
      'retweet': { $first: '$retweet' },
    }
  },
  { $match: { reply: { $exists: true }, retweet: { $exists: true } } }, // remain tweets with at least one retweet and reply
  { $count: 'Number of tweets' } // count the numbers
])

其中我们主要关注$lookupproject两种阶段。结合其中的操作符,这两种阶段能够大幅提升聚合的适用范围。在lookup中,我们可以看到几个基本的键——from、localField和foreignField,这几个键决定了当前的collection和哪个collection进行left join,以及根据哪个键进行。在上面的例子中我们可以看到,同一个collection也可以作为foreignField。由此可见,$lookup阶段非常适用于根据建模为Referencing的关系进行查询。另一个例子是$project,在这个阶段中,每个键表示输出文档的键,值则可以是数值(0,1表示输出是否包含对应的域)或projection object,其键是操作符。

db.tweets_v2.aggregate([
  { $match: { retweet_id: { $exists: true } } },
  {
    $lookup: {
      from: 'tweets_v2',
      localField: 'retweet_id',
      foreignField: 'id',
      pipeline: [
        { $match: {
          replyto_id: { $exists: false },
          retweet_id: { $exists: false },
        }
    }
      ],
      as: 'original'
    }
  },
  { $unwind: { path: '$original' } },
  {
    $match: {
      $expr: {
        $lte: [
          { $subtract: ['$created_at', '$original.created_at'] },
          3600 * 1000
        ]
      }
    }
  },
  { $group: { _id: '$original.id', retweet_count: { $count: {} } } },
  { $sort: { retweet_count: -1 } },
  { $limit: 1 },
  { $project: { _id: 0, id: '$_id', 'retweet_count': 1 } }
])

在第二个例子中,我们可以看到更多的操作,其中包括了$unwind的基本用法和$lookup的另一种用法,即通过pipeline对作为Array文档进行类似的聚合处理。关于$lookup还有一种需要说明的域是let。在这个域中可以将一些表达式计算为pipeline中可以使用的变量,需要注意,这些变量需要使用两个$符号,例如let: {time:...}, pipeline:{$match:{$$time:{...}}}

在上例中,$unwind使用了path记录Array的位置,并且没有添加额外的参数项,这样会使unwind将path相关Array为空的文档自动忽略,因此可以在查找某些具有特殊条件的lookup阶段后面使用,以在展开的同时忽略无关的文档,减少$match的使用。

# MongoDB索引

索引在这一章中介绍的原因是,它与包括聚合在内的各种数据库操作的执行性能有着较为紧要的关联。在数据库中,数据的读写都会与内存和磁盘的读写相关,例如,一个写操作可能需要数据库的存储引擎分别在内存和硬盘上进行写操作。索引的一个非常重要的作用是减少耗时的硬盘IO操作的数量。

# 索引原理简述

MongoDB索引的基本原理是建立一张关于索引属性和对应文档的独立映射表,即每一个或一组的属性对应到一篇文档上。映射表格的存储形式很多,但在MongoDB中,存储引擎WiredTiger通常使用B-树(默认)或者LSM(Log Structured Merge tree)对索引进行存储。

单一索引是基于一个域的索引方法。在建立之后,每个索引项会根据该域的值指向实际存储在磁盘上的文档或页(包含多个文档的次级结构)。由于MongoDB中的索引指定了方向,索引的遍历顺序会依照该方向进行。例如在一个包含10000文档的collection上建立一个基于created_at的索引,要查找最近创建的文档时,逆序(索引指定为-1)的索引会比顺序的(索引指定为1)更快。

复合索引的情况略微复杂。首先,它采用类似sort访问顺序的方式组织索引的顺序,即根据第一个值、第二个值…依次进行排列,次级键的值不会在整个索引上排序中,而是依赖于上一级的位置排序。下面的简单表示展示了复合索引的组织方式

index
uid     1   2   2   2   ...   8   9   10
score   30  68  62  51  ...   66  68  60

其次,在查找时复合索引需要根据索引建立的顺序去一次进行查找,不支持跳过前面的索引域的查找。例如,一个索引{uid:1, score:-1}可以支持find({uid:2312, score:{$gt:30}})的查找,但不支持find({score:{$gt:30}})。因此,在实际使用中,要根据各个域的查询顺序设计符合索引的顺序。

# 索引的建立方法

当一个collection被建立的时候,MongoDB会在默认的*_id*上建立第一组索引,这组索引可以自动生成也可以由应用程序指定,但必须保证独一性,以在内部进行查找时使用。

另外,就像SQL的索引可以建立在主键或多个键的联合上一样,MongoDB的索引也可以建立在文档的多个或一个域(包括Array内的子文档的域)上。MongoDB索引的建立方法非常简单,与find中的sort等指令的编写方法也很类似。例如,以下的指令是根据一个Array中的id创建一个正向的索引(因此指令中的值为1),第二个object用于声明索引的属性,例如unique表示是否强制唯一性。

  • db.tweets_v2.createIndex({ "user_mentions.id": 1 }, <{unique: true|false}>)

另一种常用的索引是文字索引(text index),将需要索引的域的值token化并抽取主干作为索引的键,以支持查找字符串,其建立方法如下。

  • db.tweets_v2.createIndex({ "content": "text" }) 在建立了文字索引的collection中使用text index的方法有find({$text: {$search: <search string>}})。需要注意的是,一个collection只支持一组文字索引,不管它是否包含多个域。对于复合的文字索引,index对不同域的排序权重不同,通常会基于一套文字权重算法。另外,文字索引不提供排序上的支持。

此外,由于MongoDB还支持空间数据的查询,其存储引擎也提供了相关的2d索引,具体原理请参考后续章节。哈希索引也在MongoDB的支持范围内,可通过hash索引更快地查找某域内值等于查询值的文档。这种索引主要用于哈希分片,一种MongoDB内部用于在集群上提供可用性的方法。

为了支持更为丰富灵活的特性,MongoDB还支持部分索引,如稀疏和基于生命时间TTL的索引。稀疏索引相较于正常的索引更小,因为其索引只建立在有该域的文档上,而正常索引对于无该域的文档会使用null进行记录。TTL索引则只会记录符合给定时间条件的文档。这些索引都可以通过创建索引的第二个参数位的object指定,如以下的例子所示。

  • 稀疏索引:createIndex({...}, {sparse: true})
  • 部分索引:createIndex({...}, {expireAfterSeconds:3600})(TTL索引),createIndex({...}, { partialFilterExpression: { field_1: {$gt: 50} } })

# 索引在查询中的行为

在进行查询时,索引会帮助查询范围内文档的指令定位起始和结束项,这两项可以通过B-树中的非叶子节点结构快速确定。在两项中间遍历时,则通过叶子节点之间的双向链表进行查找。

当涉及到排序时,如果排序的键及其方向符合index的要求(键出现的顺序与索引完全或前部重合且索引方向完全相同或相反),则依据索引遍历的方向会根据指定的排序方向进行。当不符合这些要求时,索引仅用于确认上下界的范围,而排序会根据返回的结果在内存中进行。

对于可能使用多个索引的查询,实际执行中MongoDB会创建一组类似于固定时间的Dummy任务(executionPlan)的方式侦测使用哪一个索引最快,并根据返回最快的那个计划执行。计划中使用的索引并不一定只有一个,存储引擎可能会使用多个索引或是其交集进行查询。

# 聚合的编写经验

由上述的讨论以及一些此前的经验,我们可以总结出一些聚合编写的实践方法。

  • 在关键的查询键上建立必要的索引,且保证索引能够被正确使用。只在查询必须使用的域上建立索引,因为索引会带来额外的性能开销,包括存储空间和写延迟。为了保证索引确实发挥了其能力,应该根据文档结构、查询内容建立正确的索引,特别是复合索引。

  • 这点对于$lookup很重要,由于是要比对两侧的键值,使用索引能有效地避免遍历右侧collection,大幅度降低时间复杂度。假设左侧collection有m个文档,右侧collection有n个文档,不使用索引的情况下,由于对左侧每篇文档都需要遍历右侧,时间复杂度为O(m)O(n)=O(mn)O(m)*O(n)=O(mn);当使用索引后,不再需要遍历,而是使用树结构来带的O(log(n))O(log(n))查询,总复杂度下降至O(mlog(n))O(m*log(n))

  • 尽早地减少每个阶段的输入文档数量,由此可以带来时间和空间上的双重优化。特别地,减少下一阶段的文档数量对于加速$lookup阶段至关重要。由于该阶段使用的是left join,左侧的文档数m始终具有线性复杂度,其数量越少则阶段的性能越好。

  • 另外,在lookup阶段,可以优化聚合的join方向以以提升查询性能,这是为了调换m、n的数量,从而将线性复杂度的操作数减少。

# 总结

在这一章中,我们总结了索引和聚合的使用方法并对其原理进行了简单的探索。下一章,我们将对MongoDB内部的执行和在集群上的运行进行讨论。