MongoDB聚合管道在处理分组数据时,经常需要从每个组中取出最后写入的几条记录,比如每个用户最近的三笔订单、每台设备最近的一条心跳数据。MongoDB 5.2版本之前,开发者通常用$push加$slice的组合来实现这个需求,但这种方式会把整个分组的数据都推入数组,在数据量较大的场景下会造成不必要的内存消耗。MongoDB 5.2引入了$lastN聚合运算符,它专门用于从分组中提取最后N个元素,能够在底层以更高效的方式完成这个操作。

理解$lastN的语法与工作机制
$lastN是一个累加器类型的聚合运算符,它接收两个参数:第一个参数表示需要返回的元素数量N,第二个参数表示从分组文档中取哪个字段的值。在很多使用场景下,第二个参数并不是简单地从当前文档中取值,而是需要先做一次$sort排序,确保取到的是真正的"最后"N条。因为MongoDB的聚合管道中,文档的物理存储顺序并不代表业务逻辑上的顺序,如果不显式指定排序规则,就无法保证"最后"这两个字符合预期。
与$firstN和$maxN类似,$lastN在$group阶段中会保留一个固定大小的缓冲区,每处理一个文档就尝试将值放入缓冲区,当缓冲区满时则丢弃最早的元素,从而保证内存占用始终只有N个元素的大小。这种设计与$push将所有元素全部保存在数组中完全不同,在分组数量大、组内元素多的场景下,内存表现会有质的提升。
db.orders.aggregate([
{
$sort: { orderDate: 1 }
},
{
$group: {
_id: "$userId",
lastThreeOrders: {
$lastN: {
n: 3,
input: { orderId: "$orderId", amount: "$amount" }
}
}
}
}
])
上面这个查询先按订单日期升序排序,然后按用户分组,取出每个用户最近的三笔订单。注意这里input字段可以是一个表达式,不仅仅限于单个字段名,也可以像示例中那样构造一个子文档对象。当每个用户的订单数量超过3时,聚合引擎内部会自动丢弃更早的订单记录,保留最后的三条。这个写法与$push加$slice的旧方案在输出格式上保持一致,迁移成本并不高。
$lastN在$group与$project中的行为差异
$lastN既可以用于$group阶段作为累加器使用,也可以用于$project阶段处理数组字段。在$group中,$lastN的输入是分组内逐个到达的文档,管道引擎会按文档流入的顺序做舍取;而在$project中,$lastN的输入则是文档中已有的数组字段,运算符需要自己对数组做遍历处理。两种场景下虽然操作符名称相同,但语义上有细微差别。
在$project阶段使用$lastN时,不需要预先做$sort,因为数组本身就带有顺序,直接截取数组末尾的N个元素即可。这种用法适合处理已经存储在文档数组字段中的数据,比如在一个包含传感器历史数据的文档中,取出最近的10条温度记录。由于$project阶段不会处理多个文档的聚合逻辑,它的执行效率更高,也不涉及跨文档的状态维护。
db.sensors.aggregate([
{
$project: {
deviceId: 1,
recentTemperatures: {
$lastN: {
n: 10,
input: "$readings"
}
}
}
}
])
另一种需要特别留意的情况是,在$group阶段结束后,如果分组字段本身是按某些业务维度划分的,而分组内并没有做过排序,那么$lastN返回的元素顺序是不确定的。MongoDB官方文档中明确说明,$lastN的输出顺序与文档在管道中的输入顺序有关,并不保证全局有序。因此,在实际业务开发中,如果"最后N条"中的"最后"包含明确的时间含义,务必在$group之前增加$sort阶段,确保排序依据与业务语义一致。
$lastN与$push加$slice的性能与功能对比
在$lastN出现之前,$push配合$slice是最常见的分组取尾方案,具体的写法是在$group阶段用$push收集所有数据,然后在$project阶段用$slice截取后N个。这种方案的缺陷在于$push需要将分组内所有文档的值都存放在数组中,即便之后只取三个,中间过程也会占用大量内存。当分组内数据量达到数十万甚至上百万条时,聚合操作可能直接触发内存限制报错。
从功能角度来说,$push加$slice的优势在于兼容性极好,MongoDB 3.2以上的所有版本都支持,而$lastN要求MongoDB版本不低于5.2。对于老版本的用户,或者暂时无法升级数据库环境的团队,仍然需要沿用旧方案。两种方案的代码如下:
// 旧方案:$push + $slice
db.orders.aggregate([
{
$sort: { orderDate: 1 }
},
{
$group: {
_id: "$userId",
allOrders: { $push: { orderId: "$orderId", amount: "$amount" } }
}
},
{
$project: {
lastThreeOrders: { $slice: ["$allOrders", -3] }
}
}
])
// 新方案:$lastN
db.orders.aggregate([
{
$sort: { orderDate: 1 }
},
{
$group: {
_id: "$userId",
lastThreeOrders: {
$lastN: {
n: 3,
input: { orderId: "$orderId", amount: "$amount" }
}
}
}
}
])
从执行计划的角度分析,$push加$slice的方案有两个阶段需要处理数据,一次是$group中的数组展开,另一次是$project中的截取操作。而$lastN在单一阶段内就能完成全部工作,减少了管道阶段数量,也减少了数据传输的开销。官方基准测试数据表明,在相同数据规模下,$lastN的内存占用减少了约80%,执行时间也有显著下降。尤其在分组键基数较大、每组数据量不均衡的场景中,这个优势会被进一步放大。
使用$lastN时的限制与版本兼容性策略
$lastN对内存的使用虽然是恒定N个元素的大小,但N本身不能设置过大,并且n参数必须是正整数,不支持变量表达式动态指定数量。如果传入一个字段引用作为n的值,MongoDB会抛出语法错误。另外与$push不同,$lastN在$group阶段无法与$sort累加器同时使用,如果业务场景需要在分组内先排序再取尾,只能在$group之前单独编排$sort阶段。
还有一个值得关注的行为特征是,当组内元素数量小于N时,$lastN返回所有元素而不是用空值补齐。这一特性在某些业务场景中可能导致输出数组的长度不一致,例如有的组返回三条,有的组只返回一条,前端展示时如果对数组长度有固定预期,就需要在管道后的代码里做补齐处理。对于版本兼容性,不少团队还在使用MongoDB 4.x或5.0版本,$lastN在这些版本中不可用,此时可以使用$push加$slice,或者利用$accumulator自定义JavaScript函数来实现,但$accumulator的执行速度较慢,不推荐在高频查询中使用。
实际业务场景中的完整应用示例
以一个用户行为分析系统为例,运营人员需要查看每个用户在最近一次活动中的最后5条操作日志,并标记出这些操作的类型。这个场景中,日志集合的数据量可能达到数千万条,如果使用旧方案会耗费大量内存。利用$lastN可以写出简洁高效的聚合管道:
db.user_actions.aggregate([
{
$match: {
eventTime: {
$gte: ISODate("2024-06-01T00:00:00Z"),
$lt: ISODate("2024-07-01T00:00:00Z")
}
}
},
{
$sort: { eventTime: -1 }
},
{
$group: {
_id: "$userId",
recentActions: {
$lastN: {
n: 5,
input: {
actionType: "$actionType",
pageUrl: "$pageUrl",
eventTime: "$eventTime"
}
}
}
}
},
{
$project: {
recentActions: 1,
totalActions: { $size: "$recentActions" }
}
}
])
在这个示例中,需要注意$sort使用的是eventTime降序,配合$lastN取尾部的逻辑,实际取到的是时间上最近的5条记录。由于$lastN输出的是一个数组,在后续投影阶段可以用$size运算符计算实际返回的记录数,以识别哪些用户的活跃度低于5次。聚合管道的输出还可以继续传递给其他阶段做分组统计或数组展开,从而在不落盘的情况下完成整个分析链路的处理。
从实战角度来看,$lastN适合的场景还包括:物联网平台中每个设备的最新状态快照、电商系统中每个商品的最近几条评价、金融系统中每张信用卡的最近几笔消费明细、社交平台中每个用户的最新几条动态。凡是涉及分组内取尾部数据的场景,都可以优先评估$lastN是否适用。在使用时注意先排序后分组,并且结合$match尽早过滤无效数据,才能最大化发挥这个运算符的性能优势。