广告位 · 页头(后台-模块管理-新建模块后替换此区域)

MongoDB聚合管道如何用$first和$last获取每组首尾文档?

在MongoDB中,如果每个分组内有多条文档,常见的需求是获取每组最早、最晚或者排序后的第一条与最后一条。这个需求用聚合管道的$first和$last累加器可以完成,但这两个操作符的行为高度依赖输入文档的顺序。如果不理解这一点,很容易在$group阶段拿到错误数据。

MongoDB聚合管道如何用$first和$last获取每组首尾文档?

一、$first与$last在$group阶段的基本定位

$first和$last是聚合管道中常用的累加器,它们主要在$group阶段使用。$first返回分组中第一个输入文档的指定表达式结果,$last则返回最后一个输入文档的指定表达式结果。这里要特别强调,所谓的第一个和最后一个,并不是按照某个业务字段自动排序后的概念,而是文档进入$group阶段时的顺序。也就是说,如果前面的管道没有做任何排序,它们反映的是存储引擎扫描出来的自然顺序。自然顺序可能会随着数据写入、删除、空间复用而变化,因此直接依赖这种顺序来获取业务上的首尾文档,结果往往不可靠。

假设有一个订单集合,结构如下:

[
  { "_id": 1, "customer": "A", "amount": 100, "createdAt": "2024-01-02" },
  { "_id": 2, "customer": "A", "amount": 80, "createdAt": "2024-01-01" },
  { "_id": 3, "customer": "B", "amount": 200, "createdAt": "2024-01-03" }
]

如果我们直接按customer分组,并试图用$first: "$$ROOT"获取每组第一条完整文档,那么返回的结果只取决于扫描顺序。比如针对客户A,可能第一条是_id:1,也可能由于存储层的变化变成_id:2。对于时间敏感的场景,例如取每个客户最早的一笔订单,这种写法显然不能满足要求。

要使用完整文档,通常配合$$ROOT变量,示例管道如下:

db.orders.aggregate([
  {
    $group: {
      _id: "$customer",
      firstDoc: { $first: "$$ROOT" },
      lastDoc: { $last: "$$ROOT" }
    }
  }
])

这段管道虽然语法正确,但如果$group之前的输入没有排序,firstDoc和lastDoc的内容就不能代表业务上的首尾文档。因此,真正想获取按时间字段排序后的首尾记录,必须在分组前显式加入$sort阶段。

二、先排序再分组:可靠获取真正首尾文档

由于$first和$last只认输入顺序,正确的做法是在$group之前使用$sort把数据排好。比如要获取每个客户最早和最晚的订单,可以先按customer和createdAt做升序排序,再分组。此时$first就是每个客户最早的一条,$last就是每个客户最晚的一条。

db.orders.aggregate([
  { $sort: { customer: 1, createdAt: 1 } },
  {
    $group: {
      _id: "$customer",
      firstDoc: { $first: "$$ROOT" },
      lastDoc: { $last: "$$ROOT" }
    }
  }
])

这里为什么要在$sort中包含分组字段customer?因为$group会根据_id进行分组,但不会自动做排序。如果只按createdAt排序,同一客户的多条文档可能不会连续排列,虽然分组仍然正确,但在某些执行计划中可能无法充分利用复合索引。建立{ customer: 1, createdAt: 1 }这样的复合索引,可以让排序阶段走索引,避免内存排序带来的性能开销。

需要留意的是,$group为了在处理完所有输入文档后输出首尾结果,必须把每个分组的输入都保留在内存中。如果某个分组非常大,内存占用会明显上升。此时直接用$$ROOT保留完整文档可能不够经济,可以只投影需要的字段,减少单文档体积。例如:

db.orders.aggregate([
  { $sort: { customer: 1, createdAt: -1 } },
  {
    $group: {
      _id: "$customer",
      latestOrder: { $first: { orderId: "$_id", amount: "$amount" } },
      earliestOrder: { $last: { orderId: "$_id", amount: "$amount" } }
    }
  }
])

