首页 > 代码库 > 【翻译】MongoDB指南/聚合——聚合管道

【翻译】MongoDB指南/聚合——聚合管道

原文地址】https://docs.mongodb.com/manual/

聚合

聚合操作处理数据记录并返回计算后的结果。聚合操作将多个文档分组,并能对已分组的数据执行一系列操作而返回单一结果。MongoDB提供了三种执行聚合的方式:聚合管道,map-reduce方法和单一目的聚合操作。

聚合管道

MongoDB的聚合框架模型建立在数据处理管道这一概念的基础之上。文档进入多阶段管道中,管道将文档转换为聚合结果。最基本的管道阶段类似于查询过滤器和修改输出文档形式的文档转换器。

其他的管道为分组和排序提供一些工具,可通过指定一个或多个字段完成分组或排序;同时提供了聚合数组内容的工具,操作的数组包括文档数组。另外,聚合阶段能够使用一些运算符,完成诸如计算均值或连接字符串之类的任务。

管道利用MongoDB本机的操作方法提供了有效的数据聚合操作,并且对于数据聚合来说采用本机的操作方法是首选的。

聚合管道支持在分片集合上执行操作。

聚合管道在它的某些阶段能够使用索引来提高性能。另外,聚合管道有一个内部优化阶段。

 技术分享

Map-Reduce

MongoDB也能够提供map-reduce操作来完成聚合。一般地,map-reduce操作有两个阶段:map 阶段处理每一个文档并将每一个输入文档映射成一个或多个对象,reduce合成map阶段的输出。可选的,map-reduce操作可以有一个finalize阶段以对输出做最后的更改。像其他的聚集操作一样,

 map-reduce操作能够指定查询条件筛选输入文档和对结果进行排序和限制。

map-reduce使用自定义JavaScript方法来实现mapreducefinalize 操作。虽然与聚合管道相比,自定义JavaScript提供了极大的灵活性,

map-reduce比聚合管道效率低且比聚合管道更复杂。

map-reduce可以在分片集合上执行操作。map-reduce操作也能将数据输出到分片集合上。

注:

2.4版本开始,某些mongo shell 方法和特性不支持map-reduce操作。2.4版本也支持同时运行多个JavaScript操作。2.4之前的版本,

JavaScript代码在单线程中执行,对map-reduce操作来说存在并发问题。

 技术分享

单一目的聚合操作

MongoDB还提供了db.collection.count(), db.collection.group(), db.collection.distinct()专用数据库命令。

所有这些操作从一个集合中聚合文档。虽然这些操作提供了简单的实现聚合操作的方式,但是它们缺乏灵活性和聚合管道与

map-reduce相似的性能。

 技术分享

1 聚合管道

聚合管道是一个建立在数据处理管道模型概念基础上的框架。文档进入多阶段管道中,管道将文档转换为聚合结果。

 技术分享

聚合管道提供了map-reduce 的替代品,并且对于 map-reduce的复杂性是多余的聚合任务来说,聚合管道可能是首选的解决方案。

聚合管道对值的类型和返回结果的大小做了限制。

1.1 管道

MongoDB 聚合管道由多个阶段组成。当文档经过各个管道时,每个管道对文档进行变换。对于每一个输入文档,管道各阶段不需要产生输出文档。例如,某些阶段可能会生成新文档或过滤掉一些文档。聚合管道的一些阶段可以在管道中出现多次。

MongoDB提供了可在mongo shell中执行的db.collection.aggregate()方法和聚合管道命令aggregate

1.2 聚合管道表达式

某些管道阶段采用聚合管道表达式作为它的操作数。聚合管道表达式指定了应用于输入文档的转换。聚合管道表达式采用文档结构并且可以包含其他聚合管道表达式。

聚合管道表达式能够仅作用于管道中的当前文档并且不会涉及其他文档数据:聚合管道表达式支持在内存中执行文档转换。

一般地,聚合管道表达式是无状态的并且仅在被聚合处理过程发现时才被求值,但累加器表达式除外。

累加器用在$group阶段,当文档经过这个管道时,它们的状态被保存下来(例如总数,最大值,最小值,相关数据)。

3.2版本中的变化:某些累加器在$project阶段可以使用。然而,在$project阶段使用这些累加器时,这些累加器不会保存它们的状态到文档中。

