MongoDB 4.2——聚合框架

聚合框架

1、管道、阶段和可调参数

聚合框架是 MongoDB 中的一组分析工具,可以对一个或多个集合中的文档进行分析。

聚合框架基于管道的概念。使用聚合管道可以从MongoDB 集合获取输入,并将该集合中的文档传递到一个或多个阶段,每个阶段对其输入执行不同的操作(参见下图)​。每个阶段都将之前阶段输出的内容作为输入。所有阶段的输入和输出都是文档------可以称为文档流。

如果你熟悉 Linux shell 中的管道,比如 bash,那么这是一个非常相似的概念。每个阶段都有其特定的工作。它会接收特定形式的文档并产生特定的输出,该输出本身就是文档流。可以在管道的终点对输出进行访问,这与执行 find 查询的方式非常相似。也就是说,我们获取一个文档流,然后对其做一些处理,无论是创建某种类型的报告、生成一个网站,还是其他类型的任务。

现在来更深入地研究各个阶段。在聚合管道中,一个阶段就是一个数据处理单元。它一次接收一个输入文档流,一次处理一个文档,并且一次产生一个输出文档流(参见下图)​。

每个阶段都会提供一组旋钮或可调参数(tunables)​,可以通过控制它们来设置该阶段的参数,以执行任何感兴趣的任务。一个阶段会执行某种类型的通用任务,我们会为正在使用的特定集合以及希望该阶段如何处理这些文档设置阶段的参数。

这些可调参数通常采用运算符的形式,可以使用这些运算符来修改字段、执行算术运算、调整文档形状、执行某种累加任务或其他各种操作。

在开始研究一些具体示例之前,请牢记在使用管道时还需要特别注意它们的另一个方面。通常,我们希望在单个管道中包含多个相同类型的阶段(参见下图)​。例如,我们可能希望执行一个初始过滤器,这样就不必将整个集合都传递到管道中了。稍后,在进行一些其他处理之后,我们可能希望应用一系列不同的条件进一步进行过滤。

概括来说,管道是与 MongoDB 集合一起使用的。它们由阶段组成,每个阶段对其输入执行不同的数据处理任务,并生成文档以作为输出传递到下一个阶段。最终,在处理结束时,管道会产生一些输出,这些输出可以用来在应用程序中执行某些操作,或者被发送到某个集合以供后续使用。在许多情况下,为了执行所需的分析,我们会在单个管道中包含多个相同类型的阶段。

2、阶段入门:常见操作

为了开发聚合管道,我们将研究如何构建一些管道,其中包含你已经熟悉的操作。下面会介绍匹配(match)​、投射(project)​、排序(sort)​、跳过(skip)限制(limit)这 5 个阶段。

要完成这些聚合示例,需要使用一个公司的数据集合。该集合中有许多字段,这些字段指定了有关公司的详细信息,比如公司名称、公司简介以及公司成立的时间。

还有一些字段描述了公司已进行的数轮融资、公司的重要里程碑,以及该公司是否进行了首次公开发行(IPO)​,如果是,那么其 IPO 的详细情况是什么。下面是一个包含 Facebook 公司数据的示例文档:

js 复制代码
{
  "_id" : "52cdef7c4bab8bd675297d8e",
  "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"
  }
}

作为第一个聚合示例,我们对 2004 年成立的所有公司进行简单的过滤:

js 复制代码
db.companies.aggregate([
    {$match: {founded_year: 2004}},
])

这相当于使用 find 执行以下操作:

js 复制代码
db.companies.find({founded_year: 2004})

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

js 复制代码
db.companies.aggregate([
  {$match: {founded_year: 2004}},
  {$project: {
    _id: 0,
    name: 1,
    founded_year: 1
  }}
])

如果运行一下,则会看到下面这样的输出内容:

js 复制代码
{"name": "Digg", "founded_year": 2004 }
{"name": "Facebook", "founded_year": 2004 }
{"name": "AddThis", "founded_year": 2004 }
{"name": "Veoh", "founded_year": 2004 }
{"name": "Pando Networks", "founded_year": 2004 }
{"name": "Jobster", "founded_year": 2004 }
{"name": "AllPeers", "founded_year": 2004 }
{"name": "blinkx", "founded_year": 2004 }
{"name": "Yelp", "founded_year": 2004 }
{"name": "KickApps", "founded_year": 2004 }
{"name": "Flickr", "founded_year": 2004 }
{"name": "FeedBurner", "founded_year": 2004 }
{"name": "Dogster", "founded_year": 2004 }
{"name": "Sway", "founded_year": 2004 }
{"name": "Loomia", "founded_year": 2004 }
{"name": "Redfin", "founded_year": 2004 }
{"name": "Wink", "founded_year": 2004 }
{"name": "Techmeme", "founded_year": 2004 }
{"name": "Eventful", "founded_year": 2004 }
{"name": "Oodle", "founded_year": 2004 }

下面来更详细地了解一下这个聚合管道。首先注意aggregate 方法的使用。这是要运行聚合查询时调用的方法。要进行聚合,就需要传入一个聚合管道。管道是一个以文档为元素的数组。每个文档必须规定一个特定的阶段运算符。本例中使用了包含两个阶段的管道:一个是用于过滤的匹配阶段,另一个是投射阶段。在投射阶段中,每个文档的输出被限制为只有两个字段。

匹配阶段会对集合进行过滤,并将结果文档一次一个地传递到投射阶段。然后投射阶段会执行其操作,调整文档形状,并从管道中将输出传递回来。

