跳至主要內容

MongoDB 的聚合操作

钝悟...大约 16 分钟数据库文档数据库MongoDB数据库文档数据库MongoDB聚合

MongoDB 的聚合操作

聚合操作处理多个文档并返回计算结果。可以使用聚合操作来:

  • 将多个文档中的值组合在一起。
  • 对分组数据执行操作,返回单一结果。
  • 分析一段时间内的数据变化。

若要执行聚合操作,可以使用:

Pipeline 简介

MongoDB 的聚合框架以数据处理管道(Pipeline)的概念为模型。**MongoDB 通过 db.collection.aggregate()open in new window 方法支持聚合操作 **。并提供了 aggregateopen in new window 命令来执行 pipeline。

聚合管道由一个或多个处理文档的 阶段open in new window 组成:

  • 每个阶段对输入文档执行一个操作。例如,某个阶段可以过滤文档、对文档进行分组并计算值。
  • 从一个阶段输出的文档将传递到下一阶段。
  • 一个聚合管道可以返回针对文档组的结果。例如,返回总值、平均值、最大值和最小值。
img
img

阶段open in new window 的要点:

Pipeline 示例

初始数据:

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" ) }
] )

【示例】计算总订单数量

以下聚合管道示例包含两个 阶段open in new window,并返回按披萨名称分组后,各款中号披萨的总订单数量:

db.orders.aggregate( [
   // Stage 1: 根据 size 过滤订单
   {
      $match: { size: "medium" }
   },
   // Stage 2: 按名称对剩余文档进行分组,并计算总数量
   {
      $group: { _id: "$name", totalQuantity: { $sum: "$quantity" } }
   }
] )

// 输出
[
   { _id: 'Cheese', totalQuantity: 50 },
   { _id: 'Vegan', totalQuantity: 10 },
   { _id: 'Pepperoni', totalQuantity: 20 }
]

$matchopen in new window 阶段:

$groupopen in new window 阶段:

  • 按披萨 name 对剩余文档进行分组。
  • 使用 $sumopen in new window 计算每种披萨 name 的总订单 quantity。总数存储在聚合管道返回的 totalQuantity 字段中。

【示例】计算订单总值和平均订单数

db.orders.aggregate( [

   // Stage 1: 根据 date 过滤订单
   {
      $match:
      {
         "date": { $gte: new ISODate( "2020-01-30" ), $lt: new ISODate( "2022-01-30" ) }
      }
   },

   // Stage 2: 按 date 对剩余文档进行分组,并计算
   {
      $group:
      {
         _id: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
         totalOrderValue: { $sum: { $multiply: [ "$price", "$quantity" ] } },
         averageOrderQuantity: { $avg: "$quantity" }
      }
   },

   // Stage 3: 根据 totalOrderValue 倒序排序
   {
      $sort: {totalOrderValue: -1}
   }

 ] )

// 输出
[
   { _id: '2022-01-12', totalOrderValue: 790, averageOrderQuantity: 30 },
   { _id: '2021-03-13', totalOrderValue: 770, averageOrderQuantity: 15 },
   { _id: '2021-03-17', totalOrderValue: 630, averageOrderQuantity: 30 },
   { _id: '2021-01-13', totalOrderValue: 350, averageOrderQuantity: 10 }
]

$matchopen in new window 阶段:

$groupopen in new window 阶段:

$sortopen in new window 阶段:

  • 按每组的总订单值以降序对文档进行排序 (-1)。
  • 返回排序文档。

更多示例:

Pipeline 优化

投影优化

聚合管道可确定是否只需文档中的部分字段即可获取结果。如果是,管道则仅会使用这些字段,从而减少通过管道传递的数据量。

使用 $projectopen in new window 阶段时,通常应该是管道的最后一个阶段,用于指定要返回给客户端的字段。

在管道的开头或中间使用 $project 阶段来减少传递到后续管道阶段的字段数量不太可能提高性能,因为数据库会自动执行此优化。

管道序列优化

$project$unset$addFields$set) + $match 序列优化

如果聚合管道包含投影阶段 ($addFieldsopen in new window$projectopen in new window$setopen in new window$unsetopen in new window),且其后跟随 $matchopen in new window 阶段,MongoDB 会将 $match 阶段中无需使用投影阶段计算的值的所有过滤器移动到投影前的新的 $match 阶段。

如果聚合管道包含多个投影或 $match 阶段,MongoDB 会对每个 $match 阶段执行此优化,将每个 $match 过滤器移到过滤器不依赖的所有投影阶段之前。

【示例】管道序列优化示例

优化前:

