跳到主要内容

MongoDB 聚合

https://www.mongodb.com/zh-cn/docs/manual/aggregation/

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

  • 将多个文档中的值组合在一起。

  • 对分组数据执行操作,返回单一结果。

  • 分析一段时间内的数据变化。

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

  • 聚合管道,这是执行聚合的首选方法。

  • 单一目的聚合方法,这些方法很简单,但缺乏聚合管道的功能。

聚合管道

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

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

如使用通过聚合管道更新中显示的阶段,则可以通过聚合管道更新文档。

使用 db.collection.aggregate() 方法运行的聚合管道不会修改集合中的文档,除非管道包含 $merge$out 阶段。

例一

本部分展示了使用以下披萨 orders 集合的聚合管道示例:

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

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

db.orders.aggregate( [

// Stage 1: Filter pizza order documents by pizza size
{
$match: { size: "medium" }
},

// Stage 2: Group remaining documents by pizza name and calculate total quantity
{
$group: { _id: "$name", totalQuantity: { $sum: "$quantity" } }
}

] )

$match 阶段:

  • 从披萨订单文档过滤出sizemedium的披萨。
  • 将剩余文档传递到$group阶段。

$group 阶段:

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

示例输出:

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

例二

以下示例计算了两个日期之间的披萨订单总额和平均订单数量:

db.orders.aggregate( [

// Stage 1: Filter pizza order documents by date range
{
$match:
{
"date": { $gte: new ISODate( "2020-01-30" ), $lt: new ISODate( "2022-01-30" ) }
}
},

// Stage 2: Group remaining documents by date and calculate results
{
$group:
{
_id: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
totalOrderValue: { $sum: { $multiply: [ "$price", "$quantity" ] } },
averageOrderQuantity: { $avg: "$quantity" }
}
},

// Stage 3: Sort documents by totalOrderValue in descending order
{
$sort: { totalOrderValue: -1 }
}

] )

$match 阶段:

  • 使用 $gte$lt 将披萨订单文档筛选为指定日期范围内的文档。
  • 将剩余文档传递到$group阶段。

$group 阶段:

  • 使用 $dateToString 按日期对文档进行分组。
  • 对于每个群组,计算:
    • 使用 $sum$multiply 的总订单值。
    • 使用 $avg 计算平均订单数量。
  • 将分组的文档传递到 $sort 阶段。

$sort 阶段:

  • 按每组的总订单值以降序对文档进行排序 ( -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 }
]

单一目的聚合方法

单一目的聚合方法聚合单个集合中的文档。这些方法很简单,但缺乏聚合管道的功能。

方法说明
db.collection.estimatedDocumentCount()返回集合或视图中文档的近似数量。
db.collection.count()返回集合或视图中文档的数量。
db.collection.distinct()返回具有指定字段的不同值的文档数组。

字段路径

https://www.mongodb.com/zh-cn/docs/manual/core/field-paths/

您可以使用字段路径(Field Path)表达式访问权限输入文档中的字段

要指定字段路径,加上美元符号$

可以在以下使用案例中使用字段路径:

嵌套字段

以下示例使用Atlas样本数据库中的planets集合。此集合中的每个文档都具有以下结构:

{
_id: new ObjectId("6220f6b78a733c51b416c80e"),
name: "Uranus",
orderFromSun: 7,
hasRings: true,
mainAtmosphere: [ "H2", "He", "CH4" ],
surfaceTemperatureC: { min: null, max: null, mean: -197.2 }
}

要在 字段中指定嵌套字段 mean ,请使用带有美元符号 "field.nestedField" 的点表示法:

db.planets.aggregate( [
{
$project: {
nested_field: "$surfaceTemperatureC.mean"
}
}
] )

以下是返回文档的示例:

{ _id: ObjectId('6220f6b78a733c51b416c80e'), nested_field: -197.2 }

嵌套字段数组

您可以在字段路径(Field Path)中使用点表示法来访问权限嵌套在数组中的字段。

示例,考虑一个包含 instock 字段的 products 集合。 instock 字段包含一个嵌套 warehouse 字段的数组。

db.products.insertMany( [
{ item: "journal", instock: [ { warehouse: "A"}, { warehouse: "C" } ] },
{ item: "notebook", instock: [ { warehouse: "C" } ] },
{ item: "paper", instock: [ { warehouse: "A" }, { warehouse: "B" } ] },
{ item: "planner", instock: [ { warehouse: "A" }, { warehouse: "B" } ] },
{ item: "postcard", instock: [ { warehouse: "B" }, { warehouse: "C" } ] }
] )

以下聚合管道使用 $instock.warehouse 访问权限嵌套的 warehouse 字段。