现在进一步扩展管道,再包括一个限制阶段。我们将使用相同的查询进行匹配,但是把结果集限制为 5,然后投射出想要的字段。为简单起见,将输出限制为每个公司的名称:

js 复制代码
db.companies.aggregate([
  {$match: {founded_year: 2004}},
  {$limit: 5},
  {$project: {
    _id: 0,
    name: 1}}
])

结果如下:

js 复制代码
{"name": "Digg"}
{"name": "Facebook"}
{"name": "AddThis"}
{"name": "Veoh"}
{"name": "Pando Networks"}

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

js 复制代码
db.companies.aggregate([
  {$match: {founded_year: 2004}},
  {$project: {
    _id: 0,
    name: 1}},
  {$limit: 5}
])

无论 MongoDB 查询规划器在给定版本中进行何种类型的优化,都应该始终注意聚合管道的效率。确保在构建管道时限制从一个阶段传递到另一个阶段的文档数量。

这需要仔细考虑通过管道的整个文档流。在前面的查询中,我们只对与查询匹配的前 5 个文档感兴趣,而不管它们是如何排序的,因此将其作为第二个阶段是非常合适的。

然而,如果顺序很重要,那么就需要在限制阶段之前进行排序。排序的工作方式与我们已经看到的类似,只是在聚合框架中,会将排序指定为管道中的一个阶段,如下所示(在本例中,将按名称升序排列)​:

js 复制代码
db.companies.aggregate([
    { $match: { founded_year: 2004 } },
    { $sort: { name: 1} },
    { $limit: 5 },
    { $project: {
        _id: 0,
        name: 1 } }
])

可以从 companies 集合中得到如下结果:

js 复制代码
{"name": "1915 Studios"}
{"name": "1Scan"}
{"name": "2GeeksinaLab"}
{"name": "2GeeksinaLab"}
{"name": "2threads"}

注意,现在看到的是一组与前面不同的 5 个公司,它们的前 5 个文档是按名称的字母数字顺序排列的。

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

js 复制代码
db.companies.aggregate([
  {$match: {founded_year: 2004}},
  {$sort: {name: 1}},
  {$skip: 10},
  {$limit: 5},
  {$project: {
    _id: 0,
    name: 1}},
])

可以从 companies 集合中得到如下结果:

js 复制代码
{"name": "1915 Studios"}
{"name": "1Scan"}
{"name": "2GeeksinaLab"}
{"name": "2GeeksinaLab"}
{"name": "2threads"}

注意,现在看到的是一组与前面不同的 5 个公司,它们的前 5 个文档是按名称的字母数字顺序排列的。

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

js 复制代码
db.companies.aggregate([
  {$match: {founded_year: 2004}},
  {$sort: {name: 1}},
  {$skip: 10},
  {$limit: 5},
  {$project: {
    _id: 0,
    name: 1}},
])

再次回顾一下此管道。这个管道有 5 个阶段。首先,过滤 companies 集合,只寻找 "founded_year" 为 2004的文档。然后,根据名称升序排列,跳过前 10 个匹配项,并将最终结果限制为 5 个。最后,将这 5 个文档传递到投射阶段,在这个阶段会重新调整文档形状,使输出的文档只包含公司名称。

这里介绍了如何使用你已经熟悉的操作所形成的阶段来构建管道。聚合框架提供了这些操作,它们对于后文讨论的阶段所要完成的分析类型是必要的。

3、表达式

随着对聚合框架的深入讨论,在构建聚合管道时,了解可以使用的不同类型的表达式是很重要的。聚合框架支持许多表达式类型。

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

4、$project

现在我们将更深入地研究投射阶段和重塑文档形状,探索在开发应用程序时最常见的重塑形状操作。在上述的聚合管道中已经介绍了一些简单的投射操作,下面介绍一些稍微复杂的投射操作。

首先看一下如何提取嵌套字段。在以下管道中进行一个匹配操作:

js 复制代码
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()

作为 companies 集合中文档相关字段的例子,再来看一下 Facebook 文档中的这一片段:

js 复制代码
{
  "_id" : "52cdef7c4bab8bd675297d8e",
  "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"
  }
}

回到我们的匹配操作:

js 复制代码
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()

我们正在筛选 Greylock Partners 参与融资的所有公司。permalink 值为 "greylock",它是此类文档的唯一标识符。下面是 Facebook 文档的另一个视图,只显示了相关字段:

js 复制代码
{
  ...
  "name" : "Facebook",
  ...
  "funding_rounds" : [{
    ...
    "investments" : [{
      ...
      "financial_org" : {
        "name" : "Greylock Partners",
        "permalink" : "greylock"
      },
      ...
    },
    {
      ...
      "financial_org" : {
        "name" : "Meritech Capital Partners",
        "permalink" : "meritech-capital-partners"
      },
      ...
    },
    {
      ...
      "financial_org" : {
        "name" : "Founders Fund",
        "permalink" : "founders-fnd"
      },
      ...
    },
    {
      "company" : null,
      "financial_org" : {
        "name" : "SV Angel",
        "permalink" : "sv-angel"
      },
      ...
    }],
    ...
  ]},
  {
    ...
    "investments" : [{
      ...
      "financial_org" : {
        "name" : "European Founders Fund",
        "permalink" : "european-founders-fund"
      },
      ...
    }]
  }],
  "ipo" : {
    "valuation_amount" : NumberLong("104000000000"),
    "valuation_currency_code" : "USD",
    "pub_year" : 2012,
    "pub_month" : 5,
    "pub_day" : 18,
    "stock_symbol" : "NASDAQ:FB"
  }
}