{
   $addFields: {
      maxTime: { $max: "$times" },
      minTime: { $min: "$times" }
   }
},
{
   $project: {
      _id: 1,
      name: 1,
      times: 1,
      maxTime: 1,
      minTime: 1,
      avgTime: { $avg: ["$maxTime", "$minTime"] }
   }
},
{
   $match: {
      name: "Joe Schmoe",
      maxTime: { $lt: 20 },
      minTime: { $gt: 5 },
      avgTime: { $gt: 7 }
   }
}

优化器会将 $match 阶段分解为四个单独的过滤器,每个过滤器对应 $match 查询文档中的一个键。然后,优化器会将每个过滤器移至尽可能多的投影阶段之前,从而按需创建新的 $match 阶段。

优化后:

{ $match: { name: "Joe Schmoe" } },
{ $addFields: {
    maxTime: { $max: "$times" },
    minTime: { $min: "$times" }
} },
{$match: { maxTime: { $lt: 20}, minTime: {$gt: 5} } },
{ $project: {
    _id: 1, name: 1, times: 1, maxTime: 1, minTime: 1,
    avgTime: { $avg: ["$maxTime", "$minTime"] }
} },
{$match: { avgTime: { $gt: 7} } }

$matchopen in new window 筛选器 { avgTime: { $gt: 7 } } 依赖 $projectopen in new window 阶段来计算 avgTime 字段。$projectopen in new window 阶段是该管道中的最后一个投影阶段,因此 avgTime 上的 $matchopen in new window 筛选器无法移动。

maxTimeminTime 字段在 $addFieldsopen in new window 阶段计算,但不依赖 $projectopen in new window 阶段。优化器已为这些字段上的筛选器创建一个新的 $matchopen in new window 阶段,并将其置于 $projectopen in new window 阶段之前。

$matchopen in new window 筛选器 { name: "Joe Schmoe" } 不使用在 $projectopen in new window$addFieldsopen in new window 阶段计算的任何值,因此它在这两个投影阶段之前移到了新的 $matchopen in new window 阶段。

优化后,筛选器 { name: "Joe Schmoe" } 在管道开始时会处于 $matchopen in new window 阶段。此举还允许聚合在最初查询该集合时使用针对 name 字段的索引。

$sort + $match 序列优化

当序列中的 $sortopen in new window 后面是 $matchopen in new window 时,$matchopen in new window 会在 $sortopen in new window 之前移动,以最大限度地减少要排序的对象数量。例如,如果管道由以下阶段组成:

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

在优化阶段,优化器会将序列转换为以下内容:

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

$redact + $match 序列优化

如果可能,当管道有 $redactopen in new window 阶段紧接着 $matchopen in new window 阶段时,聚合有时可以在 $redactopen in new window 阶段之前添加 $matchopen in new window 阶段的一部分。如果添加的 $matchopen in new window 阶段位于管道的开头,则聚合可以使用索引并查询集合以限制进入管道的文档数量。有关更多信息,请参阅 使用索引和文档过滤器提高性能open in new window

例如,如果管道由以下阶段组成:

{ $redact: { $cond: { if: { $eq: [ "$level", 5 ] }, then: "$$PRUNE", else: "$$DESCEND" } } },
{ $match: { year: 2014, category: { $ne: "Z" } } }

优化器可以在 $redactopen in new window 阶段之前添加相同的 $matchopen in new window 阶段:

{ $match: { year: 2014 } },
{ $redact: { $cond: { if: { $eq: [ "$level", 5 ] }, then: "$$PRUNE", else: "$$DESCEND" } } },
{ $match: { year: 2014, category: { $ne: "Z" } } }

$project/$unset + $skip 序列优化

如果序列中的 $projectopen in new window$unsetopen in new window 后面是 $skipopen in new window,则 $skipopen in new window$projectopen in new window 之前移动。例如,如果管道由以下阶段组成:

{ $sort: { age : -1 } },
{ $project: { status: 1, name: 1 } },
{ $skip: 5 }

在优化阶段,优化器会将序列转换为以下内容:

{ $sort: { age : -1 } },
{ $skip: 5 },
{ $project: { status: 1, name: 1 } }

管道合并优化

如果可能,优化阶段会将 Pipeline 阶段合并到其前身。通常,合并发生在任意序列重新排序优化之后。

$sort + $limit 合并

$sortopen in new window$limitopen in new window 之前时,, the optimizer can coalesce the into the 如果没有干预阶段(例如 $unwindopen in new window$groupopen in new window)修改文档的数量,则优化器可以将 $limitopen in new window 阶段合并到 $sortopen in new window。如果有管道阶段更改了 $sortopen in new window$limitopen in new window 阶段之间的文档数量,则 MongoDB 不会将 $limitopen in new window 合并到 $sortopen in new window 中。

