前面的查询都以“找到哪些文档”为目标。书店真正开始运营后,问题会变成:哪本书卖得最多?已完成订单带来了多少收入?哪些顾客下单频繁?这类问题需要让多个文档经过筛选、展开、分组和重组。
MongoDB 的聚合管道由一系列阶段组成。每个阶段接收上一步的文档,完成一件事,再把结果交给下一步。我们继续使用 bookstore.orders,所有输出都来自前面写入的 5 张固定订单。
写管道前,先把问题拆成几个动作。例如“统计已完成订单中每本书的销量和收入”,可以拆成:
paid 和 shipped 订单。items 数组展开。bookId 分组,累加数量与金额。这五个动作分别对应 $match、$unwind、$group、$sort 和 $project。先用一条短管道确认订单状态分布,后面才能判断筛选是否正确。

进入 mongosh 并切换到书店数据库:
docker exec -it paperboat-mongo mongosh --quietuse bookstore
db.orders.aggregate([
{
$group: {
_id: "$status",
orders: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
]).forEach((document) => print(EJSON.stringify(document)));{"_id":"paid","orders":3}
{"_id":"pending","orders":1}
{"_id":"shipped","orders":1}5 张订单中,3 张已支付,1 张已发货,1 张待处理。本节把 paid 和 shipped 视为已完成销售,因此后面的收入统计只使用 4 张订单。
items 是数组。如果直接按它分组,一张订单仍然是一个整体,无法分别累加其中的书籍。$unwind: "$items" 会为数组中每个元素产生一条管道文档。
展开后,$group 用 $items.bookId 作为分组键。销量是 quantity 之和,收入是 unitPrice * quantity 之和。$first 取出第一个书名快照;因为这组固定数据中同一本书的历史书名一致,这样做是可控的。
db.orders.aggregate([
{
$match: {
status: { $in: ["paid", "shipped"] }
}
},
{ $unwind: "$items" },
{
$group: {
_id: "$items.bookId",
title: { $first: "$items.title" },
quantity: { $sum: "$items.quantity" },
revenue: {
$sum: {
$multiply: ["$items.unitPrice", "$items.quantity"]
}
}
{"title":"MongoDB 从入门到实践","quantity":4,"revenue":356,"bookId":"book-mongodb"}
{"title":"数据建模的艺术","quantity":3,"revenue":237,"bookId":"book-design"}
{"title":"Node.js 项目开发","quantity":1,"revenue":69,"bookId":"book-node"}第一行可以手工复核:MongoDB 书在 order-1001、order-1003 和 order-1005 中分别售出 1、2、1 本,合计 4 本;单价快照是 89,因此收入是 356。
这里的金额都是整数,所以直接相乘与求和不会出现小数误差。有小数货币时,应储存最小货币单位的整数,或使用 Decimal128,不要把二进制浮点数当作精确金额。
$match 放在 $unwind 前面,意味着待处理订单不会被展开。较少的文档进入后续 $group 和 $sort,通常需要更少的计算与内存。
阶段不能随意交换。如果把状态筛选放在 $group 之后,原始 status 已经不在分组结果中,条件就无法表达。如果太早 $project 掉后面需要的字段,下一个阶段也会失去输入。
可以用一个简单问题检查每个阶段:“它接收的文档长什么样,交给下一步的又是什么样?”如果答不出来,先不要继续叠加阶段。
长管道结果不对时,有效的排查方式是从前往后逐段运行。先确认 $match 剩下多少订单,再确认 $unwind 产生多少个商品行。这会比盯着最终的一个大文档猜问题更直接。
只运行前两个阶段,并用 $count 计算展开后的商品行:
db.orders.aggregate([
{
$match: {
status: { $in: ["paid", "shipped"] }
}
},
{ $unwind: "$items" },
{ $count: "rows" }
]).forEach((document) => print(EJSON.stringify(document)));{"rows":6}4 张完成订单展开后共有 6 个商品行。order-1001 和 order-1005 各有两项,其余两张各有一项,数量能与原始文档对上。
订单保存 customerId,没有复制顾客姓名。需要在报表中显示姓名时,$lookup 可以用 orders.customerId 匹配 customers._id。它会把匹配结果放进数组,因此一对一的本例紧接着用 $unwind 取出唯一顾客。
这不是说引用模型必须每次都做关联。订单已经保存书名和单价快照,销售榜完全不需要 $lookup 书籍集合。只有结果确实需要顾客当前姓名时,才进行这次连接。
db.orders.aggregate([
{
$match: {
status: { $in: ["paid", "shipped"] }
}
},
{
$group: {
_id: "$customerId",
orders: { $sum: 1 },
spent: { $sum: "$total" }
}
},
{
$lookup: {
from: "customers",
localField: "_id",
foreignField: "_id",
{"orders":2,"spent":336,"customer":"林晓舟"}
{"orders":2,"spent":326,"customer":"周雨"}王青的 order-1004 仍是 pending,被第一个 $match 排除,因此这份“完成订单顾客榜”没有他。
$lookup 的外部匹配字段应有合适索引。本例匹配 customers._id,集合已自带 _id 唯一索引。如果换成无索引的普通字段,数据量增长后,关联代价会快速上升。
同一批已完成订单有时要生成两类结果:页面顶部显示总订单数和总收入,下方显示金额最高的三张订单。$facet 会把同一份输入分给多个子管道,最后返回一个包含多个数组的文档。
共用输入不等于所有报表都应塞进一个 $facet。子管道过多时,结果文档和内存都会增长。只把确实共享同一输入条件、需要一起返回的结果放在一起。
db.orders.aggregate([
{
$match: {
status: { $in: ["paid", "shipped"] }
}
},
{
$facet: {
summary: [
{
$group: {
_id: null,
orderCount: { $sum: 1 },
revenue: { $sum: "$total" }
}
},
{ $project: { _id: 0, orderCount: 1, revenue: 1 } }
],
{"summary":[{"orderCount":4,"revenue":662}],"topOrders":[{"_id":"order-1003","customerId":"customer-lin","total":178},{"_id":"order-1005","customerId":"customer-zhou","total":168},{"_id":"order-1001","customerId":"customer-lin",摘要中的 662 等于 158 + 158 + 178 + 168。榜单按 total 降序排列,并用 _id 解决相同金额的顺序,所以结果可重复。
$match 和简单 $project 可以逐条处理文档。$sort 通常要先看到待排序的输入,$group 需要保留各组累加状态,这类阶段会消耗更多内存。当输入较大时,MongoDB 8.0 可根据配置和 allowDiskUse 把部分中间数据写入磁盘,但磁盘溢写会带来额外延迟。
减小压力的顺序很朴素:先让 $match 尽量使用索引,尽早排除不需要的文档;只保留后续阶段需要的字段;不要在 $unwind 之前携带巨大的无关字段;限制 $facet 子管道数量与输出规模。
$facet 还有单独的内存边界:它在处理过程中生成的文档有 100 MB 上限,而且 allowDiskUse 不会让 $facet 把超出部分溢写到磁盘。最终输出还要遵守单个 BSON 文档 16 MiB 的上限。$facet 会把各子管道结果放进同一文档的数组,所以“每个榜单都不大”不代表合在一起一定安全。大结果应分页,或拆成独立查询。
allowDiskUse 是防止部分大型阶段因内存上限直接失败的工具,不是跳过管道设计的开关。如果每次聚合都大量溢写,应重新检查筛选、索引、阶段顺序和报表粒度。
现在,纸舟书店已经能从订单文档生成销售榜、顾客汇总和页面摘要。下一节会把这些数据接到 Node.js 服务,先做好连接、健康检查与稳定分页,再进入跨文档事务。