MongoDB的聚合管道里,$last是一个容易被误读的操作符。它出现在$group阶段时,用来返回当前分组中流入该组的最后一个文档的某个字段值,或者返回整个文档。很多人以为$last会自动按照时间字段找最新记录,实际上它完全依赖于管道上游送进来的文档顺序。如果上游没有显式排序,那么所谓的最后一个,只是磁盘扫描或索引遍历碰巧产生的顺序,这在分片集群或者不同执行计划下可能发生变化。

一、$last在$group中的基本机制与文档顺序依赖
在聚合管道中,$group阶段会把拥有相同_id的文档划分到同一个分组。对于每一个分组,MongoDB会按照文档进入$group的顺序,依次处理累加器表达式。$last的作用是:取分组内最后一个进入的文档里指定字段的值。这里的最后一个,不是按业务时间排的最后一个,而是按管道顺序的最后一个。因此,若想用$last拿最新订单、最新日志,必须在$group之前用$sort把数据排好。
下面这段管道先按用户ID分组,再取每个用户最后出现的那条记录的省份字段。由于前面没有排序,结果并不可靠。它仅仅表示该用户在分组处理时碰到的最后一行,可能是旧数据。
db.user_log.aggregate([
{
$group: {
_id: "$user_id",
last_province: { $last: "$province" }
}
}
]);
正确的做法是将$sort放在$group之前。例如先按用户ID和发生时间排序,再分组取$last,这样每个用户组内时间最晚的记录才会被拿到。注意$sort的字段最好包含分组键以及时间键,否则同组内部顺序仍可能不稳定。
db.user_log.aggregate([
{ $sort: { user_id: 1, event_time: 1 } },
{
$group: {
_id: "$user_id",
last_event: { $last: "$$ROOT" }
}
}
]);
二、$last与$first的差异及适用场景对比
$first和$last互为镜像:$first取分组内第一个进入的文档字段,$last取最后一个。二者性能特征相似,都只需要维护一个当前值,不需要缓存全组。在已排序的管道里,$first适合拿最早记录,比如首次注册信息;$last适合拿最新状态,比如设备最后一次上报。如果排序方向反过来,$first和$last拿到的内容也会互换,所以二者没有绝对优劣,关键看上游顺序。
在某些场景中,开发者会用$push把组内所有文档塞进数组,再用数组下标取末尾,例如{ $arrayElemAt: [ { $push: "$$ROOT" }, -1 ] }。这种做法和$last都能拿到最后文档,但$push会把全组文档放进内存,数据量大时容易超过100MB限制或变慢。而$last只保留一个值,内存占用极小。因此,若只要末尾一条,优先用$last而不是$push加下标。
| 方式 | 内存占用 | 是否依赖顺序 | 适用规模 |
|---|---|---|---|
| $last | 低 | 是 | 大、中、小 |
| $first | 低 | 是 | 大、中、小 |
| $push加下标 | 高 | 是 | 小、中 |
还有一点容易混淆:$last可以用在$group里,也可以作为表达式用在$project等阶段,但含义不同。在$group外单独使用$last是错误的,因为它没有分组上下文。只有在$group的累加器位置,$last才有按组分末的意义。如果在$project里写{ $last: "$arr" },那是取数组字段的最后一个元素,而不是文档分组末位,这是另一个操作符语境,不要弄混。
三、结合排序、分页与数组字段的工程实践
真实业务常需要按时间窗口取每组最后状态,并做分页。一种稳妥写法是:先用$match缩小时间范围,再$sort,再$group拿$last,最后$skip和$limit做分页。由于$group会打乱整体顺序,分页应在$group之后做,否则分页边界会和分组逻辑冲突。下面示例取出最近一天内每个设备最后一次上报的温度,并取前十条。
db.device_report.aggregate([
{ $match: { report_time: { $gte: ISODate("2023-01-01T00:00:00Z") } } },
{ $sort: { device_id: 1, report_time: 1 } },
{
$group: {
_id: "$device_id",
last_temp: { $last: "$temperature" },
last_time: { $last: "$report_time" }
}
},
{ $sort: { last_time: -1 } },
{ $skip: 0 },
{ $limit: 10 }
]);
当文档里包含数组字段,比如一个用户有多条标签,想在取最后文档时连带其标签数组,直接用$last: "$tags"即可,它会把该文档的整个数组作为值返回,不会跨文档拼接。若想跨文档把每组的数组都合并,则要用$push或$addToSet,但这和取最后一个文档不是一回事。明确自己要的是末文档的数组,还是全组数组的聚合,能少走很多弯路。
在分片集群中,若排序字段和分组键没有共同作用,mongos可能先在分片做局部$group,再汇总,这时$last的顺序更加依赖各分片返回顺序。因此生产环境务必保证$sort覆盖分组键,并尽量让排序和分组键使用同一个索引,避免额外的内存排序。通过explain观察是否出现SPILL或Blocking Sort,可验证$last是否处于安全有序的管道中。