这段管道按createdAt降序排序,因此$first得到最新订单,$last得到最早订单,并且只保留了订单号和金额,减少不必要的字段。

三、$sortArray加$arrayElemAt:数组场景下更灵活

如果数据已经通过其他管道阶段聚合成数组,或者你希望一次分组后再对组内文档做复杂排序,可以使用$push把所有文档收集成数组,然后借助$sortArray对数组排序,最后用$arrayElemAt取出第一个和最后一个元素。这种方式在MongoDB 5.2及以上版本可用,它允许在不重新扫描整个集合的情况下,对已经形成的数组做排序。

db.orders.aggregate([
  {
    $group: {
      _id: "$customer",
      docs: { $push: "$$ROOT" }
    }
  },
  {
    $project: {
      firstDoc: {
        $arrayElemAt: [
          { $sortArray: { input: "$docs", sortBy: { createdAt: 1 } } },
          0
        ]
      },
      lastDoc: {
        $arrayElemAt: [
          { $sortArray: { input: "$docs", sortBy: { createdAt: -1 } } },
          0
        ]
      }
    }
  }
])

这个方案的优点是可以在同一个分组结果中同时按不同方向排序,而且排序逻辑清晰,不依赖$group之前的输入顺序。但它也有明显缺点:$push会把整个分组的文档放在一个数组里,如果某个分组的文档数量极大,数组有可能超过MongoDB单文档16MB的限制,导致管道执行失败。因此该方法更适合组内文档数量可控的场景,例如每个用户最近的若干条日志、每个设备当天产生的有限事件等。

与$first加$sort相比,$sortArray方案没有避免内存占用,只是把压力从$group内部转移到了数组构建和排序阶段。如果数据规模较大,仍然建议先在源头完成过滤和投影,避免把不需要的字段带进数组。

四、$setWindowFields窗口函数与性能注意事项

MongoDB 5.0开始提供了$setWindowFields阶段,它支持窗口函数语义,可以在不破坏原始文档结构的情况下,为每条文档附加分组内首尾值。与$group不同,$setWindowFields不会把多条文档合并成一条,而是保留原有文档,同时在每条文档上增加计算字段。示例:

db.orders.aggregate([
  {
    $setWindowFields: {
      partitionBy: "$customer",
      sortBy: { createdAt: 1 },
      output: {
        firstDoc: {
          $first: "$$ROOT",
          window: { documents: ["unbounded", "unbounded"] }
        },
        lastDoc: {
          $last: "$$ROOT",
          window: { documents: ["unbounded", "unbounded"] }
        }
      }
    }
  },
  { $match: { $expr: { $eq: ["$_id", "$firstDoc._id"] } } }
])

这段管道按customer分区,并按createdAt升序排序,在每个分区内计算出首条和末条完整文档。由于窗口设置为unbounded到unbounded,表示整个分区范围,所以每条文档上附加的firstDoc和lastDoc都是同一组的首尾信息。最后通过$match只保留每组第一条文档,就能得到每个客户的最早订单,同时还能携带最晚订单信息。

从性能角度看,无论使用$sort配合$group,还是$setWindowFields,排序阶段都是关键。为排序字段建立合适的索引,可以显著降低CPU和内存消耗。如果排序数据量超过100MB内存上限,MongoDB会报错,除非显式开启allowDiskUse。因此,在设计聚合管道时,应尽量通过$match提前缩小数据范围,并为过滤条件和排序字段建立复合索引。

另外,$first和$last虽然能返回完整文档,但不应该用来替代专门的去重或最新值查询。如果只是想取某个字段的最大值或最小值,$max和$min更轻量。如果必须返回完整文档,可以根据数据量、MongoDB版本以及对内存的敏感程度,在$sort + $group、$push + $sortArray和$setWindowFields之间做选择。小数据量且逻辑直观时,第一种方案最为简洁;组内数据可控时,第二种方案排序方向更灵活;需要保留原始文档并做复杂窗口计算时,第三种方案更合适。

广告位 · 文章底部(后台-模块管理替换此区域)