mongodb 权威指南 聚合框架
管道、阶段和可调参数
聚合框架是 MongoDB 中的一组分析工具,可以对一个或多个集合中的文档进行分析。聚合框架基于管道的概念。使用聚合管道可以从 MongoDB 集合获取输入,并将该集合中的文档传递到一个或多个阶段,每个阶段对其输入执行不同的操作(参见图 7-1)。每个阶段都将之前阶段输出的内容作为输入。所有阶段的输入和输出都是文档——可以称为文档流

每个阶段都会提供一组旋钮或可调参数(tunables),可以通过控制它们来设置该阶段的参数,以执行任何感兴趣的任务。一个阶段会执行某种类型的通用任务,我们会为正在使用的特定集合以及希望该阶段如何处理这些文档设置阶段的参数。这些可调参数通常采用运算符的形式,可以使用这些运算符来修改字段、执行算术运算、调整文档形状、执行某种累加任务或其他各种操作
阶段入门:常见操作
为了开发聚合管道,我们将研究如何构建一些管道,其中包含你已经熟悉的操作。下面会介绍匹配(match)、投射(project)、排序(sort)、跳过(skip)和限制(limit)这5 个阶段
下面是一个包含 Facebook 公司数据的示例文档
作为第一个聚合示例,我们对 2004 年成立的所有公司进行简单的过滤

现在在管道中添加一个投射阶段来将每个文档的输出减少到几个字段。排除 "_id" 字段,但将 "name" 字段和"founded_year" 字段包含在内。管道如下所示

下面来更详细地了解一下这个聚合管道。首先注意 aggregate方法的使用。这是要运行聚合查询时调用的方法。要进行聚合,就需要传入一个聚合管道。管道是一个以文档为元素的数组。每个文档必须规定一个特定的阶段运算符。本例中使用了包含两个阶段的管道:一个是用于过滤的匹配阶段,另一个是投射阶段。在投射阶段中,每个文档的输出被限制为只有两个字段
现在进一步扩展管道,再包括一个限制阶段。我们将使用相同的查询进行匹配,但是把结果集限制为 5,然后投射出想要的字段。为简单起见,将输出限制为每个公司的名称

注意,构建的这条管道已在投射阶段之前进行限制。如果先运行投射阶段,然后再进行限制,那么就像下面的查询一样,将得到完全相同的结果,但这样就必须在投射阶段传递数百个文档,最后才能将结果限制为 5 个

无论 MongoDB 查询规划器在给定版本中进行何种类型的优化,都应该始终注意聚合管道的效率。确保在构建管道时限制从一个阶段传递到另一个阶段的文档数量
然而,如果顺序很重要,那么就需要在限制阶段之前进行排序。排序的工作方式与我们已经看到的类似,只是在聚合框架中,会将排序指定为管道中的一个阶段,如下所示(在本例中,将按名称升序排列)

最后,再将跳过阶段包含进来。先进行排序,然后跳过前 10 个文档,并再次将结果集限制为 5 个文档

表达式
聚合框架支持许多表达式类型
-
布尔表达式允许使用 AND、OR 和 NOT。
-
集合表达式允许将数组作为集合来处理。特别地,可以取两个或多个集合的交集或并集,也可以取两个集合的差值并执行一些其他的集合运算。
-
比较表达式能够表达许多不同类型的范围过滤器。
-
算术表达式能够计算上限(ceiling)、下限(floor)、自然对数和对数,以及执行简单的算术运算,比如乘法、除法、加法和减法。甚至可以执行更复杂的运算,比如计算值的平方根。
-
字符串表达式允许连接、查找子字符串,以及执行与大小写和文本搜索相关的操作。
-
数组表达式为操作数组提供了强大的功能,包括过滤数组元素、对数组进行分割或从特定数组中获取某一个范围的值。
-
变量表达式在本书中不会深入研究,这类表达式允许处理文字、解析日期值及条件表达式。
-
累加器提供了计算总和、描述性统计和许多其他类型值的能力
$project
在上述的聚合管道中已经介绍了一些简单的投射操作,下面介绍一些稍微复杂的投射操作。首先看一下如何提取嵌套字段。在以下管道中进行一个匹配操作

$unwind
在聚合管道中处理数组字段时,通常需要包含一个或多个展开(unwind)阶段。这允许我们将指定数组字段中的每个元素都形成一个输出文档,如图 7-4 所示