在这个聚合管道中定义的投射阶段将禁用 "_id" 并包含"name"。它还会提取某些嵌套字段。这个投射操作使用点表示法来表示到达 "ipo" 字段和 "funding_rounds" 字段的路径,以从这些嵌套的文档和数组中选择值。这个投射阶段会将这些值作为文档的顶级字段来输出,如下所示:

js 复制代码
{
  "name" : "Digg",
  "funders" : [
    [
      "greylock",
      "omidyar-network"
    ],
    [
      "greylock",
      "omidyar-network",
      "floodgate",
      "sv-angel"
    ],
    [
      "highland-capital-partners",
      "greylock",
      "omidyar-network",
      "svb-financial-group"
    ]
  ]
}
{
  "name" : "Facebook",
  "ipo" : 2012,
  "valuation" : NumberLong("104000000000"),
  "funders" : [
    [
      "accel-partners"
    ],
    [
      "greylock",
      "meritech-capital-partners",
      "founders-fund",
      "sv-angel"
    ],
    ...
    [
      "goldman-sachs",
      "digital-sky-technologies-fo"
    ]
  ]
}
{
  "name" : "Revision3",
  "funders" : [
    [
      "greylock",
      "sv-angel"
    ],
    [
      "greylock"
    ]
  ]
}
...

在输出中,每个文档都有一个 "name" 字段和 "funders"字段。对于那些已经进行过 IPO 的公司,"ipo" 字段包含公司上市的年份,"valuation" 字段包含公司在 IPO 时的估值。注意,在所有文档中,这些都是顶级字段,这些字段的值是从嵌套的文档和数组中提升上来的。

在投射阶段中,用于指定 ipo、valuation 和 funders 值的 $ 字符表示这些值应被解释为字段路径,并分别用于为每个字段选择应投射的值。

你可能已经注意到 funders 显示出了多个值。实际上,我们看到的是数组的数组。根据 Facebook 示例文档,所有的投资方都被列在一个名为 "investments" 的数组中。阶段中指定了对于每一轮融资,都要为"investments" 数组中的每个条目投射 financial_org.permalink 值。因此,一个由投资方名字组成的数组就建立起来了。

5、$unwind

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

在图上部有一个输入文档,它有 3 个键及其相应的值。第三个键的值是一个包含 3 个元素的数组。如果在这种类型的输入文档中运行 $unwind,并配置为展开key3 字段,那么将生成类似图 7-4 下部所示的文档。这点可能不太直观,在每个输出文档中都会有一个 key3 字段,但是该字段包含的是一个值而不是数组,并且该数组中的每个元素都将有一个单独的文档。换句话说,如果数组中有 10 个元素,则展开阶段将生成 10 个输出文档。

回到 companies 的例子,看看展开阶段的使用。我们将从下面的聚合管道开始。注意,与上一节一样,在这个管道中,只是简单地匹配特定的投资方,并通过投射阶段从内嵌的 funding_rounds 文档中提取数值:

js 复制代码
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"
  }}
])

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

js 复制代码
{
  "_id" : "52cdef7c4bab8bd675297d8e",
  "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"
  }
}

聚合查询将产生如下结果:

js 复制代码
{
  "name" : "Digg",
  "amount" : [
    8500000,
    2800000,
    28700000,
    5000000
  ],
  "year" : [
    2006,
    2005,
    2008,
    2011
  ]
}
{
  "name" : "Facebook",
  "amount" : [
    500000,
    12700000,
    27500000,
    ...

该查询生成了同时具有 "amount" 数组和 "year" 数组的文档,因为我们正在访问 "funding_rounds" 数组中每个元素的 "raised_amount" 字段和 "funded_year" 字段。

为了解决这个问题,可以在聚合管道中的投射阶段之前包含一个展开阶段,并通过指定应该展开的"funding_rounds" 数组来参数化这个阶段(参见下图)​。

再次回到 Facebook 的例子,可以看到每轮融资都有"raised_amount" 字段和 "funded_year" 字段。

展开阶段将为 "funding_rounds" 数组的每个元素生成一个输出文档。在本例中这个值是字符串,但是无论值是哪种类型,展开阶段都将为每个元素生成一个输出文档。以下是更新后的聚合查询:

js 复制代码
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"
  } }
])

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

js 复制代码
{"name": "Digg", "amount": 8500000, "year": 2006 }
{"name": "Digg", "amount": 2800000, "year": 2005 }
{"name": "Digg", "amount": 28700000, "year": 2008 }
{"name": "Digg", "amount": 5000000, "year": 2011 }
{"name": "Facebook", "amount": 500000, "year": 2004 }
{"name": "Facebook", "amount": 12700000, "year": 2005 }
{"name": "Facebook", "amount": 27500000, "year": 2006 }
{"name": "Facebook", "amount": 240000000, "year": 2007 }
{"name": "Facebook", "amount": 60000000, "year": 2007 }
{"name": "Facebook", "amount": 15000000, "year": 2008 }
{"name": "Facebook", "amount": 100000000, "year": 2008 }
{"name": "Facebook", "amount": 60000000, "year": 2008 }
{"name": "Facebook", "amount": 200000000, "year": 2009 }
{"name": "Facebook", "amount": 210000000, "year": 2010 }
{"name": "Facebook", "amount": 1500000000, "year": 2011 }
{"name": "Revision3", "amount": 1000000, "year": 2006 }
{"name": "Revision3", "amount": 8000000, "year": 2007 }

