mongodb4.4 Aggregation
如果需要进行数据分析,那么可以使用MongoDB的聚合框架,可以对一个或多个集合中的文档进行分析。聚合框架基于管道的概念。使用聚合管道可以从 MongoDB 集合获取输入,并将该集合中的文档传递到一个或多个阶段,每个阶段对其输入执行不同的操作。每个阶段都将之前阶段输出的内容作为输入。所有阶段的输入和输出都是文档——可以称为文档流。
如果你熟悉 Linux shell 中的管道,比如 bash,那么这是一个非常相似的概念。每个阶段都有其特定的工作。它会接收特定形式的文档并产生特定的输出,该输出本身就是文档流。可以在管道的终点对输出进行访问,这与执行 find 查询的方式非常相似。也就是说,我们获取一个文档流,然后对其做一些处理,无论是创建某种类型的报告、生成一个网站,还是其他类型的任务。
现在来更深入地研究各个阶段。在聚合管道中,一个阶段就是一个数据处理单元。它一次接收一个输入文档流,一次处理一个文档,并且一次产生一个输出文档流。
每个阶段都会提供一组旋钮或可调参数(tunables),可以通过控制它们来设置该阶段的参数,以执行任何感兴趣的任务。一个阶段会执行某种类型的通用任务,我们会为正在使用的特定集合以及希望该阶段如何处理这些文档设置阶段的参数。这些可调参数通常采用运算符的形式,可以使用这些运算符来修改字段、执行算术运算、调整文档形状、执行某种累加任务或其他各种操作。
特别注意:通常,我们希望在单个管道中包含多个相同类型的阶段。例如,我们可能希望执行一个初始过滤器,这样就不必将整个集合都传递到管道中了。稍后,在进行一些其他处理之后,我们可能希望应用一系列不同的条件进一步进行过滤。
概括来说,管道是与 MongoDB 集合一起使用的。它们由阶段组成,每个阶段对其输入执行不同的数据处理任务,并生成文档以作为输出传递到下一个阶段。最终,在处理结束时,管道会产生一些输出,这些输出可以用来在应用程序中执行某些操作,或者被发送到某个集合以供后续使用。在许多情况下,为了执行所需的分析,我们会在单个管道中包含多个相同类型的阶段。
先看一个最简单的聚合管道示例:
db.orders.insertMany( [ { _id: 0, name: "Pepperoni", size: "small", price: 19,quantity: 10, date: ISODate( "2021-03-13T08:14:30Z" ) }, { _id: 1, name: "Pepperoni", size: "medium", price: 20,quantity: 20, date : ISODate( "2021-03-13T09:13:24Z" ) }, { _id: 2, name: "Pepperoni", size: "large", price: 21,quantity: 30, date : ISODate( "2021-03-17T09:22:12Z" ) }, { _id: 3, name: "Cheese", size: "small", price: 12,quantity: 15, date : ISODate( "2021-03-13T11:21:39.736Z" ) }, { _id: 4, name: "Cheese", size: "medium", price: 13,quantity:50, date : ISODate( "2022-01-12T21:23:13.331Z" ) }, { _id: 5, name: "Cheese", size: "large", price: 14,quantity: 10, date : ISODate( "2022-01-12T05:08:13Z" ) }, { _id: 6, name: "Vegan", size: "small", price: 17,quantity: 10, date : ISODate( "2021-01-13T05:08:13Z" ) }, { _id: 7, name: "Vegan", size: "medium", price: 18,quantity: 10, date : ISODate( "2021-01-13T05:10:13Z" ) }, { _id: 8, name: "Vegan", size: "medium", price: 18,quantity: 10, date : ISODate( "2021-01-13T05:14:13Z" ) } ] ) db.orders.aggregate( [ // Stage 1: 只查询medium大小的 pizza { $match: { size: "medium" } }, // Stage 2: 按照name分组并且对quantity求和 { $group: { _id: "$name", totalQuantity: { $sum: "$quantity" } } } ] )
1.阶段入门(stages):常见操作
为了开发聚合管道,我们将研究如何构建一些管道,其中包含你已经熟悉的操作。下面会介绍匹配(match)、投射(project)、排序(sort)、跳过(skip)和限制(limit)这 5个阶段。
要完成这些聚合示例,需要使用一个公司的数据集合。该集合中有许多字段,这些字段指定了有关公司的详细信息,比如公司名称、公司简介以及公司成立的时间。还有一些字段描述了公司已进行的数轮融资、公司的重要里程碑,以及该公司是否进行了首次公开发行(IPO),如果是,那么其 IPO 的详细情况是什么。下面是一个包含 Facebook公司数据的示例文档:
db.companies.insert( { "name": "Facebook", "category_code": "social", "founded_year": 2004, "description": "Social network", "funding_rounds": [{ "id": 4, "round_code": "b", "raised_amount": 27500000, "raised_currency_code": "USD", "funded_year": 2006, "investments": [ { "company": null, "financial_org": { "name": "Greylock Partners", "permalink": "greylock" }, "person": null }, { "company": null, "financial_org": { "name": "Meritech Capital Partners", "permalink": "meritech-capital-partners" }, "person": null }, { "company": null, "financial_org": { "name": "Founders Fund", "permalink": "founders-fund" }, "person": null }, { "company": null, "financial_org": { "name": "SV Angel", "permalink": "sv-angel" }, "person": null } ] }, { "id": 2197, "round_code": "c", "raised_amount": 15000000, "raised_currency_code": "USD", "funded_year": 2008, "investments": [ { "company": null, "financial_org": { "name": "European Founders Fund", "permalink": "european-founders-fund" }, "person": null } ] }], "ipo": { "valuation_amount": NumberLong("104000000000"), "valuation_currency_code": "USD", "pub_year": 2012, "pub_month": 5, "pub_day": 18, "stock_symbol": "NASDAQ:FB" } } )
(1).对 2004 年成立的所有公司进行简单的过滤
db.companies.aggregate([ {$match: {founded_year: 2004}}, ])
这相当于使用 find 执行以下操作:
db.companies.find({founded_year: 2004})
现在在管道中添加一个投射阶段来将每个文档的输出减少到几个字段。排除 "_id" 字段,但将 "name" 字段和 "founded_year" 字段包含在内。管道如下所示:
db.companies.aggregate([ { $match: { founded_year: 2004 } }, { $project: { _id: 0, name: 1, founded_year: 1 } } ])
运行聚合查询时调用的方法。要进行聚合,就需要传入一个聚合管道。管道是一个以文档为元素的数组。每个文档必须规定一个特定的阶段运算符。本例中使用了包含两个阶段的管道: 一个是用于过滤的匹配阶段,另一个是投射阶段。在投射阶段中,每个文档的输出被限制为只有两个字段。
匹配阶段会对集合进行过滤,并将结果文档一次一个地传递到投射阶段。然后投射阶段会执行其操作,调整文档形状,并从管道中将输出传递回来。
(2).把结果集限制为 5,然后投射出想要的字段,为简单起见,将输出限制为每个公司的名称
db.companies.aggregate([ {$match: {founded_year: 2004}}, {$limit: 5}, {$project: { _id: 0, name: 1}} ])
注意,构建的这条管道已在投射阶段之前进行限制。如果先运行投射阶段,然后再进行限制,那么就像下面的查询一样,将得到完全相同的结果,但这样就必须在投射阶段传递数百个文档,最后才能将结果限制为 5 个。
db.companies.aggregate([ {$match: {founded_year: 2004}}, {$project: { _id: 0, name: 1}}, {$limit: 5} ])
无论 MongoDB 查询规划器在给定版本中进行何种类型的优化,都应该始终注意聚合管道的效率。确保在构建管道时限制从一个阶段传递到另一个阶段的文档数量。
2.表达式
在构建聚合管道时,了解可以使用的不同类型的表达式是很重要的。聚合框架支持许多表达式类型。具体如下:
(1).布尔表达式允许使用 AND、OR 和 NOT。
(2).集合表达式允许将数组作为集合来处理。特别地,可以取两个或多个集合的交集或并集,也可以取两个集合的差值并执行一些其他的集合运算。
(3).比较表达式能够表达许多不同类型的范围过滤器
(4).算术表达式能够计算上限(ceiling)、下限(floor)、自然对数和对数,以及执行简单的算术运算,比如乘法、除法、加法和减法。甚至可以执行更复杂的运算,比如计算值的平方根。
(5).字符串表达式允许连接、查找子字符串,以及执行与大小写和文本搜索相关的操作。
(6).数组表达式为操作数组提供了强大的功能,包括过滤数组元素、对数组进行分割或从特定数组中获取某一个范围的值。
(7).变量表达式这类表达式允许处理文字、解析日期值及条件表达式。
(8).累加器提供了计算总和、描述性统计和许多其他类型值的能力。
3. $project
(1).首先看一下如何提取嵌套字段。在以下管道中进行一个匹配操作
我们正在筛选 Greylock Partners 参与融资的所有公司。permalink 值为 "greylock",它是此类文档的唯一标识符。
db.companies.aggregate([ { $match: { "funding_rounds.investments.financial_org.permalink": "greylock" } }, { $project: { _id: 0, name: 1, ipo: "$ipo.pub_year", valuation: "$ipo.valuation_amount", funders: "$funding_rounds.investments.financial_org.permalink" } } ]).pretty()
在输出中,每个文档都有一个 "name" 字段和 "funders" 字段。对于那些已经进行过 IPO的公司,"ipo" 字段包含公司上市的年份,"valuation" 字段包含公司在 IPO 时的估值。
注意,在所有文档中,这些都是顶级字段,这些字段的值是从嵌套的文档和数组中提升上来的。你可能已经注意到 funders 显示出了多个值。实际上,我们看到的是数组的数组。
4. $unwind
在聚合管道中处理数组字段时,通常需要包含一个或多个展开(unwind)阶段。这允许我们将指定数组字段中的每个元素都形成一个输出文档,如下图所示:
在上图有一个输入文档,它有 3 个键及其相应的值。第三个键的值是一个包含 3 个元素的数组。如果在这种类型的输入文档中运行 $unwind,并配置为展开 key3 字段,那么将生成类似如上图下部所示的文档。这点可能不太直观,在每个输出文档中都会有一个key3 字段,但是该字段包含的是一个值而不是数组,并且该数组中的每个元素都将有一个单独的文档。换句话说,如果数组中有 10 个元素,则展开阶段将生成 10 个输出文档。
(1).回到 companies 的例子,看看展开阶段的使用
首先先看看没有使用unwind的数据
db.companies.aggregate([ {$match: {"funding_rounds.investments.financial_org.permalink": "greylock"} }, {$project: { _id: 0, name: 1, amount: "$funding_rounds.raised_amount", year: "$funding_rounds.funded_year" }} ])
该查询生成了同时具有 "amount" 数组和 "year" 数组的文档,因为我们正在访问 "funding_rounds" 数组中每个元素的 "raised_amount" 字段和 "funded_year" 字段。
为了解决这个问题,可以在聚合管道中的投射阶段之前包含一个展开阶段,并通过指定应该展开的 "funding_rounds" 数组来参数化这个阶段
以下是更新后的聚合查询:
db.companies.aggregate([ { $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} }, { $unwind: "$funding_rounds" }, { $project: { _id: 0, name: 1, amount: "$funding_rounds.raised_amount", year: "$funding_rounds.funded_year" } } ])
5.数组表达式
(1).现在把注意力转向数组表达式。我们会尝试在投射阶段中使用数组表达式,这个是需要深入研究的。首先要介绍的是过滤器表达式。过滤器表达式根据过滤条件选择数组中的元素子集。再次使用 companies 数据集,用相同的条件匹配 Greylock 参与的融资轮。下面看一下这个管道中的 rounds 字段:
db.companies.aggregate([ { $match: { "funding_rounds.investments.financial_org.permalink": "greylock" } }, { $project: { _id: 0, name: 1, founded_year: 1, rounds: { $filter: { input: "$funding_rounds", as: "round", cond: { $gte: ["$$round.raised_amount", 20000000] } } } } }, { $match: { "rounds.investments.financial_org.permalink": "greylock" } }, ]).pretty()
rounds 字段使用了一个过滤器表达式。 $filter 运算符用来处理数组字段,并指定必须提供的选项。 $filter 的第一个选项是 input。对于 input,只需为其指定一个数组。本例使用了一个字段路径说明符来标识在 companies 集合的文档中找到的 "funding_rounds" 数组。接下来指定这个 "funding_rounds" 数组在过滤器表达式的其余部分中使用的名称。然后,作为第三个选项,需要指定一个条件。这个条件应该提供用于过滤作为输入的任何数组的条件,选择一个子集。在本例中,所过滤的是只选择那些 "funding_rounds" 的"raised_amount" 大于或等于 20000000 的元素。
(2). $arrayElemAt 运算符允许选择数组中特定位置的元素。下面的管道提供了一个使用 $arrayElemAt 的例子
> db.companies.aggregate([ ... { $match: { "founded_year": 2004 } }, ... { $project: { ... _id: 0, ... name: 1, ... founded_year: 1, ... first_round: { $arrayElemAt: [ "$funding_rounds", 0 ] }, ... last_round: { $arrayElemAt: [ "$funding_rounds", -1 ] } ... } } ... ]).pretty() { "name" : "Facebook", "founded_year" : 2004, "first_round" : { "id" : 4, "round_code" : "b", "raised_amount" : 27500000, "raised_currency_code" : "USD", "funded_year" : 2006, "investments" : [ { "company" : null, "financial_org" : { "name" : "Greylock Partners", "permalink" : "greylock" }, "person" : null }, { "company" : null, "financial_org" : { "name" : "Meritech Capital Partners", "permalink" : "meritech-capital-partners" }, "person" : null }, { "company" : null, "financial_org" : { "name" : "Founders Fund", "permalink" : "founders-fund" }, "person" : null }, { "company" : null, "financial_org" : { "name" : "SV Angel", "permalink" : "sv-angel" }, "person" : null } ] }, "last_round" : { "id" : 2197, "round_code" : "c", "raised_amount" : 15000000, "raised_currency_code" : "USD", "funded_year" : 2008, "investments" : [ { "company" : null, "financial_org" : { "name" : "European Founders Fund", "permalink" : "european-founders-fund" }, "person" : null } ] } } >
注意在投射阶段中使用 $arrayElemAt 的语法。这里定义了一个想要投射出来的字段,并指定了一个文档,以 $arrayElemAt 作为字段名,以一个双元素数组作为值。第一个元素应该是一个字段路径,用于指定要从中选择的数组字段。第二个元素标识了数组中的位置。记住数组是从 0 开始索引的。
在许多情况下,数组的长度不容易获得。选择从数组末尾开始的数组位置可以使用负整数。数组中的最后一个元素用 -1 标识。
(3).与 $arrayElemAt 相关的是 $slice 表达式,其允许在数组中从一个特定的索引开始按顺序返回多个元素
> db.companies.aggregate([ ... { $match: { "founded_year": 2004 } }, ... { $project: { ... _id: 0, ... name: 1, ... founded_year: 1, ... early_rounds: { $slice: [ "$funding_rounds", 1, 3 ] } ... } } ... ]).pretty() { "name" : "Facebook", "founded_year" : 2004, "early_rounds" : [ { "id" : 2197, "round_code" : "c", "raised_amount" : 15000000, "raised_currency_code" : "USD", "funded_year" : 2008, "investments" : [ { "company" : null, "financial_org" : { "name" : "European Founders Fund", "permalink" : "european-founders-fund" }, "person" : null } ] } ] } >
(4).过滤和选择数组的单个元素或片段是对数组执行的常见操作之一。然而,最常见的操作可能是确定数组的大小或长度。可以使用 $size 运算符执行此操作
db.companies.aggregate([ { $match: { "founded_year": 2004 } }, { $project: { _id: 0, name: 1, founded_year: 1, total_rounds: { $size: "$funding_rounds" } } } ]).pretty()
在投射阶段中使用时, $size 表达式只是简单地提供了一个值,即数组中的元素个数。
6.累加器
累加器本质上是另一种类型的表达式,因为它的值是从多个文档中的字段计算得来的。
在投射阶段使用累加器,下面从一个在投射阶段使用累加器的例子开始。注意,匹配阶段用于过滤包含 "funding_rounds" 字段且 funding_rounds 数组不为空的文档:
db.companies.aggregate([ { $match: { "funding_rounds": { $exists: true, $ne: [ ]} } }, { $project: { _id: 0, name: 1, largest_round: { $max: "$funding_rounds.raised_amount" } } } ])
再举一个例子,使用 $sum 累加器来计算集合中每个公司的总资金:
db.companies.aggregate([ { $match: { "funding_rounds": { $exists: true, $ne: [ ]} } }, { $project: { _id: 0, name: 1, total_funding: { $sum: "$funding_rounds.raised_amount" } } } ])
7.分组简介
(1).分组阶段中的_id字段
db.companies.aggregate([ { $match: { founded_year: { $gte: 2000 } } }, { $group: { _id: { founded_year: "$founded_year" }, companies: { $push: "$name" } } }, { $sort: { "_id.founded_year": 1 } } ]).pretty()
在输出的文档中有两个字段:"_id" 和 "companies"。每个文档都包含一个在 "founded_year" 内成立的公司列表,"companies" 是由公司名称组成的数组。
在某些情况下可能需要使用另一种方法,其中 _id 的值是由多个字段组成的文档。
db.companies.aggregate([ { $match: { founded_year: { $gte: 2000 } } }, { $group: { _id: { founded_year: "$founded_year", category_code: "$category_code" }, companies: { $push: "$name" } } }, { $sort: { "_id.founded_year": 1 } } ]).pretty()
当分组阶段在其输入流中处理文档时, $push 表达式会将结果的值添加到其在运行过程中所构建的数组中。在前面的管道中,分组阶段创建了一个由公司名称组成的数组。
db.companies.aggregate([ { $group: { _id: { ipo_year: "$ipo.pub_year" }, companies: { $push: "$name" } } }, { $sort: { "_id.ipo_year": 1 } } ]).pretty()
在看一个完整的例子:
db.companies.aggregate([ { $match: { "relationships.person": { $ne: null } } }, { $project: { relationships: 1, _id: 0 } }, { $unwind: "$relationships" }, { $group: { _id: "$relationships.person", count: { $sum: 1 } } }, { $sort: { count: -1 } } ])
(2).分组与投射
db.companies.aggregate([ { $match: { funding_rounds: { $ne: [] } } }, { $unwind: "$funding_rounds" }, { $sort: { "funding_rounds.funded_year": 1, "funding_rounds.funded_month": 1, "funding_rounds.funded_day": 1 } }, { $group: { _id: { company: "$name" }, funding: { $push: { amount: "$funding_rounds.raised_amount", year: "$funding_rounds.funded_year" } } } }, ]).pretty()
这里,首先将 funding_rounds 数组不为空的文档过滤出来,然后展开 funding_rounds。这样,每个公司的 funding_rounds 数组中的每个元素在排序阶段和分组阶段都会有一个文档。这个管道中的排序阶段按照年、月、日进行排序,全部都是升序。这意味着,这一阶段会首先输出最早的几轮融资。在排序之后的分组阶段,根据公司名称进行分组,并使用 $push 累加器来构造排序后的融资轮数组。由于在排序阶段已经对所有融资轮进行了全局排序,因此每个公司的 funding_rounds 数组都是排好序的。
在这个管道中使用了 $push 来生成一个数组。本例指定了 $push 表达式将文档添加到数组的末尾。由于各轮融资都是按时间顺序进行的,因此将其排在末尾可以保证每家公司的融资金额是按时间顺序进行排序的。 $push 表达式仅适用于分组阶段。这是因为分组阶段被设计成了接受文档的输入流,并通过依次处理每个文档来对值进行累加操作。另外,投射阶段会单独处理输入流中的每个文档。
再看另一个例子。这个管道有点儿长,但它其实是建立在前面例子基础上的:
db.companies.aggregate([ { $match: { funding_rounds: { $exists: true, $ne: [] } } }, { $unwind: "$funding_rounds" }, { $sort: { "funding_rounds.funded_year": 1, "funding_rounds.funded_month": 1, "funding_rounds.funded_day": 1 } }, { $group: { _id: { company: "$name" }, first_round: { $first: "$funding_rounds" }, last_round: { $last: "$funding_rounds" }, num_rounds: { $sum: 1 }, total_raised: { $sum: "$funding_rounds.raised_amount" } } }, { $project: { _id: 0, company: "$_id.company", first_round: { amount: "$first_round.raised_amount", article: "$first_round.source_url", year: "$first_round.funded_year" }, last_round: { amount: "$last_round.raised_amount", article: "$last_round.source_url", year: "$last_round.funded_year" }, num_rounds: 1, total_raised: 1, } }, { $sort: { total_raised: -1 } } ]).pretty()
同样,还是展开 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 值传递到输出文档。
官方地址:https://www.mongodb.com/docs/upcoming/aggregation/