在图 7-4 上部有一个输入文档,它有 3 个键及其相应的值。第三个键的值是一个包含 3 个元素的数组。如果在这种类型的输入文档中运行 $unwind,并配置为展开 key3 字段,那么将生成类似图 7-4 下部所示的文档。这点可能不太直观,在每个输出文档中都会有一个 key3 字段,但是该字段包含的是一个值而不是数组,并且该数组中的每个元素都将有一个单独的文档。换句话说,如果数组中有 10 个元素,则展开阶段将生成 10 个输出文档
回到 companies 的例子,看看展开阶段的使用。我们将从下面的聚合管道开始。注意,与上一节一样,在这个管道中,只是简单地匹配特定的投资方,并通过投射阶段从内嵌的funding_rounds 文档中提取数值

同样,下面是这个集合中文档的数据模型示例

聚合查询将产生如下结果

该查询生成了同时具有 "amount" 数组和 "year" 数组的文档,因为我们正在访问 "funding_rounds" 数组中每个元素的"raised_amount" 字段和 "funded_year" 字段
为了解决这个问题,可以在聚合管道中的投射阶段之前包含一个展开阶段,并通过指定应该展开的 "funding_rounds" 数组来参数化这个阶段(参见图 7-5

再次回到 Facebook 的例子,可以看到每轮融资都有"raised_amount" 字段和 "funded_year" 字段。展开阶段将为 "funding_rounds" 数组的每个元素生成一个输出文档。在本例中这个值是字符串,但是无论值是哪种类型,展开阶段都将为每个元素生成一个输出文档。以下是更新后的聚合查询

展开阶段会为接收到的每个文档生成一个精确的副本。除了"funding_rounds" 字段,所有字段都具有相同的键和值。它不是一个 "funding_rounds" 文档的数组,而是单独的文档,此文档对应了一个单独的融资轮

现在向输出文档中添加一个额外的字段 基于展开的数组进行二次查询
数据如下

新增的查询语句

数组表达式
首先要介绍的是过滤器表达式。过滤器表达式根据过滤条件选择数组中的元素子集。再次使用 companies 数据集,用相同的条件匹配 Greylock 参与的融资轮。下面看一下这个管道中的 rounds 字段

rounds 字段使用了一个过滤器表达式。$filter 运算符用来处理数组字段,并指定必须提供的选项。$filter 的第一个选项是input。对于 input,只需为其指定一个数组。本例使用了一个字段路径说明符来标识在 companies 集合的文档中找到的"funding_rounds" 数组。接下来指定这个 "funding_rounds"数组在过滤器表达式的其余部分中使用的名称。然后,作为第三个选项,需要指定一个条件。这个条件应该提供用于过滤作为输入的任何数组的条件,选择一个子集。在本例中,所过滤的是只选择那些 "funding_rounds" 的 "raised_amount" 大于或等于 100 000 000 的元素
在指定条件时,我们使用了 $$。$$ 用来引用在表达式中定义的变量。as 子句在过滤器表达式中定义了一个变量。由于在 as子句中对这个变量进行了标记,因此这个变量的名称是"round"。这是为了消除字段路径中对变量引用的歧义。在本例中,比较表达式会接受一个由两个值组成的数组,如果提供的第一个值大于或等于第二个值,则返回 true
现在考虑一下,如果给定了这个过滤器,那么这个管道的投射阶段将生成什么文档。输出文档会有"name"、"founded_year" 和 "rounds" 字段。"rounds" 的值会是由匹配过滤条件(募集的金额大于 100 000 000 美元)的元素所组成的数组。
$arrayElemAt 运算符允许选择数组中特定位置的元素。下面的管道提供了一个使用 $arrayElemAt 的例子

注意在投射阶段中使用 $arrayElemAt 的语法。这里定义了一个想要投射出来的字段,并指定了一个文档,以 $arrayElemAt作为字段名,以一个双元素数组作为值。第一个元素应该是一个字段路径,用于指定要从中选择的数组字段。第二个元素标识了数组中的位置。记住数组是从 0 开始索引的
与 $arrayElemAt 相关的是 $slice 表达式,其允许在数组中从一个特定的索引开始按顺序返回多个元素

在这里,同样使用 funding_rounds 数组,从索引 1 开始并在数组中获取 3 个元素。在这个数据集中,也许我们对第一轮融资并不那么感兴趣,或者只想了解一些早期的融资轮,而不是第一轮
而,最常见的操作可能是确定数组的大小或长度。可以使用 $size 运算符执行此操作:

在投射阶段中使用时,$size 表达式只是简单地提供了一个值,即数组中的元素个数
累加器
聚合框架提供的累加器可以执行对特定字段中的所有值进行求和($sum)、计算平均值($avg)等操作。$first 和 $last 也被视为累加器,因为在它们所在的阶段中所有经过的文档的值都会被检查。$max 和 $min 是另外两个累加器的例子,它们会查看文档流并只保存看到的其中一个值。可以使用$mergeObjects 将多个文档合并为单个文档
还有用于数组的累加器。当文档通过管道传递时,可以将值$push 到数组中。$addToSet 与 $push 非常相似,只是它可以确保结果数组中不包含重复的值
在 MongoDB 3.2 之前,累加器只能在分组阶段使用。MongoDB 3.2 引入了在投射阶段访问部分累加器的功能。累加器在分组阶段和投射阶段的主要区别是,在投射阶段,像 $sum和 $avg 这样的累加器必须在单个文档中对数组进行操作,而分组阶段中的累加器能够跨多个文档对值进行计算,这一点在后文中可以看到
在投射阶段使用累加器
下面从一个在投射阶段使用累加器的例子开始。注意,匹配阶段用于过滤包含 "funding_rounds" 字段且 funding_rounds数组不为空的文档

因为 $funding_rounds 的值是每个公司文档中的一个数组,所以可以使用累加器。记住,在投射阶段,累加器必须用在数组值的字段上。在本例中,我们可以做一些很酷的事情。可以很容易地识别出数组中的最大值,方法是进入数组中的内嵌文档,并将最大值投射到输出文档中

再举一个例子,使用 $sum 累加器来计算集合中每个公司的总资金

分组简介
分组阶段执行的功能类似于 SQL 中的 GROUP BY命令。在分组阶段,可以将多个文档的值聚合在一起并对它们执行某种类型的聚合操作,比如计算平均值。来看一个例子

这里,我们使用分组阶段将所有公司根据其成立年份聚合在一起,然后计算每年的平均员工数。该管道的输出如下

输出中包括文档类型的 "_id" 以及平均员工数
可以看到,所构建的管道有两个阶段:分组阶段和排序阶段。分组阶段的基础是 "_id" 字段,我们将其指定为文档的一部分。这是 $group 运算符本身的值,其解释是非常严格的
来看另一个例子。我们在 companies 数据集中还没有考虑"relationships" 字段。"relationships" 字段以如下形式出现在文档中

"relationships" 字段让我们能够深入研究并寻找那些以某种方式与很多公司有关联的人。请看以下聚合查询

对 "relationships.person" 字段进行匹配。如果看一下Facebook 示例文档,就可以了解关系是如何构建的以及这样做的含义。过滤出所有 "person" 不为 null 的关系。然后投射出匹配文档的所有关系,仅将关系传递到管道的下一个阶段,也就是展开阶段。接下来对关系进行展开,以使数组中的每个关系都进入随后的分组阶段。在分组阶段,使用字段路径来标识每个 "relationship" 文档中的人。具有相同 "person" 值的所有文档会被分在一组。正如之前看到的,将文档作为分组的值是非常合适的。因此,每个与文档的名字、姓氏和 permalink 匹配的人都会聚合在一起。使用 $sum 累加器来计算与每个人有关的关系数量。最后按照降序进行排序
分组阶段中的_id字段
再讨论一下 _id 字段,并看看在分组聚合阶段为该字段构造值的一些最佳实践。本节会通过一些示例来说明通常对文档进行分组的几种方式。作为第一个例子,请看如下管道

这个管道的输出如下所示

在输出的文档中有两个字段:"_id" 和 "companies"。每个文档都包含一个在 "founded_year" 内成立的公司列表,"companies" 是由公司名称组成的数组。注意这里是如何在分组阶段构造 "_id" 字段的。为什么不直接提供成立年份,而是将其放入一个标有 "founded_year" 的文档中呢?这样做的原因是,如果不标注分组的值,那么就不清楚是按照公司成立的年份进行分组的。为了避免混淆,最佳实践是显式地标注分组的值
在某些情况下可能需要使用另一种方法,其中 _id 的值是由多个字段组成的文档。本例实际上是根据成立年份和类别代码对文档进行分组的

在分组阶段使用具有多个字段的文档作为 _id 值是完全没问题的。在某些情况下,以下这样的做法可能是必需的

在这种情况下,我们根据公司 IPO 的年份对文档进行分组,而这个年份实际上是内嵌文档的一个字段。通常的做法是使用内嵌文档的字段路径作为在分组阶段中分组的值。在本例中,输出如下所示

本节的示例使用了一个之前没有见过的累加器:$push。当分组阶段在其输入流中处理文档时,$push 表达式会将结果的值添加到其在运行过程中所构建的数组中。在前面的管道中,分组阶段创建了一个由公司名称组成的数组
$lookup
主要功能 是将每个输入待处理的文档,经过$lookup 阶段的处理,输出的新文档中会包含一个新生成的数组列(户名可根据需要命名新key的名字 )。数组列存放的数据 是 来自 被Join 集合的适配文档,如果没有,集合为空(即 为[ ])
基本语法{
$lookup:
{
from: <collection to join>,
localField: <field from the input documents>,
foreignField: <field from the documents of the "from" collection>,
as: <output array field>
}
}

案例
db.orders.aggregate([
{
$lookup:
{
from: "inventory",
localField: "item",
foreignField: "sku",
as: "inventory_docs"
}
}
])
db.orders.insert({ "_id" : 1, "item" : "MON1003", "price" : 350, "quantity" : 2, "specs" :[ "27 inch", "Retina display", "1920x1080" ], "type" : "Monitor" })
阶段聚合
db.orders.aggregate([
{
$unwind: "$specs"
},
{
$lookup:
{
from: "inventory",
localField: "specs",
foreignField: "size",
as: "inventory_docs"
}
},
{
$match: { "inventory_docs": { $ne: [] } }
}
])
分组与投射
总结一下对分组聚合阶段的讨论,看看无法在投射阶段使用的几个额外的累加器。这是为了鼓励你更深入地思考累加器在投射阶段和分组阶段的作用。例如,考虑以下聚合查询


同样,还是展开 funding_rounds 并按照时间排序。然而,本例并未将 funding_rounds 作为数组累积到一起,而是使用了两个尚未介绍过的累加器:$first 和 $last。$first 表达式只是保存通过输入流传入阶段的第一个值。$last 表达式则会跟踪所有传入分组阶段的值并保留最后一个。与 $push 一样,$first 和 $last 是不能在投射阶段使用的,因为投射阶段的目的并不是基于经过的多个文档来对值进行累加。相反,它们是用来调整单个文档形状的。除了 $first 和 $last,本例还使用了 $sum 来计算融资轮的总数。这个表达式可以将其值指定为 1。这样的 $sum 表达式用来计算它在每个分组中所看到的文档数量。最后,这个管道包含了一个相当复杂的投射阶段。然而,它真正的作用只是让输出变得更美观。这个投射阶段既没有展示first_round 的值,也没有展示首轮和末轮融资的整个文档,而是创建了一个摘要。注意,这种做法维护了良好的语义,因为每个值都被清楚地进行了标记。对于 first_round,我们将生成一个简单的内嵌文档,其中只包含金额、年份等基本细节,这些这些值是从原始的融资轮文档中提取出来的,并最终形成了$first_round。投射阶段中的 $last_round 也做了类似的操作。最后,此投射阶段将基于输入文档计算出的 num_rounds 值和total_raised 值传递到输出文档
将聚合管道结果写入集合中
作为两个特定的阶段,$out 和 $merge 可以将聚合管道生成的文档写入集合中。这两个阶段不能同时使用,并且任一阶段必须是聚合管道的最后一个阶段
$merge 是在 MongoDB 4.2中引入的,如果可以使用的话,它是将结果写入集合中的首选方式。$out 有一些限制:它只能写入相同的数据库;如果集合已经存在,那么它会覆盖任何现有的集合;它不能写入已分片的集合中。$merge 可以写入任何数据库和集合中,无论是否分片。$merge 还可以在处理现有集合时对结果进行合并(插入新文档、与现有文档合并、操作失败、保留现有文档或使用自定义更新处理所有文档)。但使用 $merge 的真正优势在于,它可以创建按需生成的物化视图(materialized view),在管道运行的过程中,输出到集合的内容会进行增量更新。

浙公网安备 33010602011771号