现在向输出文档中添加一个额外的字段。当这样做的时候,你会发现这个聚合管道的一个小问题:

js 复制代码
db.companies.aggregate([
  { $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
  { $unwind: "$funding_rounds" },
  { $project: {
    _id: 0,
    name: 1,
    funder: "$funding_rounds.investments.financial_org.permalink",
    amount: "$funding_rounds.raised_amount",
    year: "$funding_rounds.funded_year"
  } }
])

在添加 "funder" 字段时,有了一个字段路径值,该值将访问 "funding_rounds" 内嵌文档的 "investments" 字段,这个文档是从展开阶段获得的,而对于金融组织来说将选择 permalink 值。注意,这与匹配过滤器中的操作非常相似。输出如下:

js 复制代码
{
  "name" : "Digg",
  "funder" : [
    "greylock",
    "omidyar-network"
  ],
  "amount" : 8500000,
  "year" : 2006
}
{
  "name" : "Digg",
  "funder" : [
    "greylock",
    "omidyar-network",
    "floodgate",
    "sv-angel"
  ],
  "amount" : 2800000,
  "year" : 2005
}
{
  "name" : "Digg",
  "funder" : [
    "highland-capital-partners",
    "greylock",
    "omidyar-network",
    "svb-financial-group"
  ],
  "amount" : 28700000,
  "year" : 2008
}
...
{
  "name" : "Farecast",
  "funder" : [
    "madrona-venture-group",
    "wrf-capital"
  ],
  "amount" : 1500000,
  "year" : 2004
}
{
  "name" : "Farecast",
  "funder" : [
    "greylock",
    "madrona-venture-group",
    "wrf-capital"
  ],
  "amount" : 7000000,
  "year" : 2005
}
{
  "name" : "Farecast",
  "funder" : [
    "greylock",
    "madrona-venture-group",
    "par-capital-management",
    "pinnacle-ventures",
    "sutter-hill-ventures",
    "wrf-capital"
  ],
  "amount" : 12100000,
  "year" : 2007
}

为了理解这里所看到的情况,需要回到文档中查看"investments" 字段。

"funding_rounds.investments" 字段本身就是一个数组。每轮融资都可以有多个投资方参与,"investments"会列出每一个投资方。在结果中,正如最初的"raised_amount" 字段和 "funded_year" 字段,现在看到的是一个 "funder" 数组,因为 "investments" 是一个数组字段。

另一个问题是,由于编写管道的方式,导致 Greylock 没有参与融资轮的许多文档被传递到了投射阶段。可以从Farecast 的融资轮中看出这一点。此问题源于这样一个事实:匹配阶段选择了 Greylock 至少参与一轮融资的所有公司。如果只对 Greylock 实际参与的那些融资轮感兴趣,则需要找到一种不同的过滤方法。

一种可能的方式是颠倒展开阶段和匹配阶段的顺序,也就是说,先展开后匹配。这保证了只匹配那些从展开阶段输出的文档。但仔细思考这种方式,很快就会发现,以展开作为第一个阶段,将对整个集合进行扫描。

为了提高效率,我们希望在管道中尽早进行匹配。这使得聚合框架能够使用索引。因此,为了只选择 Greylock参与的那些融资轮,可以包含第二个匹配阶段:

js 复制代码
db.companies.aggregate([
  { $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
  { $unwind: "$funding_rounds" },
  { $match: {"funding_rounds.investments.financial_org.permalink": "greylock"} },
  { $project: {
    _id: 0,
    name: 1,
    individualFunder: "$funding_rounds.investments.person.permalink",
    fundingOrganization: "$funding_rounds.investments.financial_org.permalink",
    amount: "$funding_rounds.raised_amount",
    year: "$funding_rounds.funded_year"
  } }
])

这个管道将首先过滤出 Greylock 至少参与过一轮融资的公司,然后展开融资轮并再次进行过滤,这样就只有Greylock 实际参与融资轮的文档才会被传递到投射阶段。

正如开头提到的,常常需要包含相同类型的多个阶段。这是一个很好的例子:通过过滤来减少最初查看的文档数量,将文档集缩小到 Greylock 至少参与一轮融资的那些文档。然后,在展开阶段最终输出一些代表Greylock 所投资公司的融资轮的文档,但实际上有个别的融资轮 Greylock 并没有参与。可以简单地加入另一个过滤器,使用第二个匹配阶段,来排除所有不感兴趣的融资轮。

6、数组表达式

首先要介绍的是过滤器表达式。过滤器表达式根据过滤条件选择数组中的元素子集。

再次使用 companies 数据集,用相同的条件匹配Greylock 参与的融资轮。下面看一下这个管道中的rounds 字段:

js 复制代码
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", 100000000] } } }
  } },
  { $match: {"rounds.investments.financial_org.permalink": "greylock" } },
]).pretty()

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 000000 美元)的元素所组成的数组。

在接下来的匹配阶段中,就像前面所做的一样,我们将简单地过滤出那些由 Greylock 以某种方式投资的输入文档。该管道输出的文档如下:

