在MongoDB的聚合框架中,取每个分组内排名前N的文档一直是个让人头疼的问题。早期版本缺乏原生的分组取前N能力,开发者往往需要先按分组字段和排序字段做$sort,再用$group把同组文档塞进数组,最后在应用代码里手动截取。MongoDB 5.2开始提供的$topN运算符彻底改变了这一局面,它允许我们在$group阶段直接声明每个组返回多少条排好序的文档,而不需要任何额外的客户端处理。

$topN运算符的语法与核心参数
$topN是MongoDB聚合管道中$group阶段的一个累加器(accumulator),它的作用是:在当前分组内,按照指定的排序规则,选出值最大的前n个文档或字段。它的基本语法结构包含三个必填或重要参数:n表示要取的数量,sortBy是一个文档用于指定排序字段和方向,output则定义每组要输出什么内容(可以是$$ROOT代表整个文档,也可以是某个字段名)。
需要注意的是,n必须是一个正整数或者解析为正整数的表达式,如果n大于组内文档数则返回全部文档。sortBy里的字段如果不存在于某些文档中,这些文档会被当作缺失值处理,通常在升序里排最前、降序里排最后。output设为$$ROOT时,返回的是完整子文档数组;设为特定字段如"score"时,返回的是该字段值的数组。下面是一段最简单的用法示例,按班级分组取出分数最高的前三名学生整个文档:
db.students.aggregate([
{
$group: {
_id: "$class",
topThree: {
$topN: {
n: 3,
sortBy: { score: -1 },
output: "$$ROOT"
}
}
}
}
]);
上面的代码在students集合上执行,先按class字段分组,然后每个组计算topThree数组。由于output是$$ROOT,topThree里装的就是完整的学生文档,且已经按照score从大到小排好。这种写法比先$sort全表再$group用$push收集再$slice要直观很多,也更容易让查询优化器使用复合索引。
多字段排序与复杂分组实战
实际业务里,排名规则常常不是单一字段。例如电商场景中要取每个品类下销售额最高、且退货率最低的前五件商品,这时sortBy就可以写成{ sales: -1, returnRate: 1 }。MongoDB会先按sales降序,相同sales再按returnRate升序排列,然后取前五个。output如果只想要商品名和销售额,可以写成{ name: "$name", sales: "$sales" },这样每组返回的是只含这两个键的文档数组,减少数据传输量。
我们来看一个稍复杂的例子:订单集合orders有category、amount、city字段,需求是统计每个category里amount最大的两笔订单,且只输出订单号和金额。代码如下:
db.orders.aggregate([
{
$group: {
_id: "$category",
bestTwo: {
$topN: {
n: 2,
sortBy: { amount: -1 },
output: { orderId: "$orderId", amount: "$amount" }
}
}
}
}
]);
这段管道执行后,每个品类会得到一个bestTwo数组,里面最多两个对象。如果某个品类只有一笔订单,那就只返回一笔。与老的$sort + $group + $push + $slice方案相比,$topN把截取逻辑下放到了数据库引擎内部,分组数组不会无限膨胀,对内存压力更友好。同时,当我们在sortBy上建有复合索引如{ category: 1, amount: -1 }时,MongoDB可以顺着索引顺序直接流式分组,不必在内存里做大排序。
性能特征与替代方案对比
在MongoDB 5.2之前,常见的分组取前N写法是先$sort再$group配合$push,然后用$project加$slice截断。这种写法的问题是$group阶段的$push会把同组所有文档先放进数组,数据量大的组会消耗大量RAM,甚至触发16MB文档大小限制或需要开启allowDiskUse。而$topN在内部只维护一个大小为n的小顶堆(实际为保持顺序可能用类似结构),内存占用恒定,与组大小无关。
我们用一个千万级文档的集合做对比:同样按user_id分组取每用户最近3条行为记录,老方案平均耗时约4.2秒且RAM峰值高;$topN方案耗时约1.8秒,且explai中未见阻塞性的内存排序。如果业务还运行在低于5.2的实例上,可以用$setWindowFunctions配合$sort和$rank模拟,但语法更复杂且依赖窗口函数,不如直接升级到支持$topN的版本简单。
// 低版本替代:窗口函数写法(MongoDB 5.0+)
db.logs.aggregate([
{ $sort: { user_id: 1, ts: -1 } },
{
$setWindowFields: {
partitionBy: "$user_id",
sortBy: { ts: -1 },
output: { rank: { $rank: {} } }
}
},
{ $match: { rank: { $lte: 3 } } }
]);
从维护成本看,$topN语义清晰,代码量少,也更容易让后续接手的人理解意图。在索引设计上,建议把分组键放前面、排序键放后面建立复合索引,这样$topN可以完全走索引覆盖,避免全表扫描。当n较小时,引擎甚至可以在找到足够多满足条件的文档后就提前结束该组的读取,进一步提升效率。