1.3 聚合管道行为

MongoDB中聚合命令作用于一个集合,在逻辑上将整个集合传入聚合管道。为了优化操作,尽可能地使用下面的策略以避免扫描整个集合。

管道操作符合索引

$match $sort管道操作符能够利用索引,当它们在管道开始处出现时。

2.4版本的变化:$geoNear管道操作符能够利用地理空间索引。当使用$geoNear时,$geoNear管道操作符必须出现在聚合管道的第一阶段。

3.2版本中的变化:从3.2版本开始索引能够覆盖一个聚合管道。在2.6 3.0版本中,索引不能覆盖聚合管道,因为即使管道使用了索引,聚合还是需要利用实际的文档。

较早地过滤

如果你的聚合操作仅需要集合中的一个数据子集,那么使用$match $limit,和$skip阶段来限制最开始进入管道的文档。当被放到管道的开始处时,$match操作使用合适的索引,只扫描集合中匹配到的文档。

在管道的开始处使用后面紧跟了$sort阶段的$match管道阶段,这在逻辑上等价于使用了索引的带有排序的查询操作。尽可能地将$match阶段放在管道的最开始处。

其他的特性

聚合管道有一个内部最优化阶段,这个阶段改进了某些操作的性能。

聚合管道支持分片集合上的操作。

1.4 聚合管道优化

聚合管道操作有一个优化阶段,此阶段试图重塑管道以改进性能。

为查看优化程序如何改进一个特定的聚合管道,在db.collection.aggregate()方法中使用explain 选项。

1.4.1 投影器优化

聚合管道能够判定是否使用集合中字段的一个子集来获得结果。如果使用子集,那么聚合管道将只会使用那些需要的字段以减少管道中传输的数据量。

1.4.2 管道顺序优化

$sort + $match管道顺序优化

当管道顺序为$sort 后跟$match时, $match会移动到$sort之前以减少排序对象的数量。例如,如果管道包含下面的阶段:

{ $sort: { age : -1 } },{ $match: { status: ‘A‘ } }

在优化阶段,优化器将队列顺序改变为下面这样:

{ $match: { status: ‘A‘ } },{ $sort: { age : -1 } }

$skip + $limit管道顺序优化

当管道顺序为$skip 后跟$limit时, $limit会移动到$skip 之前以减少排序对象的数量。顺序改变后,$limit值增加的值为$skip的值。

例如,如果管道包含下面的阶段:

{ $skip: 10 },{ $limit: 5 }

在优化阶段,优化器将队列顺序改变为下面这样:

{ $limit: 15 },{ $skip: 10 }

这种优化为$sort + $limit合并提供更多的机会,例如序列$sort + $skip + $limit

对于分片集合上的聚合操作,这种优化减少了每一个分片返回的结果。

$redact + $match管道顺序优化

当管道包含了之后紧跟$match阶段的$redact阶段时,尽可能地,管道会不时地在 $redact阶段前添加一部分$match阶段。如果添加的$match阶段是管道的开始,管道会在查询的同时使用索引来限制进入管道的文档数量。

例如,如果管道包含下面的阶段:

{ $redact: { $cond: { if: { $eq: [ "$level", 5 ] }, then: "$$PRUNE", else: "$$DESCEND" } } },

{ $match: { year: 2014, category: { $ne: "Z" } } }

优化程序能够在$redact阶段之前添加相同的$match阶段:

{ $match: { year: 2014 } },

{ $redact: { $cond: { if: { $eq: [ "$level", 5 ] }, then: "$$PRUNE", else: "$$DESCEND" } } },

{ $match: { year: 2014, category: { $ne: "Z" } } }

$project + $skip $limit管道顺序优化

3.2版本新增

当管道顺序为$projec后跟$skip或$limit时,$skip或$limit会移动到$projec之前,

例如,如果管道包含下面的阶段:

{ $sort: { age : -1 } },

{ $project: { status: 1, name: 1 } },

{ $limit: 5 }

在优化阶段,优化器将队列顺序改变为下面这样:

{ $sort: { age : -1 } },

{ $limit: 5 },

{ $project: { status: 1, name: 1 } }

这种优化为$sort + $limit合并提供更多的机会,例如序列$sort + $limit

1.4.3 管道合并优化