js 复制代码
{
  "name" : "Dropbox",
  "founded_year" : 2007,
  "rounds" : [
    {
      "id" : 25090,
      "round_code" : "b",
      "source_description" :
        "Dropbox Raises $250M In Funding, Boasts 45 Million Users",
      "raised_amount" : 250000000,
      "raised_currency_code" : "USD",
      "funded_year" : 2011,
      "investments" : [
        {
          "financial_org" : {
            "name" : "Index Ventures",
            "permalink" : "index-ventures"
          }
        },
        {
          "financial_org" : {
            "name" : "RIT Capital Partners",
            "permalink" : "rit-capital-partners"
          }
        },
        {
          "financial_org" : {
            "name" : "Valiant Capital Partners",
            "permalink" : "valiant-capital-partners"
          }
        },
        {
          "financial_org" : {
            "name" : "Benchmark",
            "permalink" : "benchmark-2"
          }
        },
        {
          "company" : null,
          "financial_org" : {
            "name" : "Goldman Sachs",
            "permalink" : "goldman-sachs"
          },
          "person" : null
          },
        {
          "financial_org" : {
            "name" : "Greylock Partners",
            "permalink" : "greylock"
          }
        },
        {
          "financial_org" : {
            "name" : "Institutional Venture Partners",
            "permalink" : "institutional-venture-partners"
          }
        },
        {
          "financial_org" : {
            "name" : "Sequoia Capital",
            "permalink" : "sequoia-capital"
          }
        },
        {
          "financial_org" : {
            "name" : "Accel Partners",
            "permalink" : "accel-partners"
          }
        },
        {
          "financial_org" : {
            "name" : "Glynn Capital Management",
            "permalink" : "glynn-capital-management"
          }
        },
        {
          "financial_org" : {
            "name" : "SV Angel",
            "permalink" : "sv-angel"
          }
        }
      ]
    }
  ]
}

只有募集金额超过 100 000 000 美元的 "rounds" 数组项可以通过过滤器。以 Dropbox 为例,只有一个融资轮符合这一标准。过滤器表达式在设置时具有很大的灵活性,这里采用的是一个基本的形式,并为这个特定数组表达式提供了具体的应用示例。

接下来看一下数组元素运算符。我们将继续操作融资轮,但这次只想获取第一轮和最后一轮。例如,我们会对这些融资轮的发生时间或它们的金额对比感兴趣。可以使用日期和算术表达式来实现这些事情,下一节会对此进行介绍。

arrayElemAt 运算符允许选择数组中特定位置的元素。下面的管道提供了一个使用 arrayElemAt 的例子:

js 复制代码
db.companies.aggregate([
  { $match: { "founded_year": 2010 } },
  { $project: {
    _id: 0,
    name: 1,
    founded_year: 1,
    first_round: { $arrayElemAt: [ "$funding_rounds", 0 ] },
    last_round: { $arrayElemAt: [ "$funding_rounds", -1 ] }
  } }
]).pretty()

注意在投射阶段中使用 a r r a y E l e m A t 的语法。这里定义了一个想要投射出来的字段,并指定了一个文档,以 arrayElemAt 的语法。这里定义了一个想要投射出来的字段,并指定了一个文档,以 arrayElemAt的语法。这里定义了一个想要投射出来的字段,并指定了一个文档,以arrayElemAt 作为字段名,以一个双元素数组作为值。第一个元素应该是一个字段路径,用于指定要从中选择的数组字段。第二个元素标识了数组中的位置。记住数组是从 0 开始索引的。

在许多情况下,数组的长度不容易获得。选择从数组末尾开始的数组位置可以使用负整数。数组中的最后一个元素用 -1 标识。

这个聚合管道的简要输出文档如下:

js 复制代码
{
  "name" : "vufind",
  "founded_year" : 2010,
  "first_round" : {
    "id" : 19876,
    "round_code" : "angel",
    "source_url" : "",
    "source_description" : "",
    "raised_amount" : 250000,
    "raised_currency_code" : "USD",
    "funded_year" : 2010,
    "funded_month" : 9,
    "funded_day" : 1,
    "investments" : [ ]
  },
  "last_round" : {
    "id" : 57219,
    "round_code" : "seed",
    "source_url" : "",
    "source_description" : "",
    "raised_amount" : 500000,
    "raised_currency_code" : "USD",
    "funded_year" : 2012,
    "funded_month" : 7,
    "funded_day" : 1,
    "investments" : [ ]
  }
}

$arrayElemAt相关的是 $slice 表达式,其允许在数组中从一个特定的索引开始按顺序返回多个元素:

js 复制代码
db.companies.aggregate([
  { $match: { "founded_year": 2010 } },
  { $project: {
    _id: 0,
    name: 1,
    founded_year: 1,
    early_rounds: { $slice: [ "$funding_rounds", 1, 3 ] }
  } }
]).pretty()

在这里,同样使用 funding_rounds 数组,从索引 1 开始并在数组中获取 3 个元素。在这个数据集中,也许我们对第一轮融资并不那么感兴趣,或者只想了解一些早期的融资轮,而不是第一轮。

过滤和选择数组的单个元素或片段是对数组执行的常见操作之一。然而,最常见的操作可能是确定数组的大小或长度。可以使用 $size 运算符执行此操作:

js 复制代码
db.companies.aggregate([
  { $match: { "founded_year": 2004 } },
  { $project: {
    _id: 0,
    name: 1,
    founded_year: 1,
    total_rounds: { $size: "$funding_rounds" }
  } }
]).pretty()

在投射阶段中使用时,$size 表达式只是简单地提供了一个值,即数组中的元素个数。

7、累加器

累加器本质上是另一种类型的表达式,但这里把它单独列出,因为它的值是从多个文档中的字段计算得来的。

