在处理分组数据时,经常需要从每个分组中提取排名靠前的若干条记录,例如电商平台按品类统计销量Top 5的商品、监控系统按设备ID获取最近N次异常日志的峰值。早期MongoDB聚合框架要实现这类需求,只能在$group阶段先用$push将排序后的数据全部收集成数组,再通过$slice或$filter截取前N个元素。这种写法不但逻辑绕,而且所有文档的排序字段都要加载到内存数组里,一旦单组数据量过大,很容易触发16 MB的文档大小限制或拖垮管道性能。MongoDB 5.2引入的累加器$maxN直击痛点,只需在一个简单的$group中就能完成“分组取前N大”的操作,让聚合语句回归简洁高效。

一、$maxN 的语法与基本行为
$maxN只能在$group阶段作为累加器使用,它的基本结构是:
{ $group: {
_id: "$category",
topScores: { $maxN: { input: "$score", n: 3 } }
} }
其中input指定要比较的字段,n是一个正整数,表示要返回的最大值的数量。$maxN会比较组内所有文档的input值,然后将最大的n个值以数组形式放入topScores字段。需要特别指出的是,n的值可以小于等于组内文档总数,如果组内文档数本身不足n个,则返回的数组里只包含所有文档的对应值,不会补null或缺失位。
与$max只返回单一最大值不同,$maxN返回的是一个有序数组,默认按照input值的降序排列。也就是说,数组索引0的位置是最大值,索引1是次大值,依此类推。如果组内多个文档的input值相等,它们都会被保留在结果数组中,直至达到n的数量限制。当出现并列情况时,排在数组前面的具体是哪个文档的值,取决于文档进入聚合管道的顺序,这一点与$push结合$sort时的稳定排序表现一致。
除了直接对数值字段取最大N个值,input也支持嵌套字段和表达式。例如想按照文档中某个内嵌对象的属性排序,可以写成input: "$details.score"。更灵活的是,input还可以接收一个表达式来动态计算比较值,比如{ $maxN: { input: { $multiply: ["$price", "$quantity"] }, n: 5 } },这样就能按总价取出前五名。不过需要注意,input表达式的结果必须可比较,否则MongoDB会抛出类型错误。
二、$maxN 与传统“push+sort+slice”方案的对比
在$maxN出现之前,实现“分组取Top N”的经典思路是三个阶段连用:
[
{ $sort: { category: 1, score: -1 } },
{ $group: {
_id: "$category",
items: { $push: "$$ROOT" }
} },
{ $project: {
topN: { $slice: ["$items", 3] }
} }
]
这段管道先按分组字段正序、排序字段倒序进行全局排序,然后在$group阶段用$push把整个文档压入数组,最后用$slice截取前3个。它的缺点一目了然:全局排序需要扫描整个集合,即使只关心每组的前几名,也必须让所有文档参与排序;$push将整个文档推入内存数组,如果某个分组的文档数量巨大,可能会瞬间撑爆当前管道的文档大小上限。此外,$push不保证排序稳定性,虽然此前已经用$sort排序,但一旦进入$group,文档到达的顺序仍可能存在细微的不确定性,导致$slice结果在重复执行时偶发不一致。
而$maxN将排序、截取逻辑全部封装在一个累加器中,管道只需要:
{ $group: {
_id: "$category",
topScores: { $maxN: { input: "$score", n: 3 } }
} }
这种写法省去了前置的$sort阶段和中间的$push开销,$maxN内部会维护一个大小为n的列表,无需将组内所有文档先放入内存,内存复杂度从O(k)降为O(n),k是整个分组的文档数。当分组基数很大而n很小时,性能提升会非常明显。在行为层面,$maxN直接返回的是值的数组,而不是完整文档的数组。如果需要保留整个文档,可以将input设为$$ROOT或者通过$addFields预先构造组合字段,但要注意这样会增加结果数组的内存占用。
另一个细微差异是处理null值的方式。$maxN会将null视为一个可比的值,在降序排列中,null会排在所有非null值之后(低于任何实际值)。而传统$push + $sort方案中,如果排序字段存在null,null一般默认排在前面还是后面取决于MongoDB的排序规则,可能会导致截取Top N时意外引入null记录。$maxN的行为更符合“取最大值”的直觉,null被当作最小值处理,因此基本不会干扰真正有价值的高数值。
三、$maxN 的性能考量与最佳实践
虽然$maxN显著降低了内存压力,但它并不意味着可以完全忽略索引。MongoDB在执行$group阶段时,如果无法利用索引提供的天然顺序,仍然需要将所有符合条件的文档加载到内存进行分组累加。为了让$maxN真正发挥威力,建议在分组字段上建立索引,而且如果管道前还有$match过滤,应确保过滤条件也能被索引覆盖。例如在按category分组的场景,可以创建索引{ category: 1, score: -1 },这样$group就能借助索引扫描顺序高效推进,避免以SORT_MERGE等方式产生较大的临时存储。
在结果集大小可控的前提下,$maxN还可以与$unwind组合,将数组拆分成多条文档,方便后续与应用层数据结构对齐。比如输出每个品类的前三名商品详情:
db.products.aggregate([
{ $group: {
_id: "$category",
topProducts: {
$maxN: {
input: { score: "$score", name: "$name", id: "$_id" },
n: 3
}
}
} },
{ $unwind: "$topProducts" },
{ $replaceRoot: { newRoot: "$topProducts" } }
])
这段代码将input设置为一个文档,里面包含了排序字段score和其他需要保留的字段。$maxN会根据input文档中的所有字段进行比较吗?实际上,当input是一个文档时,MongoDB会先按照文档的第一个字段排序,如果第一个字段相等,再比较第二个字段,以此类推。因此需要把排序依据的score放在文档的第一个位置,确保比较行为符合预期。$unwind后再用$replaceRoot把嵌套文档提升为顶层文档,就能得到可直接消费的排行榜列表。
在监控和告警系统中,$maxN还能与$bucket、$facet等操作配合,实现多维度Top N分析。例如按时间窗口统计每小时内响应时间最慢的5次请求,可以将时间戳转换为小时桶,再对桶内responseTime使用$maxN。为了进一步提升聚合性能,建议将$maxN放在管道较后的位置,先通过$match、$project等缩减数据量,减少累加器需要处理的文档数目。同时要注意,$maxN在单个$group语句中可以使用多次,但每次都会独立维护自己的内部列表,不宜在一个分组里滥用大量$maxN,否则会额外增加CPU和内存消耗。合理规划管道结构,适时使用$unset丢弃不再需要的中间字段,能让$maxN在复杂业务中稳定发挥最大效能。