这个优化阶段将一个管道阶段与它之前的管道阶段合并。一般地,合并发生在阶段重新排序之后。

合并$sort + $limit

$sort后面紧跟$limit时,优化程序能将$limit合并到$sort,这使得排序操作仅保存结果集中的前n条数据并处理它,n是指定的限制,MongoDB只需要在内存中存储n个条目。

当设置allowDiskUse 为true时并且n条数据已经超过了聚合内存的限制,上面这种优化仍然会被采用。

合并$limit + $limit

$limit后面紧跟另一个$limit时,两个阶段合并为一个阶段,合并后的限制值为两者中最小值。

例如,如果管道包含下面的阶段:

{ $limit: 100 },

{ $limit: 10 }

第二个$limit阶段被合并到第一个$limit阶段中,合并后的限制值为100和10中最小的,即10。

{ $limit: 10 }

合并$skip + $skip

$skip后面紧跟另一个$skip时,两个$skip合并为一个$skip,跳过的数量为两者之和。

例如,如果管道包含下面的阶段:

{ $skip: 5 },

{ $skip: 2 }

第二个$skip被合并到第一个$skip中,合并后跳过的数量为5和2之和。

{ $skip: 7 }

合并$match + $match

$match后面紧跟另一个$match时,两个阶段合并为一个结合使用$and$match,跳过的数量为两者之和。

例如,如果管道包含下面的阶段:

{ $match: { year: 2014 } },

{ $match: { status: "A" } }

第二个$match被合并到第一个$match中。

{ $match: { $and: [ { "year" : 2014 }, { "status" : "A" } ] } }

合并$lookup + $unwind

3.2版本新增

$lookup之后紧跟$unwind并且$unwind 操作$lookup的字段,优化阶段能够将$unwind合并到$lookup中。这避免了创建较大的中间文档。

例如,如果管道包含下面的阶段:

{

  $lookup: {

    from: "otherCollection",

    as: "resultingArray",

    localField: "x",

    foreignField: "y"

  }

},

{ $unwind: "$resultingArray"}

优化器将$unwind合并到$lookup中。如果运行聚合的时候使用explain 选项,输出的合并阶段为:
{

  $lookup: {

    from: "otherCollection",

    as: "resultingArray",

    localField: "x",

    foreignField: "y",

    unwinding: { preserveNullAndEmptyArrays: false }

  }

}

1.5例子

下面例子所示的一些序列能够利用重新排序和合并优化。一般地,合并发生在重新排序之后。

序列$sort + $skip + $limit

管道包含$sort阶段,其后接$skip阶段,$skip阶段后接 $limit阶段

{ $sort: { age : -1 } },{ $skip: 10 },{ $limit: 5 }

首先,优化程序将$skip + $limit转化为下面的顺序:

{ $sort: { age : -1 } },

{ $limit: 15 },

{ $skip: 10 }

目前的序列为$sort阶段后跟$limit阶段,管道能够合并这两个过程以减少排序阶段对内存的消耗。

序列$limit + $skip + $limit + $skip

一个管道包含了$limit$skip交替出现的序列:

{ $limit: 100 },

{ $skip: 5 },

{ $limit: 10 },

{ $skip: 2 }

优化程序将{ $skip: 5 } 和{ $limit: 10 } 顺序反转,并增大限制数量:

{ $limit: 100 },

{ $limit: 15},

{ $skip: 5 },

{ $skip: 2 }

优化程序能够将两个$limit合并,将两个$skip合并,结果为:

{ $limit: 15 },

{ $skip: 7 }

1.6 聚合管道限制

使用聚合命令有如下限制:

结果大小限制

2.6版本中变化

2.6版本开始,聚合命令(aggregate)能够返回一个游标或将结果存储在集合中。当返回游标或者将结果存储到集合中时,结果集中的每一个文档受限于BSON文档大小,目前BSON文档大小最大允许为16MB;如果任何一个文档的大小超过了这个值,聚合命令将抛出一个错误。这个限制只作用于返回的文档,在管道中被处理的文档有可能超出这个阈值。从2.6开始,db.collection.aggregate() 方法默认返回游标。

如果不指定游标选项或者将结果存储到集合中,aggregate 命令返回一个BSON文档,文档有一个包含结果集的字段。文档的大小超过了BSON文档允许的最大值,聚合命令将抛出一个错误。