聚合框架提供的累加器可以执行对特定字段中的所有值进行求和($sum)​、计算平均值($avg)等操作。$first$last 也被视为累加器,因为在它们所在的阶段中所有经过的文档的值都会被检查。$max$min 是另外两个累加器的例子,它们会查看文档流并只保存看到的其中一个值。可以使用 $mergeObjects 将多个文档合并为单个文档。

还有用于数组的累加器。当文档通过管道传递时,可以将值 $push 到数组中。$addToSet$push 非常相似,只是它可以确保结果数组中不包含重复的值。

在 MongoDB 3.2 之前,累加器只能在分组阶段使用。MongoDB 3.2 引入了在投射阶段访问部分累加器的功能。累加器在分组阶段和投射阶段的主要区别是,在投射阶段,像 $sum$avg 这样的累加器必须在单个文档中对数组进行操作,而分组阶段中的累加器能够跨多个文档对值进行计算,这一点在后文中可以看到。

7.1、在投射阶段使用累加器

下面从一个在投射阶段使用累加器的例子开始。注意,匹配阶段用于过滤包含 "funding_rounds" 字段且funding_rounds 数组不为空的文档:

js 复制代码
db.companies.aggregate([
  { $match: { "funding_rounds": { $exists: true, $ne: [ ]} } },
  { $project: {
    _id: 0,
    name: 1,
    largest_round: { $max: "$funding_rounds.raised_amount" }
  } }
])

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

js 复制代码
{ "name" : "Wetpaint", "largest_round" : 25000000 }
{ "name" : "Digg", "largest_round" : 28700000 }
{ "name" : "Facebook", "largest_round" : 1500000000 }
{ "name" : "Omnidrive", "largest_round" : 800000 }
{ "name" : "Geni", "largest_round" : 10000000 }
{ "name" : "Twitter", "largest_round" : 400000000 }
{ "name" : "StumbleUpon", "largest_round" : 17000000 }
{ "name" : "Gizmoz", "largest_round" : 6500000 }
{ "name" : "Scribd", "largest_round" : 13000000 }
{ "name" : "Slacker", "largest_round" : 40000000 }
{ "name" : "Lala", "largest_round" : 20000000 }
{ "name" : "eBay", "largest_round" : 6700000 }
{ "name" : "MeetMoi", "largest_round" : 2575000 }
{ "name" : "Joost", "largest_round" : 45000000 }
{ "name" : "Babelgum", "largest_round" : 13200000 }
{ "name" : "Plaxo", "largest_round" : 9000000 }
{ "name" : "Cisco", "largest_round" : 2500000 }
{ "name" : "Yahoo!", "largest_round" : 4800000 }
{ "name" : "Powerset", "largest_round" : 12500000 }
{ "name" : "Technorati", "largest_round" : 10520000 }
...

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

js 复制代码
db.companies.aggregate([
  { $match: { "funding_rounds": { $exists: true, $ne: [ ]} } },
  { $project: {
    _id: 0,
    name: 1,
    total_funding: { $sum: "$funding_rounds.raised_amount" }
  } }
])

这只是在投射阶段使用累加器的一个尝试。同样,建议你查看 MongoDB 文档中关于聚合管道的快速参考以获取可用累加器表达式的完整概述。

8、分组简介

在 MongoDB 以前的版本中,累加器只能在聚合框架的分组阶段中使用。分组阶段执行的功能类似于 SQL 中的GROUP BY 命令。在分组阶段,可以将多个文档的值聚合在一起并对它们执行某种类型的聚合操作,比如计算平均值。来看一个例子:

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

js 复制代码
{ "_id" : { "founded_year" : 1847 }, "average_number_of_employees" : 405000 }
{ "_id" : { "founded_year" : 1896 }, "average_number_of_employees" : 388000 }
{ "_id" : { "founded_year" : 1933 }, "average_number_of_employees" : 320000 }
{ "_id" : { "founded_year" : 1915 }, "average_number_of_employees" : 186000 }
{ "_id" : { "founded_year" : 1903 }, "average_number_of_employees" : 171000 }
{ "_id" : { "founded_year" : 1865 }, "average_number_of_employees" : 125000 }
{ "_id" : { "founded_year" : 1921 }, "average_number_of_employees" : 107000 }
{ "_id" : { "founded_year" : 1835 }, "average_number_of_employees" : 100000 }
{ "_id" : { "founded_year" : 1952 }, "average_number_of_employees" : 92900 }
{ "_id" : { "founded_year" : 1946 }, "average_number_of_employees" : 91500 }
{ "_id" : { "founded_year" : 1947 }, "average_number_of_employees" : 88510.5 }
{ "_id" : { "founded_year" : 1898 }, "average_number_of_employees" : 80000 }
{ "_id" : { "founded_year" : 1968 }, "average_number_of_employees" : 73550 }
{ "_id" : { "founded_year" : 1957 }, "average_number_of_employees" : 70055 }
{ "_id" : { "founded_year" : 1969 }, "average_number_of_employees" : 67635.1 }
{ "_id" : { "founded_year" : 1928 }, "average_number_of_employees" : 51000 }
{ "_id" : { "founded_year" : 1963 }, "average_number_of_employees" : 50503 }
{ "_id" : { "founded_year" : 1959 }, "average_number_of_employees" : 47432.5 }
{ "_id" : { "founded_year" : 1902 }, "average_number_of_employees" : 41171.5 }
{ "_id" : { "founded_year" : 1887 }, "average_number_of_employees" : 35000 }

输出中包括文档类型的 "_id" 以及平均员工数。这类分析是评估公司成立年份和增长之间相关性的第一步,还可以用来对公司的"年龄"进行标准化。

