在MongoDB的聚合框架里,窗口操作符自5.2版本起得到了增强,$lastN便是其中之一。它专门用于在一个有序窗口内返回最后N个文档,常被用在需要取每组或整体末尾若干条记录的统计需求中。相比传统的先排序再分页,使用$lastN可以让数据库在服务端直接完成截取,降低应用层负担。

一、$lastN的基本语法与参数
$lastN属于窗口操作符,必须写在$setWindowFields阶段中使用。它的核心参数有两个:一个是n,表示要取的后几个文档数量;另一个是input,表示取值的来源字段或表达式。数据库会先按照指定的排序规则确定窗口内的顺序,然后从尾部向前数n个。
需要注意,n必须是一个正整数或者能解析为正整数的表达式,如果写成0或负数会报错。input如果指向的字段在某些文档中不存在,这些文档在排序时会被当作最小值处理,因此可能不会出现在最后的N个当中。下面是一段最简单的聚合示例,对全部文档按时间倒序后取最后3条:
db.orders.aggregate([
{
$setWindowFields: {
sortBy: { createTime: 1 },
output: {
lastThree: {
$lastN: {
n: 3,
input: "$$ROOT"
}
}
}
}
}
]);
在上面的代码中,sortBy里createTime为1表示升序,因此时间最大的文档排在窗口最后,$lastN取走的正是最新的三条。如果将sortBy改为-1,则窗口顺序反转,取到的就是最早的三条,这一点极易混淆,写管道时要特别确认排序方向。
二、在分组内使用$lastN
实际业务中更常见的场景是按某个维度分组,例如每个用户只保留其最近5次登录记录。$setWindowFields支持通过partitionBy来划分窗口,每个分组独立计算自己的末尾N个,不会互相干扰。
以下示例按userId分区,在区内按登录时间升序,取出每个用户最后两次登录信息:
db.loginLog.aggregate([
{
$setWindowFields: {
partitionBy: "$userId",
sortBy: { loginTime: 1 },
output: {
recentTwo: {
$lastN: {
n: 2,
input: "$$ROOT"
}
}
}
}
}
]);
这种写法比先按用户查再在代码里截尾要简洁得多,而且数据库可以利用分区并行计算。不过要留意,如果某个用户的文档总数不足n,则返回该用户全部文档,不会用空值补齐。这对后续消费数据是友好的,但如果你期望固定长度数组,就需要在应用层自行填充。
三、与$firstN及普通分页的对比
MongoDB同时提供了$firstN操作符,二者逻辑对称,只是一个取头一个取尾。如果你的排序是升序,想拿最早记录用$firstN,想拿最新用$lastN;若排序是降序则相反。选错操作符是新手常踩的坑。
传统做法是通过find加sort再limit,或者aggregate里先$sort再$limit。这两种方式在单集合无分组时表现类似,但一旦涉及partitionBy分组取末尾,原生$limit做不到组内独立截取,必须借助$group加$push后切片,既耗内存又繁琐。$lastN在管道内以流式窗口计算,对大数据量更友好。下列表格简要对比了三者差异:
| 方式 | 是否支持分组内截取 | 服务端截断 | 代码复杂度 |
|---|---|---|---|
| $lastN窗口 | 支持 | 是 | 低 |
| $sort+$limit | 不支持 | 是 | 低 |
| $group+$push+切片 | 支持 | 否 | 高 |
从表中可以看出,当需求落到组内末尾N条时,$lastN几乎是最优解。只是在MongoDB版本低于5.2的环境中无法使用,此时只能退化到$group方案。
四、空值与边界情况处理
如果input字段存在但值为null,该文档依然参与排序,null在升序中被视为比任何数值都小,所以在升序窗口里null文档永远排在前部,不会被$lastN选中,除非所有文档都是null。如果某分区内文档数小于n,如前文所述,返回全部,不会报错也不会补位。
另一个边界是n为表达式的情况,比如从另一个字段读取长度。此时若该字段不是正整数,管道会直接失败,因此建议在写入数据时校验,或在管道前用$addFields确保n合法。示例如下,用常量1确保至少取1条:
db.events.aggregate([
{
$addFields: {
safeN: { $max: [ "$wantCount", 1 ] }
}
},
{
$setWindowFields: {
sortBy: { ts: 1 },
output: {
tail: {
$lastN: {
n: "$safeN",
input: "$$ROOT"
}
}
}
}
}
]);
通过上述预处理,即使业务传了0或负数,聚合也不会中断。这种防御性写法在多人协作的系统中非常实用,可以避免脏数据引发大范围查询异常。
五、性能与索引建议
$setWindowFields阶段的排序操作会消耗资源,尤其是无分区的大集合全量排序。为了提升$lastN效率,应在排序字段上建立索引,使MongoDB能利用索引顺序避免内存排序。例如上文按createTime排序,就建议建{ createTime: 1 }或配合分区字段的复合索引。
当使用partitionBy时,把分区键放在索引前缀效果更好,如{ userId: 1, loginTime: 1 }。这样数据库可以顺着索引逐区扫描并直接取尾,不必每次重新划分。此外,output中若只需要某几个字段,不要直接input整个$$ROOT,改为input指定字段能减少返回文档体积,进一步加快管道速度。
db.loginLog.aggregate([
{
$setWindowFields: {
partitionBy: "$userId",
sortBy: { loginTime: 1 },
output: {
lastIp: {
$lastN: {
n: 1,
input: "$ip"
}
}
}
}
}
]);
上面示例只取了ip字段的最后一个,网络返回量比传整行小很多。在日志类高写入低查询时延要求的系统中,这类细节往往决定了接口能否稳定支撑高峰流量。