例如,如果管道由以下阶段组成:

{ $sort : { age : -1 } },
{ $project : { age : 1, status : 1, name : 1 } },
{ $limit: 5 }

在优化阶段,优化器会将此序列合并为以下内容:

{
    "$sort" : {
       "sortKey" : {
          "age" : -1
       },
       "limit" : NumberLong(5)
    }
},
{ "$project" : {
         "age" : 1,
         "status" : 1,
         "name" : 1
  }
}

此操作可让排序操作在推进时仅维护前 n 个结果,其中 n 为指定的限制,而 MongoDB 仅需要在内存中存储 n 个项目 [1]open in new window。有关更多信息,请参阅 $sort 操作符和内存open in new window

$limit + $limit 合并

$limitopen in new window 紧随另一个 $limitopen in new window 时,这两个阶段可以合并为一个 $limitopen in new window,以两个初始限额中较小的为合并后的限额。例如,一个管道包含以下序列:

{ $limit: 100 },
{ $limit: 10 }

然后第二个 $limitopen in new window 阶段可以合并到第一个 $limitopen in new window 阶段,形成一个 $limitopen in new window 阶段,新阶段的限额 10 是两个初始限额 10010 中的较小者。

{ $limit: 10 }

$skip + $skip 合并

$skipopen in new window 紧随在另一个 $skipopen in new window 之后时,这两个阶段可以合并为一个 $skipopen in new window,其中的跳过数量是两个初始跳过数量的总和。例如,一个管道包含以下序列:

{ $skip: 5 },
{ $skip: 2 }

然后第二个 $skipopen in new window 阶段可以合并到第一个 $skipopen in new window 阶段,形成一个 $skipopen in new window 阶段,新阶段的跳过数量 7 是两个初始限额 52 的总和。

{ $skip: 7 }

$match + $match 合并

$matchopen in new window 紧随另一个 $matchopen in new window 之后时,这两个阶段可以合并为一个 $matchopen in new window,用 $andopen in new window 将条件组合在一起。例如,一个管道包含以下序列:

{ $match: { year: 2014 } },
{ $match: { status: "A" } }

然后第二个 $matchopen in new window 阶段可合并到第一个 $matchopen in new window 阶段并形成一个 $matchopen in new window 阶段

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

$lookup$unwind$match Coalescence