在更早的版本中,aggregate仅能返回一个包含结果集的BSON文档,如果文档的大小超过了BSON文档允许的最大值,聚合命令将抛出一个错误。

内存限制

2.6版本中变化

管道阶段对内存的限制为100MB。如果某一阶段使用的内存超过100MB,MongoDB 会抛出一个错误。为了能够处理大数据集,

使用allowDiskUse选项使聚合管道阶段将数据写入临时文件。

1.7聚合管道和分片集合

聚合管道支持分片集合上的操作。

行为

3.2版本中的变化

如果聚合管道以$match开始,精确地匹配一个片键,整个聚合管道仅运行在匹配到的分片上。之前的版本中,管道会被拆分,合并的工作要在主分片上完成。

对于要运行在多个分片上的聚合操作,如果操作不需要运行在数据库的主分片上,这些操作将会路由结果到任意分片来合并结果以避免数据库主分片过载。

$out阶段和$lookup阶段需要运行在数据库主分片上。

优化

当把聚和管道分成两个部分时,在考虑优化的情况下,拆分管道时确保每一个分片执行阶段数量尽可能多。

要查看管道如何被拆分,使用db.collection.aggregate()explain选项。

1.8 邮政编码数据集上的聚合操作

示例中使用集合zipcodes ,这个集合可以从:http://media.mongodb.org/zips.json处获得。使用mongoimport将数据导入你的mongod 实例。

数据模型

集合zipcodes中的每一文档的样式如下:

{

  "_id": "10280",

  "city": "NEW YORK",

  "state": "NY",

  "pop": 5574,

  "loc": [

    -74.016323,

    40.710537

  ]

}

  • _id字段值为字符串形式的邮政编码。
  • city 字段值为城市名称。一个城市可有多个邮政编码,城市的不同城区邮政编码不同。
  • State字段值为两个字母的州名称缩写。
  • pop字段值为人口数量。
  • Loc字段值为用经纬度表示的方位。

aggregate()方法

aggregate() 方法使用聚合管道处理文档,输出聚合结果。一个聚合管道由多个阶段组成,当文档经过聚集管道各个阶段时,管道处理进入其中的文档。

mongo shell中,aggregate() 方法提供了对aggregate 的包装。

返回人口数量在一千万以上的州

下面的聚合操作返回所有人口数在一千万以上的州:

db.zipcodes.aggregate( [

   { $group: { _id: "$state", totalPop: { $sum: "$pop" } } },

   { $match: { totalPop: { $gte: 10*1000*1000 } } }] )

在这个例子中,聚合管道包含 $group阶段,其后跟$match阶段。

  • $group阶段根据state 字段将zipcode 集合分组,计算每一个州的totalPop字段值,输出结果为每个州对应一个文档。

新的关于每个州的信息的文档包含两个字段:_id 字段和totalPop字段。_id字段值是州的名称,totalPop字段值是经计算后获得的各州的总人口数。为了计算这个值$group阶段使用$sum操作符统计每个州的人口数。

  • 经过$group管道阶段后的在管道中的文档样式如下:

{

  "_id" : "AK",

  "totalPop" : 550043

}

$match阶段过滤分组后的文档,仅输出那些totalPop值大于等于一千万的文档。$match阶段不会修改文档而是输出未修改的匹配到的文档。

与聚合操作等价的SQL语句为:

SELECT state, SUM(pop) AS totalPop 

FROM zipcodes 

GROUP BY state 

HAVING totalPop >= (10*1000*1000)

返回每个州的城市人口平均值

下面的聚合操作返回每个州的城市人口平均值

db.zipcodes.aggregate( [

   { $group: { _id: { state: "$state", city: "$city" }, pop: { $sum: "$pop" } } },

   { $group: { _id: "$_id.state", avgCityPop: { $avg: "$pop" } } }]

)

在这个例子中,聚合操作包含了两个$group阶段。

  • 第一个$group 阶段根据citystate字段组合将文档分组,$sum 表达式根据每个组合计算人口数,并输出文档,每一个城市和州的组合对应一个文档。

上面那个阶段完成后,管道中的文档样式为:
{

  "_id" : {

    "state" : "CO",

    "city" : "EDGEWATER"

  },

  "pop" : 13154

}

  • 第二个$group阶段根据_id.state字段将文档分组(state字段在_id文档内),使用$avg表达式计算每一个城市人口的平均值(avgCityPop)并输出文档,每个州对应一个文档

