MongoDB聚合管道中的$first操作符用于从一组文档中提取第一个文档的某个字段值。它并不是一个独立的管道阶段,而是作为累加器表达式出现在$group阶段里,通常配合$sort阶段先把数据排好序,再按分组键聚合。很多人在使用时容易忽略排序步骤,导致取出的第一条记录不是时间最早或数值最小的那一条。接下来我们从语法细节、排序配合、误区和实战场景几个角度逐步拆解。

$first操作符的基本语法与运行机制
$first的典型写法是在$group阶段中指定某个字段,例如{ $first: "$createdAt" }。它表示针对当前分组内的所有文档,返回第一个文档里createdAt字段的值。这里的分组由_id决定,如果_id设置为某个业务字段,比如用户ID,那么每个用户会形成一个分组,$first就会从该用户的所有文档中取第一条。
需要特别注意,$first本身不会主动排序。它依赖文档进入$group阶段时的顺序。如果前面没有$sort,MongoDB会按照存储引擎的自然顺序读取文档,这个顺序可能是插入顺序,也可能与索引扫描顺序有关,并不能保证稳定。因此,想要稳定获取每组最早的一条记录,必须在$group之前添加$sort阶段,对时间字段做升序排序。
从MongoDB 5.0开始,$first还可以用在$setWindowFields阶段中作为窗口函数使用,但日常开发里最广泛的应用仍然是$group内部的累加器场景。在$group中,$first只能引用字段路径,不能直接写常量表达式。如果需要返回整个文档对象,可以配合$$ROOT变量,但通常建议只提取需要的字段,减少聚合结果的内存占用。
$first与$sort配合实现按条件取首条记录
假设有一个订单集合,每个订单包含userId、amount和createdAt字段。如果要查询每个用户最早的一笔订单金额,直接按用户分组并取$first金额是不稳定的。正确做法是先按createdAt升序,再按userId分组,最后使用$first获取金额。
db.orders.aggregate([
{
$sort: { createdAt: 1 }
},
{
$group: {
_id: "$userId",
firstAmount: { $first: "$amount" },
firstCreatedAt: { $first: "$createdAt" }
}
}
])
这段脚本先把整个订单集合按创建时间从早到晚排序,然后按用户ID分组。因为排序后的文档顺序已经固定,$first取到的就是每个用户最早的订单金额和创建时间。如果还需要展示订单号,可以把$first: "$orderId"加进去。$sort阶段可以跟多个排序字段,例如先按用户ID、再按时间,但这会影响后续分组顺序,一般排序字段要覆盖实际需求。
当数据量很大时,$sort会消耗较多内存。MongoDB默认允许聚合管道使用100MB内存,如果排序数据超过这个限制会报错。此时可以为排序字段创建索引,让$sort利用索引顺序输出文档,避免内存排序。例如对createdAt建立索引后,$sort升序可以直接走索引扫描,性能会明显提升。如果没有索引,也可以设置{ allowDiskUse: true }允许聚合操作写入临时磁盘文件,但速度会慢一些。
另一个常见做法是把$sort放在$group之前,而不是之后。虽然两者都能写出正确结果,但先排序再分组会处理整个集合的排序,而后排序只是对分组结果排序,两者语义不同。要取每组第一条记录,必须在分组前排序,这一点不能颠倒。
$first与$last的对比及常见误区
$first和$last是一对对称的累加器。$first取分组内第一个文档的字段值,$last取最后一个文档的字段值。它们的行为同样依赖输入顺序。如果先按时间升序排序,$first是最早记录,$last是最晚记录;如果按时间降序排序,$first就成了最晚记录,$last是最早记录。很多人在不同排序方向下混淆这两个操作符的含义,导致查询结果与预期相反。
一个典型误区是认为$first总是代表插入顺序中的第一条,或者认为$group内部会自动排序。实际上MongoDB的聚合管道是流式处理,$group会接收上游阶段输出的文档,并按照文档到达的顺序累加。$sort阶段可以改变这个顺序,$match、$project等阶段不会改变顺序。如果省略$sort,不应该假设任何固定顺序。
另一个容易混淆的是$first和$top、$topN等操作符的区别。$first只取出一个字段值,$top则可以返回排序后的整个文档,并且不要求前面使用$sort。例如MongoDB 5.2及以上版本可以使用{ $top: { sort: { score: -1 }, output: "$name" } }在$group中直接指定组内排序规则。对于需要取每组前N条的场景,$topN比$first加$sort更直观,但在版本兼容性上不如$first广泛。
还需要注意,如果分组内某些文档缺少目标字段,$first会返回null。这与其他累加器如$max、$min的处理有所不同,$max会忽略null和缺失字段。因此,如果业务上希望跳过空值,可能需要在$group之前用$match过滤,或者改用$top操作符指定排序规则并处理缺失值。
实际应用场景:排行榜、最新状态与去重
排行榜是$first的经典应用之一。比如游戏玩家每局得分记录在一个集合中,每个玩家可能有多条记录。要找出每个玩家的最高分,可以先按得分降序排序,再按玩家ID分组,用$first取出最高分和对应时间。这样每个玩家只返回一条记录,便于前端展示排行榜。
db.game_scores.aggregate([
{
$sort: { score: -1, playedAt: -1 }
},
{
$group: {
_id: "$playerId",
highestScore: { $first: "$score" },
achievedAt: { $first: "$playedAt" }
}
},
{
$sort: { highestScore: -1 }
}
])
这段脚本先按得分降序、再按时间降序排序,分组后$first拿到每个玩家的最高分及最近取得时间,最后再按最高分降序整理整个排行榜结果。如果玩家有多条相同最高分记录,由于第二排序字段是时间降序,$first会取到最近一次达到该分数的记录,符合多数排行榜的业务规则。
在物联网或状态监控系统中,设备会不断上报状态,集合中同一设备可能存在大量历史状态。想要获得每台设备的最新状态,可以先按上报时间降序,再按设备ID分组,用$first取状态字段和最后一次上报时间。相比先按时间升序再取$last,直接降序取$first在阅读上更符合“最新”语义。
db.device_status.aggregate([
{
$sort: { reportedAt: -1 }
},
{
$group: {
_id: "$deviceId",
latestStatus: { $first: "$status" },
lastReportedAt: { $first: "$reportedAt" }
}
}
])
该场景下如果设备上报频率很高,集合数据量会迅速增长。为reportedAt和deviceId建立复合索引可以显著提升排序和分组效率。同时建议定期归档历史数据,避免单次聚合扫描过多文档。
去重场景也可以借助$first实现。比如某张日志表中每条URL被访问多次,要得到每个URL最早一次访问的用户和访问时间,可以先按访问时间升序,再按URL分组取$first。这样得到的结果相当于按URL去重并保留首次访问信息。如果查询条件是最近24小时,可以在管道最前面加$match过滤时间范围,减少后续阶段处理的数据量。
$first虽然简单,但只有正确结合$sort、索引和业务规则,才能稳定拿到预期中的“第一个”字段值。在实际编写聚合管道时,先明确分组键、排序字段和排序方向,再决定使用$first还是$last,可以避免大多数数据错位问题。
MongoDB聚合管道$first操作符分组排序修改时间:2026-08-30 08:57:03