可以看到,所构建的管道有两个阶段:分组阶段和排序阶段。分组阶段的基础是 "_id" 字段,我们将其指定为文档的一部分。这是 $group 运算符本身的值,其解释是非常严格的。

我们使用这个字段来定义分组阶段使用什么来组织文档。因为分组阶段是第一阶段,所以 aggregate 命令会将 companies 集合中的所有文档都传递给此阶段。分组阶段会将所有 "founded_year" 具有相同值的文档视为一组。在构造这个字段的值时,该阶段会使用 $avg 累加器计算具有所有相同 "founded_year" 公司的平均员工数。

可以这样考虑:每当分组阶段遇到具有特定成立年份的文档时,都会将该文档中的 "number_of_employees" 的值添加到员工数量的总和中,并将该年度迄今为止看到的文档数量计数加 1。一旦所有文档都通过了分组阶段,就可以使用该总和以及基于成立年份的每个分组的计数来计算平均值。

来看另一个例子。我们在 companies 数据集中还没有考虑 "relationships" 字段。"relationships" 字段以如下形式出现在文档中:

js 复制代码
{
  "_id" : "52cdef7c4bab8bd675297d8e",
  "name" : "Facebook",
  "permalink" : "facebook",
  "category_code" : "social",
  "founded_year" : 2004,
  ...
  "relationships" : [
    {
      "is_past" : false,
      "title" : "Founder and CEO, Board Of Directors",
      "person" : {
        "first_name" : "Mark",
        "last_name" : "Zuckerberg",
        "permalink" : "mark-zuckerberg"
      }
    },
    {
      "is_past" : true,
      "title" : "CFO",
      "person" : {
        "first_name" : "David",
        "last_name" : "Ebersman",
        "permalink" : "david-ebersman"
      }
    },
    ...
  ],
  "funding_rounds" : [
    ...
    {
      "id" : 4,
      "round_code" : "b",
      "source_description" : "Facebook Funding",
      "raised_amount" : 27500000,
      "raised_currency_code" : "USD",
      "funded_year" : 2006,
      "funded_month" : 4,
      "funded_day" : 1,
      "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
        }
      ]
    },
    ...
  "ipo" : {
    "valuation_amount" : NumberLong("104000000000"),
    "valuation_currency_code" : "USD",
    "pub_year" : 2012,
    "pub_month" : 5,
    "pub_day" : 18,
    "stock_symbol" : "NASDAQ:FB"
  },
  ...
}

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

js 复制代码
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 } }
]).pretty()

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

js 复制代码
{
  "_id" : {
    "first_name" : "Tim",
    "last_name" : "Hanlon",
    "permalink" : "tim-hanlon" 
  },
  "count" : 28
}
{
  "_id" : {
    "first_name" : "Pejman",
    "last_name" : "Nozad",
    "permalink" : "pejman-nozad"
  },
  "count" : 24
}
{
  "_id" : {
    "first_name" : "David S.",
    "last_name" : "Rose",
    "permalink" : "david-s-rose"
  },
  "count" : 24
}
{
  "_id" : {
    "first_name" : "Saul",
    "last_name" : "Klein",
    "permalink" : "saul-klein"
  },
  "count" : 24
}
...

Tim Hanlon 是这个集合中与公司关系最多的人。Hanlon先生可能与 28 家公司有关系,但我们不能确定,因为也 有可能他与一家或多家公司有多个关系,在每一家都有不同的头衔。这个示例说明了关于聚合管道非常重要的一点:在进行计算时,要确保完全理解正在处理的是什么,特别是在使用某种类型的累加器表达式计算聚合值时。

在本例中,Tim Hanlon 在集合中所有公司的"relationships" 文档中出现了 28 次。如果想知道他到底和多少家公司有关联,就需要更深入地进行挖掘,我们将这个管道的构建留给你作为练习。

8.1、分组阶段中的_id字段

在进一步讨论分组阶段之前,再讨论一下 _id 字段,并看看在分组聚合阶段为该字段构造值的一些最佳实践。本节会通过一些示例来说明通常对文档进行分组的几种方式。作为第一个例子,请看如下管道:

js 复制代码
db.companies.aggregate([
  { $match: { founded_year: { $gte: 2013 } } },
  { $group: {
    _id: { founded_year: "$founded_year"},
    companies: { $push: "$name" }
  } },
  { $sort: { "_id.founded_year": 1 } }
]).pretty()

这个管道的输出如下所示:

js 复制代码
{
  "_id" : {
    "founded_year" : 2013
  },
  "companies" : [
    "Fixya",
    "Wamba",
    "Advaliant",
    "Fluc",
    "iBazar",
    "Gimigo",
    "SEOGroup",
    "Clowdy",
    "WhosCall",
    "Pikk",
    "Tongxue",
    "Shopseen",
    "VistaGen Therapeutics"
  ]
}
...

在输出的文档中有两个字段:"_id" 和 "companies"。每个文档都包含一个在 "founded_year" 内成立的公司列表,"companies" 是由公司名称组成的数组。

注意这里是如何在分组阶段构造 "_id" 字段的。为什么不直接提供成立年份,而是将其放入一个标有"founded_year" 的文档中呢?这样做的原因是,如果不标注分组的值,那么就不清楚是按照公司成立的年份进行分组的。为了避免混淆,最佳实践是显式地标注分组的值。

在某些情况下可能需要使用另一种方法,其中 _id 的值是由多个字段组成的文档。本例实际上是根据成立年份和类别代码对文档进行分组的:

