MongoDB聚合管道:$match、$group、$sort、$project、$lookup

说实话,MongoDB的聚合管道是我在项目中用得最多的功能之一。记得刚接触NoSQL那会儿,总觉得它做复杂查询不如SQL顺手。后来真正理解了聚合管道的设计思想,才发现——这玩意儿其实比SQL更灵活,只是思维方式要转个弯。

聚合管道的核心,说白了就是「流水线作业」。你把一堆文档扔进去,经过一道道工序处理,最后得到你想要的结果。每一道工序就是一个阶段(stage),用不同的操作符来控制。今天咱们就聊聊最常用的五个:$match$group$sort$project$lookup

核心思想:聚合管道是文档的流水线,每个阶段接收上一阶段的输出,处理后传给下一阶段。顺序很重要,顺序错了,结果可能天差地别。

原始集合 $match 过滤文档 $group 分组聚合 $sort 排序 $project 投影/重塑 $lookup 关联查询 $unwind 展开数组 $addFields 添加字段 最终结果集

$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 会导致文档体积急剧膨胀。如果关联的集合很大,考虑用 $lookuppipeline 参数做子查询过滤。
  • 字段名冲突:$lookup 输出的数组字段名不要跟已有字段重名,否则会覆盖。

好了,聚合管道的五个核心操作符就聊到这儿。说白了,它就是MongoDB里最强大的数据处理工具。你只要记住「流水线」这个比喻,每个阶段只做一件事,组合起来就能解决大部分数据统计需求。


公众号:蓝海资料掘金营,微信deep3321