db.products.aggregate( [
{
$project: {
item: 1,
warehouses: "$instock.warehouse"
}
}
] )

在此示例中,$instock.warehouse 输出每个文档的嵌套 warehouse字段中的值的数组。管道返回以下文档:

[
{
_id: ObjectId('6740b55e33b29cf6b1d884f7'),
item: "journal",
warehouses: [ "A", "C" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884f8'),
item: "notebook",
warehouses: [ "C" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884f9'),
item: "paper",
warehouses: [ "A", "B" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884fa'),
item: "planner",
warehouses: [ "A", "B" ]
},
{
_id: ObjectId('6740b55e33b29cf6b1d884fb'),
item: "postcard",
warehouses: [ "B", "C" ]
}
]

嵌套数组的数组

还可以在字段路径(Field Path)中使用带有美元符号$ 的点表示法来访问权限嵌套数组中的数组。

此示例使用包含以下文档的 fruits 集合:

db.fruits.insertOne(
{
_id: ObjectId("5ba53172ce6fa2fcfc58e0ac"),
inventory: [
{
apples: [
"macintosh",
"golden delicious",
]
},
{
oranges: [
"mandarin",
]
},
{
apples: [
"braeburn",
"honeycrisp",
]
}
]
}
)

集合中的文档包含一个 inventory 数组嵌数组

考虑以下聚合管道:

db.fruits.aggregate( [
{ $project:
{ all_apples: "$inventory.apples" } }
] )

管道返回以下文档:

{
_id: ObjectId('5ba53172ce6fa2fcfc58e0ac'),
all_apples: [
[ "macintosh", "golden delicious" ],
[ "braeburn", "honeycrisp" ]
]
}

优化

聚合管道操作包含一个优化阶段,该阶段会尝试重塑管道以提高性能。

要查看优化器如何转换特定的聚合管道,请在 db.collection.aggregate() 方法中添加 explain 选项。

优化可能因版本而异。

优化的管道不宜手动运行。原始管道和优化管道返回相同的结果。

投影优化

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

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

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

管道序列优化

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

如果聚合管道包含投影阶段 ($addFields$project$set$unset),且其后跟随 $match 阶段,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 } } }
  • $match 筛选器 { avgTime: { $gt: 7 } } 依赖 $project 阶段来计算 avgTime 字段。$project 阶段是该管道中的最后一个投影阶段,因此 avgTime 上的 $match 筛选器无法移动。

  • maxTimeminTime 字段在 $addFields 阶段计算,但不依赖 $project 阶段。优化器已为这些字段上的筛选器创建一个新的 $match 阶段,并将其置于 $project 阶段之前。

  • $match 筛选器 { name: "Joe Schmoe" } 不使用在 $project$addFields 阶段计算的任何值,因此它在这两个投影阶段之前移到了新的 $match 阶段。

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

$sort + $match 序列优化

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

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

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

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

$redact + $match 序列优化

如果可能,当管道有 $redact 阶段紧接着 $match 阶段时,聚合有时可以在$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/$unset + $skip 序列优化

如果序列中的 $project$unset 后面是 $skip,则 $skip$project 之前移动。例如,如果管道由以下阶段组成:

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

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

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

此操作可让排序操作在推进时仅维护前 n 个结果,其中 n 为指定的限制,而 MongoDB 仅需要在内存中存储 n 个项目

$limit + $limit 合并

$limit 紧随另一个 $limit 时,这两个阶段可以合并为一个 $limit,以两个初始限额中较小的为合并后的限额。

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

第二个 $limit 阶段可以合并到第一个 $limit 阶段:

{ $limit: 10 }

$skip + $skip 合并

$skip 紧随在另一个 $skip 之后时,这两个阶段可以合并为一个 $skip,其中的跳过数量是两个初始跳过数量的总和

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

然后第二个 $skip 阶段可以合并到第一个 $skip 阶段:

{ $skip: 7 }
$sort + $skip + $limit 顺序

如果 $sort$limit 阶段之间存在一个 $skip 阶段,MongoDB 会将 $limit 合并到 $sort 阶段,并将 $limit 的值增加 $skip 的数量。

管道的阶段序列为:首先为 $sort,其次为 $skip,再次为 $limit

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

优化器执行 $sort + $limit 合并以将此序列转换为以下内容:

{
"$sort" : {
"sortKey" : {
"age" : -1
},
"limit" : NumberLong(15)
}
},
{
"$skip" : NumberLong(10)
}

重新排序后,MongoDB 增加了 $limit 的数量。

$match + $match 合并

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

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

然后第二个 $match 阶段可合并到第一个 $match 阶段并形成一个 $match 阶段

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

$lookup$unwind$match Coalescence