js 复制代码
db.companies.aggregate([
  { $match: { founded_year: { $gte: 2010 } } },
  { $group: {
    _id: { founded_year: "$founded_year", category_code: "$category_code" },
    companies: { $push: "$name" }
  } },
  { $sort: { "_id.founded_year": 1 } }
]).pretty()

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

js 复制代码
db.companies.aggregate([
  { $group: {
    _id: { ipo_year: "$ipo.pub_year" },
    companies: { $push: "$name" }
  } },
  { $sort: { "_id.ipo_year": 1 } }
]).pretty()

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

js 复制代码
{
  "_id" : {
    "ipo_year" : 1999
  },
  "companies" : [
    "Akamai Technologies",
    "TiVo",
    "XO Group",
    "Nvidia",
    "Blackberry",
    "Blue Coat Systems",
    "Red Hat",
    "Brocade Communications Systems",
    "Juniper Networks",
    "F5 Networks",
    "Informatica",
    "Iron Mountain",
    "Perficient",
    "Sitestar",
    "Oxford Instruments"
  ]
}

注意,本节的示例使用了一个之前没有见过的累加器: p u s h 。当分组阶段在其输入流中处理文档时, push。当分组阶段在其输入流中处理文档时, push。当分组阶段在其输入流中处理文档时,push 表达式会将结果的值添加到其在运行过程中所构建的数组中。在前面的管道中,分组阶段创建了一个由公司名称组成的数组。

最后一个例子是我们已经见过的,但为完整起见,这里再次列了出来:

js 复制代码
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 } }
] )

前面对 IPO 年份进行分组的示例中使用了一个解析为标量值的字段路径------IPO 年份。在本例中,字段路径解析为了一个含有 3 个字段("first_name"、"last_name" 和"permalink")的文档。这表明分组阶段支持对文档值进行分组。

现在,你已经看到了在分组阶段中构造 _id 值的几种方法。通常,需要记住的是要确保在输出中 _id 的值语义清晰。

8.2、分组与投射

总结一下对分组聚合阶段的讨论,看看无法在投射阶段使用的几个额外的累加器。这是为了鼓励你更深入地思考累加器在投射阶段和分组阶段的作用。例如,考虑以下聚合查询:

js 复制代码
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 数组都是排好序的。

这个管道输出的文档如下所示:

js 复制代码
{
  "_id" : {
    "company" : "Green Apple Media"
  },
  "funding" : [
    {
      "amount" : 30000000,
      "year" : 2013
    },
    {
      "amount" : 100000000,
      "year" : 2013
    },
    {
      "amount" : 2000000,
      "year" : 2013
    }
  ]
}

在这个管道中使用了 push 来生成一个数组。本例指定了 push 表达式将文档添加到数组的末尾。由于各轮融资都是按时间顺序进行的,因此将其排在末尾可以保证每家公司的融资金额是按时间顺序进行排序的。

$push 表达式仅适用于分组阶段。这是因为分组阶段被设计成了接受文档的输入流,并通过依次处理每个文档来对值进行累加操作。另外,投射阶段会单独处理输入流中的每个文档。

再看另一个例子。这个管道有点儿长,但它其实是建立在前面例子基础上的:

js 复制代码
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 值传递到输出文档。

这个管道输出的文档如下所示:

js 复制代码
{
  "first_round" : {
    "amount" : 7500000,
    "year" : 2004
  },
  "last_round" : {
    "amount" : 10000000,
    "year" : 2012
  },
  "num_rounds" : 11,
  "total_raised" : 823000000,
  "company" : "Tesla Motors"
}

9、将聚合管道结果写入集合中

作为两个特定的阶段,$out$merge 可以将聚合管道生成的文档写入集合中。这两个阶段不能同时使用,并且任一阶段必须是聚合管道的最后一个阶段。

  • $merge 是在 MongoDB 4.2 中引入的,如果可以使用的话,它是将结果写入集合中的首选方式。
  • $out 有一些限制:它只能写入相同的数据库;如果集合已经存在,那么它会覆盖任何现有的集合;它不能写入已分片的集合中。

$merge 可以写入任何数据库和集合中,无论是否分片。$merge 还可以在处理现有集合时对结果进行合并(插入新文档、与现有文档合并、操作失败、保留现有文档或使用自定义更新处理所有文档)​。但使用 $merge 的真正优势在于,它可以创建按需生成的物化视图(materialized view)​,在管道运行的过程中,输出到集合的内容会进行增量更新。

相关推荐
墨神谕1 小时前
认识一下Unicode(统一码)
android·java·数据库
xywww1681 小时前
Claude Opus 5 API 接入实战:国内项目上线前的网络、Key、限流和排错清单
大数据·linux·网络·数据库·云计算·aws
yuezhilangniao2 小时前
某专科医院老数据库迁移实战记录-Oracle10g sqlserver2008等
数据库
白猫不黑2 小时前
SQL注入实战:手工注入全流程详解
网络·数据库·sql·web安全·网络安全·信息安全
kirs_ur11 小时前
ECC & LDPC — SSD 的数据卫士
服务器·数据库·性能优化
是三一seven12 小时前
Sql注入基础
数据库·安全·网络安全
Sirens.12 小时前
MySQL表设计进阶-约束范式连接索引与事务
android·数据库·mysql
字节跳动开源14 小时前
火山引擎开源 Agent 驱动的搜索自迭代技术
数据库·开源·agent
Oo大司命oO16 小时前
藏在正则表达式里的陷阱
数据库·mysql·正则表达式