MongoDB聚合管道:$match、$group、$sort、$project、$lookup
说实话,MongoDB的聚合管道是我在项目中用得最多的功能之一。记得刚接触NoSQL那会儿,总觉得它做复杂查询不如SQL顺手。后来真正理解了聚合管道的设计思想,才发现——这玩意儿其实比SQL更灵活,只是思维方式要转个弯。
聚合管道的核心,说白了就是「流水线作业」。你把一堆文档扔进去,经过一道道工序处理,最后得到你想要的结果。每一道工序就是一个阶段(stage),用不同的操作符来控制。今天咱们就聊聊最常用的五个:$match、$group、$sort、$project 和 $lookup。
核心思想:聚合管道是文档的流水线,每个阶段接收上一阶段的输出,处理后传给下一阶段。顺序很重要,顺序错了,结果可能天差地别。
$match:先过滤,再干活
我个人的习惯是,只要能用 $match 提前过滤掉的文档,绝不留到后面处理。为什么?因为管道越往后数据量越大,早过滤早轻松。这跟SQL里写WHERE子句是一个道理。
$match 的语法跟普通查询的筛选条件一模一样。你可以在里面用 $gt、$lt、$in 这些操作符,也可以嵌套 $and、$or。
db.orders.aggregate([
{ $match: { status: "completed", total: { $gte: 100 } } }
])
小技巧:把 $match 放在管道最前面,可以利用索引加速。如果放在中间,索引就失效了。我曾经因为把 $match 放在了 $group 后面,导致一个报表查询跑了30秒,调整顺序后降到2秒。
$group:分组聚合,SQL的GROUP BY
$group 是聚合管道的核心。它把文档按某个字段分组,然后对每组做聚合计算。常用的累加器有 $sum、$avg、$max、$min、$push 等。
这里有个坑——_id 字段是必须的,它指定了按什么分组。如果你传 null,那就把所有文档当成一组。
db.orders.aggregate([
{ $match: { status: "completed" } },
{
$group: {
_id: "$category", // 按分类分组
totalSales: { $sum: "$total" },
avgAmount: { $avg: "$total" },
count: { $sum: 1 },
items: { $push: "$itemName" } // 收集该组所有商品名
}
}
])
你看,这个管道先过滤出已完成订单,再按分类统计销售额、平均金额、订单数和商品列表。我在做电商后台的销售看板时,这种查询几乎天天用。
$sort:排序,别放错位置
$sort 本身很简单,传一个排序规则就行。但我要强调一点——排序的位置很关键。
如果你在 $group 之前排序,那是对原始文档排序;如果在 $group 之后排序,那是对聚合结果排序。两者含义完全不同。
// 对聚合结果排序
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$category", totalSales: { $sum: "$total" } } },
{ $sort: { totalSales: -1 } } // 销售额降序
])
注意:如果数据量很大,$sort 会消耗大量内存。MongoDB默认给排序分配的内存上限是100MB,超过会报错。这时候要么加索引,要么用 allowDiskUse(true) 允许使用磁盘。
$project:投影与重塑
$project 的作用有两个:一是选择要输出的字段(类似SQL的SELECT),二是创建新字段或改变字段结构。
我经常用它来做数据清洗。比如把时间戳转成可读格式,或者把嵌套的字段提到顶层。
db.orders.aggregate([
{ $match: { status: "completed" } },
{
$project: {
_id: 0, // 隐藏_id
orderId: "$_id", // 重命名
customer: "$customer.name",
total: 1, // 保留原字段
tax: { $multiply: ["$total", 0.06] }, // 计算新字段
orderYear: { $year: "$orderDate" }
}
}
])
这里 1 表示保留,0 表示隐藏。你可以用各种表达式操作符来创建新字段,比如 $year、$substr、$cond 等。
$lookup:关联查询,MongoDB的JOIN
这是很多人最关心的。MongoDB不是关系型数据库,但实际业务中难免要关联两张表。$lookup 就是干这个的。
它的语法稍微复杂一点,但逻辑很清晰:指定本地字段、目标集合、目标字段,然后输出一个数组字段。
db.orders.aggregate([
{ $match: { status: "completed" } },
{
$lookup: {
from: "customers", // 关联的集合
localField: "customerId", // 当前集合的字段
foreignField: "_id", // 目标集合的字段
as: "customerInfo" // 输出字段名(数组)
}
},
{ $unwind: "$customerInfo" } // 展开数组,变成对象
])
嗯,这里要注意一点。$lookup 输出的 customerInfo 是一个数组,即使只匹配到一条记录也是数组。如果你确定是一对一关系,可以用 $unwind 把它展开成对象。但如果你不展开,后面用 $project 取字段时就要用 $arrayElemAt 或者点号语法。
实战经验:我在做一个订单管理系统时,需要把订单、客户、商品三张表关联起来。一开始用了三个 $lookup,结果管道跑得很慢。后来发现,其实可以先在应用层做一次缓存,把客户和商品信息加载到内存里,只关联必要的字段。这样数据库压力小了很多。
综合案例:一个完整的聚合管道
咱们把上面五个操作符串起来,做一个完整的例子。假设我们要统计每个分类下销售额最高的商品。
db.orders.aggregate([
// 第一步:过滤
{ $match: { status: "completed", orderDate: { $gte: ISODate("2024-01-01") } } },
// 第二步:关联客户信息
{
$lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
},
{ $unwind: "$customer" },
// 第三步:按分类和商品分组
{
$group: {
_id: { category: "$category", item: "$itemName" },
totalSales: { $sum: "$total" },
orderCount: { $sum: 1 },
avgRating: { $avg: "$customer.rating" }
}
},
// 第四步:按分类内销售额排序
{ $sort: { "_id.category": 1, totalSales: -1 } },
// 第五步:重塑输出结构
{
$project: {
_id: 0,
category: "$_id.category",
item: "$_id.item",
totalSales: 1,
orderCount: 1,
avgRating: { $round: ["$avgRating", 1] }
}
}
])
这个管道做了五件事:过滤出今年已完成订单 → 关联客户信息 → 按分类和商品分组统计 → 按分类排序 → 输出干净的结果。每一步都清晰可读,出了问题也容易定位。
我的建议:写聚合管道时,不要一口气写完。先写 $match 看看过滤结果对不对,再加 $group 验证分组逻辑,最后加 $lookup 和 $project。分段调试,能省下大量排错时间。
避坑指南
- 索引失效:
$match如果在$group或$lookup之后,索引就帮不上忙了。尽量把$match放在最前面。 - 内存溢出:大数据量的
$sort和$group容易撑爆内存。记得加allowDiskUse(true)。 - 关联爆炸:多个
$lookup会导致文档体积急剧膨胀。如果关联的集合很大,考虑用$lookup的pipeline参数做子查询过滤。 - 字段名冲突:
$lookup输出的数组字段名不要跟已有字段重名,否则会覆盖。
好了,聚合管道的五个核心操作符就聊到这儿。说白了,它就是MongoDB里最强大的数据处理工具。你只要记住「流水线」这个比喻,每个阶段只做一件事,组合起来就能解决大部分数据统计需求。
公众号:蓝海资料掘金营,微信deep3321