$unwindopen in new window 紧随 $lookupopen in new window,且 $unwindopen in new window$lookupopen in new window 的 `as`[](https://www.mongodb.com/zh-cn/docs/manual/reference/operator/aggregation/lookup/#mongodb-pipeline-pipe.-lookupopen in new window) 字段上运行时,优化器将 $unwindopen in new window 合并到 $lookupopen in new window 阶段。这样可以避免创建大型中间文档。此外,如果 $unwindopen in new window 后接 $lookupopen in new window 的任意 as 子字段上的 $matchopen in new window,则优化器也会合并 $matchopen in new window``open in new window

例如,一个管道包含以下序列:

{
   $lookup: {
     from: "otherCollection",
     as: "resultingArray",
     localField: "x",
     foreignField: "y"
   }
},
{ $unwind: "$resultingArray"  },
{ $match: {
    "resultingArray.foo": "bar"
  }
}

优化器将 $unwindopen in new window$matchopen in new window 阶段合并到 $lookupopen in new window 阶段。如果使用 explain 选项运行聚合,explain 输出将显示合并阶段:

{
   $lookup: {
     from: "otherCollection",
     as: "resultingArray",
     localField: "x",
     foreignField: "y",
     let: {},
     pipeline: [
       {
         $match: {
           "foo": {
             "$eq": "bar"
           }
         }
       }
     ],
     unwinding: {
       "preserveNullAndEmptyArrays": false
     }
   }
}

您可以在 解释计划open in new window 中查看此优化后的管道。

Pipeline 限制

Map-Reduce

聚合 pipeline 比 map-reduce 提供更好的性能和更一致的接口。

Map-reduce 是一种数据处理范式,用于将大量数据汇总为有用的聚合结果。为了执行 map-reduce 操作,MongoDB 提供了 mapReduceopen in new window 数据库命令。

img
img

在上面的操作中,MongoDB 将 map 阶段应用于每个输入 document(即 collection 中与查询条件匹配的 document)。 map 函数分发出多个键 - 值对。对于具有多个值的那些键,MongoDB 应用 reduce 阶段,该阶段收集并汇总聚合的数据。然后,MongoDB 将结果存储在 collection 中。可选地,reduce 函数的输出可以通过 finalize 函数来进一步汇总聚合结果。

MongoDB 中的所有 map-reduce 函数都是 JavaScript,并在 mongod 进程中运行。 Map-reduce 操作将单个 collection 的 document 作为输入,并且可以在开始 map 阶段之前执行任意排序和限制。 mapReduce 可以将 map-reduce 操作的结果作为 document 返回,也可以将结果写入 collection。

单一目的聚合方法

MongoDB 支持一下单一目的的聚合操作:

所有这些操作都汇总了单个 collection 中的 document。尽管这些操作提供了对常见聚合过程的简单访问,但是它们相比聚合 pipeline 和 map-reduce,缺少灵活性和丰富的功能性。

img
img

RDBM 聚合 vs. MongoDB 聚合

MongoDB pipeline 提供了许多等价于 SQL 中常见聚合语句的操作。

下表概述了常见的 SQL 聚合语句或函数和 MongoDB 聚合操作的映射表:

RDBM 操作MongoDB 聚合操作
WHERE$matchopen in new window
GROUP BY$groupopen in new window
HAVING$matchopen in new window
SELECT$projectopen in new window
ORDER BY$sortopen in new window
LIMIT$limitopen in new window
SUM()$sumopen in new window
COUNT()$sumopen in new window$sortByCountopen in new window
JOIN$lookupopen in new window
SELECT INTO NEW_TABLE$outopen in new window
MERGE INTO TABLE$mergeopen in new window (Available starting in MongoDB 4.2)
UNION ALL$unionWithopen in new window (Available starting in MongoDB 4.4)

【示例】

db.orders.insertMany([
  {
    _id: 1,
    cust_id: 'Ant O. Knee',
    ord_date: new Date('2020-03-01'),
    price: 25,
    items: [
      { sku: 'oranges', qty: 5, price: 2.5 },
      { sku: 'apples', qty: 5, price: 2.5 }
    ],
    status: 'A'
  },
  {
    _id: 2,
    cust_id: 'Ant O. Knee',
    ord_date: new Date('2020-03-08'),
    price: 70,
    items: [
      { sku: 'oranges', qty: 8, price: 2.5 },
      { sku: 'chocolates', qty: 5, price: 10 }
    ],
    status: 'A'
  },
  {
    _id: 3,
    cust_id: 'Busby Bee',
    ord_date: new Date('2020-03-08'),
    price: 50,
    items: [
      { sku: 'oranges', qty: 10, price: 2.5 },
      { sku: 'pears', qty: 10, price: 2.5 }
    ],
    status: 'A'
  },
  {
    _id: 4,
    cust_id: 'Busby Bee',
    ord_date: new Date('2020-03-18'),
    price: 25,
    items: [{ sku: 'oranges', qty: 10, price: 2.5 }],
    status: 'A'
  },
  {
    _id: 5,
    cust_id: 'Busby Bee',
    ord_date: new Date('2020-03-19'),
    price: 50,
    items: [{ sku: 'chocolates', qty: 5, price: 10 }],
    status: 'A'
  },
  {
    _id: 6,
    cust_id: 'Cam Elot',
    ord_date: new Date('2020-03-19'),
    price: 35,
    items: [
      { sku: 'carrots', qty: 10, price: 1.0 },
      { sku: 'apples', qty: 10, price: 2.5 }
    ],
    status: 'A'
  },
  {
    _id: 7,
    cust_id: 'Cam Elot',
    ord_date: new Date('2020-03-20'),
    price: 25,
    items: [{ sku: 'oranges', qty: 10, price: 2.5 }],
    status: 'A'
  },
  {
    _id: 8,
    cust_id: 'Don Quis',
    ord_date: new Date('2020-03-20'),
    price: 75,
    items: [
      { sku: 'chocolates', qty: 5, price: 10 },
      { sku: 'apples', qty: 10, price: 2.5 }
    ],
    status: 'A'
  },
  {
    _id: 9,
    cust_id: 'Don Quis',
    ord_date: new Date('2020-03-20'),
    price: 55,
    items: [
      { sku: 'carrots', qty: 5, price: 1.0 },
      { sku: 'apples', qty: 10, price: 2.5 },
      { sku: 'oranges', qty: 10, price: 2.5 }
    ],
    status: 'A'
  },
  {
    _id: 10,
    cust_id: 'Don Quis',
    ord_date: new Date('2020-03-23'),
    price: 25,
    items: [{ sku: 'oranges', qty: 10, price: 2.5 }],
    status: 'A'
  }
])

SQL 和 MongoDB 聚合方式对比:

img
img

参考资料

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.7