这个聚合操作返回文档类似于:

{

  "_id" : "MN",

  "avgCityPop" : 5335

}

返回州中规模最大和最小的城市

下面的聚合操作返回每个州人口数最多和最少的城市。

db.zipcodes.aggregate( [

   { $group:

      {

        _id: { state: "$state", city: "$city" },

        pop: { $sum: "$pop" }

      }

   },

   { $sort: { pop: 1 } },

   { $group:

      {

        _id : "$_id.state",

        biggestCity:  { $last: "$_id.city" },

        biggestPop:   { $last: "$pop" },

        smallestCity: { $first: "$_id.city" },

        smallestPop:  { $first: "$pop" }

      }

   },

 

  // the following $project is optional, and

  // modifies the output format.

 

  { $project:

    { _id: 0,

      state: "$_id",

      biggestCity:  { name: "$biggestCity",  pop: "$biggestPop" },

      smallestCity: { name: "$smallestCity", pop: "$smallestPop" }

    }

  }]

)

在这个聚合操作中包含了两个$group阶段,一个$sort阶段,一个$project阶段。

  • 第一个$group 阶段根据citystate字段组合将文档分组,$sum 表达式根据每个组合计算人口数(一个城市可能有多个邮政编码,因为一个城市的不同区有不同的邮政编码),并输出文档,每一个城市和州的组合对应一个文档。这个阶段文档类似于:

{

  "_id" : {

    "state" : "CO",

    "city" : "EDGEWATER"

  },

  "pop" : 13154

}

  • $sort阶段根据pop字段的值为管道中的文档排序,顺序为从小到大;例如递增的顺序。这个操作不会修改文档。
  • 第二个$group 阶段根据_id.state字段对当前已排序的文档分组(例如,state 字段在_id文档中)并输出每个州对应的文档。

这个阶段为每个州计算如下四个字段值:使用$last表达式,$group操作符创建biggestCity biggestPop字段,biggestPop字段值为最大的人口数,biggestCity值为biggestPop对应的城市名称。使用$first 表达式,$group操作符创建了smallestCitysmallestPopsmallestPop为最小的人口数,smallestCitysmallestPop对应的城市名称。

管道中这个阶段的文档类似于:

{

  "_id" : "WA",

  "biggestCity" : "SEATTLE",

  "biggestPop" : 520096,

  "smallestCity" : "BENGE",

  "smallestPop" : 2

}

最后的$project阶段将_id字段重命名为state 并将biggestCity, biggestPop, smallestCity, 和smallestPop移到嵌入式文档biggestCity 和

smallestCity中。

上面这个聚合操作的结果类似于:

{

  "state" : "RI",

  "biggestCity" : {

    "name" : "CRANSTON",

    "pop" : 176404

  },

  "smallestCity" : {

    "name" : "CLAYVILLE",

    "pop" : 45

  }

}

 

1.9 用户引用数据的聚合操作

数据模型

假设一个体育俱乐部有一个包含users集合数据库,users集合中的文档包含用户的加入日期和喜欢的运动,文档样式如下:

{

  _id : "jane",

  joined : ISODate("2011-03-02"),

  likes : ["golf", "racquetball"]

}

{

  _id : "joe",

  joined : ISODate("2012-07-02"),

  likes : ["tennis", "golf", "swimming"]

}

文档规范化和排序

下面的操作返回的文档中,用户名称转成大写并按字母顺序排序。操作如下:

db.users.aggregate(

  [

    { $project : { name:{$toUpper:"$_id"} , _id:0 } },

    { $sort : { name : 1 } }

  ])

Users集合中的所有文档都经过了管道,在管道中执行以下操作:

  • $project操作符:
  • 创建名为name的字段。
  • 使用$toUpper操作符将_id字段值转换成大写。然后将值存储在名为name 的字段中。
  • 阻止_id字段。$project 操作符默认允许_id字段通过,除非明确地阻止。
  • $sort操作符根据name字段对结果进行排序。

聚合操作返回结果为:

{

  "name" : "JANE"},{

  "name" : "JILL"},{

  "name" : "JOE"

}

返回根据加入时间排序后的用户名称