$unwind 紧随 $lookup,且 $unwind$lookupas 字段上运行时,优化器将 $unwind 合并到 $lookup 阶段。这样可以避免创建大型中间文档。此外,如果 $unwind 后接 $lookup 的任意 as 子字段上的 $match,则优化器也会合并 $match

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

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

优化器将 $unwind$match 阶段合并到 $lookup 阶段。如果使用 explain 选项运行聚合,explain 输出将显示合并阶段:

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

使用索引和文档筛选器来提高性能

索引

聚合管道可以使用输入集合中的索引来提高性能。使用索引可限制阶段处理的文档数量。理想情况下,索引可以覆盖阶段查询。覆盖查询性能特别高,因为索引返回所有匹配的文档。

例如,由 $match$sort$group 组成的管道可以从每个阶段的索引中受益:

  • $match 查询字段上的索引可高效地识别相关数据
  • 针对排序字段的索引会按排序顺序为 $sort 阶段返回数据
  • $sort 顺序匹配的针对分组字段的索引则会返回 $group 阶段所需的所有字段值,从而使其成为一个覆盖查询。

要确定管道是否使用了索引,请查看查询计划并查找 IXSCAN 计划或 DISTINCT_SCAN 计划。

在某些情况下,查询计划器使用 DISTINCT_SCAN 索引计划,该计划可为每个索引键值返回一个文档。如果每个键值有多个文档,则DISTINCT_SCAN 的执行速度比 IXSCAN 快。但是,索引扫描参数可能会影响 DISTINCT_SCANIXSCAN 的时间比较。

对于聚合管道的早期阶段,请考虑对查询字段建立索引。可以从索引受益的阶段是:

  • $match stage
    • 如果 $match 是管道中的第一个阶段,则在查询规划器进行任何优化之后,服务器可以使用索引。
  • $sort stage
  • $group stage
    • 如果该阶段满足以下两个条件,服务器可以使用索引快速查找每个群组中的 $first$last 文档
      • 管道 sortsgroups 按照同一字段执行。
      • $group 阶段仅使用 $first$last 累加器运算符。
  • $geoNear stage

此外,管道中的一些后期阶段在从其他未修改的集合中检索数据时,可以使用这些集合上的索引来实现优化。这些阶段包括:

文档筛选器

如果聚合操作只需要集合中文档的子集,请先过滤文档:

  • 使用 $match$limit$skip 阶段来限制进入管道的文档。
  • 在可能的情况下,将 $match 放在管道的开头,以使用索引扫描集合中的匹配文档。
  • 管道开头的 $match 后面跟上 $sort 等同于带有排序的单个查询,并且可以使用索引。

限制

使用 aggregate 命令的聚合操作具有以下限制。

结果大小限制

aggregate 命令既可以返回一个游标,也可以将结果存储在集合中。结果集中的每个文档存在 16 MB 的 BSON 文档大小限制。如果任何单个文档超过 BSON 文档大小限制,则聚合会产生错误。该限制仅适用于返回的文档。在管道处理过程中,文档可能会超过此大小。

阶段数量限制

MongoDB 将单个管道中允许的聚合管道阶段数量限制为 1000 个。

如果聚合管道在解析之前或之后超过阶段限制,您会收到错误消息。

内存限制

从 MongoDB 6.0 开始,allowDiskUseByDefault 参数可控制需要 100 MB 以上内存容量来执行的管道阶段是否默认会将临时文件写入磁盘。

  • 如果将 allowDiskUseByDefault 设为 true,则默认情况下,需要 100 MB 以上内存容量的管道阶段会将临时文件写入磁盘。您可以使用 { allowDiskUse: false } 选项来为特定的 findaggregate 命令禁用将临时文件写入磁盘的功能。
  • 如果 allowDiskUseByDefault 设置为 false,则默认情况下,需要超过 100 MB 的内存才能执行的管道阶段会引发错误。您可以使用 { allowDiskUse: true } 选项来为特定 findaggregate 启用向磁盘写入临时文件的功能。

$search 聚合阶段不限于 100 MB 的 RAM,因为它在单独的进程中运行。

allowDiskUsetrue 时,可将临时文件写入磁盘的阶段示例如下:

管道阶段对文档流进行操作,每个管道阶段接收文档,对其进行处理,然后输出结果文档。

某些阶段在处理完所有传入文档之前无法输出任何文档。这些管道阶段必须将其阶段输出保留在 RAM 中,直到处理完所有传入文档。因此,这些管道阶段所需的空间可能超过 100 MB 的限制。

如果某一 $sort 管道阶段的结果超过此限制,则可考虑添加一个 $limit 阶段

如果任何聚合阶段因内存限制而将数据写入临时文件,则分析器日志消息诊断日志消息会包含 usedDisk 指示器。