下面的聚合操作返回根据加入月份排序的用户名称,这种聚合操作有助于生成会员更新提醒。

db.users.aggregate(

  [

    { $project :

       {

         month_joined : { $month : "$joined" },

         name : "$_id",

         _id : 0

       }

    },

    { $sort : { month_joined : 1 } }

  ]

)

Users集合中的所有文档都经过了管道,在管道中执行以下操作:

  • $project操作符:
  • 创建两个字段month_joined name
  • 阻止结果集中的id输出。$project 操作符默认允许_id字段通过,除非明确地阻止。
  • $month操作符将joined字段的值转换为以整数表示的月份。然后$project操作符将这些值指定给month_joined字段。
  • $sort操作符根据month_joined字段对结果进行排序。

操作返回的结果为:

{

  "month_joined" : 1,

  "name" : "ruth"},{

  "month_joined" : 1,

  "name" : "harold"},{

  "month_joined" : 1,

  "name" : "kate"}{

  "month_joined" : 2,

  "name" : "jill"

}

 

返回每个月加入会员的总数

下面的操作展示了每个月有多少人成为会员。你或许可以利用这些聚合数据来考虑是否招聘新员工和制定营销策略。

db.users.aggregate(

  [

    { $project : { month_joined : { $month : "$joined" } } } ,

    { $group : { _id : {month_joined:"$month_joined"} , number : { $sum : 1 } } },

    { $sort : { "_id.month_joined" : 1 } }

  ]

)

users 集合中所有文档都经过管道,在管道中执行如下操作:

  • $project操作符创建了一个新字段month_joined
  • $month操作符将joined字段的值转换为以整数表示的月份。然后$project操作符将这些值指定给month_joined字段。
  • $group操作符将所有文档按month_joined值分组,并计算每个month_joined字段值对应多少个文档。特别地,对于一个唯一的

    month_joined值,$group创建了一个新的“每个月”的文档,该文档包含了两个字段:

  • _id字段,包含一个嵌入式文档,嵌入式文档有一个month_joined字段。
  • number字段,这是一个新生成的字段。对每一个包含给定month_joined字段值文档,$sum操作符将number字段值加1.
  • $sort操作符根据month_joine字段将$group操作符处理过的文档排序。

这个聚和操作的结果为:

{

  "_id" : {

    "month_joined" : 1

  },

  "number" : 3},

{

  "_id" : {

    "month_joined" : 2

  },

  "number" : 9},

{

  "_id" : {

    "month_joined" : 3

  },

  "number" : 5}

返回五种最常见的“爱好”

下面的聚合操作选出五个最常见“爱好”。这种类型的分析有助于发展规划。

db.users.aggregate(

  [

    { $unwind : "$likes" },

    { $group : { _id : "$likes" , number : { $sum : 1 } } },

    { $sort : { number : -1 } },

    { $limit : 5 }

  ]

)

users 集合中所有文档都经过管道,在管道中执行如下操作:

  • $unwind操作符将数组likes中的每一个元素分离,并为每一个元素创建一个原文档的新版本。

例如:

下面的文档:

{

  _id : "jane",

  joined : ISODate("2011-03-02"),

  likes : ["golf", "racquetball"]

}

$unwind操作符创建的文档为:

{

  _id : "jane",

  joined : ISODate("2011-03-02"),

  likes : "golf"

}

{

  _id : "jane",

  joined : ISODate("2011-03-02"),

  likes : "racquetball"

}

  • $group操作符根据likes字段值分组并计算每组的数量。使用这些信息,$group创建含有两个字段的新文档:
  • _id字段,包含likes字段值
  • number新生成的字段,对于包含给定likes字段值的每个文档$sum操作符将number1。
  • $sort操作符根据number字段将文档顺序反转。
  • $limit 操作符限制结果集中仅包含前五个文档。

{

  "_id" : "golf",

  "number" : 33},

{

  "_id" : "racquetball",

  "number" : 31},

{

  "_id" : "swimming",

  "number" : 24},

{

  "_id" : "handball",

  "number" : 19},

{

  "_id" : "tennis",

  "number" : 18}

}

 -----------------------------------------------------------------------------------------

转载与引用请注明出处。

时间仓促,水平有限,如有不当之处,欢迎指正。

 

【翻译】MongoDB指南/聚合——聚合管道