MongoDB的聚合管道功能强大,分组统计是最常见的操作之一。在分组之后取每组的前N条或后N条记录,是排行榜、垫底分析等场景下的典型需求。早期版本要实现这种效果,往往需要借助$push把整个分组的数据推入数组,再用$arraySlice截取,或者通过复杂的排序加计数方式变通实现。从MongoDB 5.2开始,官方引入了$bottomN操作符,让取分组底部N个文档变得非常直接。本文将系统讲解它的用法。

$bottomN的基本语法与参数说明
$bottomN只能用在$group阶段内部,它的作用是按照指定的排序规则,返回每个分组中排在最后面的N个文档,并将这些文档以数组形式输出。它的完整语法结构如下:
{
$group: {
_id: "$category",
lowestDocs: {
$bottomN: {
input: "$score",
n: 3,
sortBy: { score: 1 }
}
}
}
}参数含义需要逐个理解。input指定要收集的字段,可以是任意表达式,通常直接引用文档中的某个字段。n表示要取的文档数量,必须是一个正整数的表达式,可以传常量,也可以引用字段动态计算。sortBy定义排序规则,字段值为1表示升序,-1表示降序,$bottomN返回的就是按这个规则排序后位于末尾的N个元素。
需要特别注意的是,sortBy是$bottomN内部必须提供的参数,不能省略。如果省略,MongoDB会直接抛出错误。另外,如果分组内的文档数量少于N,$bottomN会返回该分组内全部文档,不会报错,这一点比很多编程语言中的切片操作更宽容。
实战案例:取每个班级成绩最低的学生
假设有一个学生成绩集合students,文档结构包含学生姓名、班级和分数。现在的需求是找出每个班级分数最低的3名学生。传统做法需要先排序再分组后手动截取数组,而使用$bottomN只需要一条聚合语句。先准备测试数据:
db.students.insertMany([
{ name: "张三", className: "一班", score: 88 },
{ name: "李四", className: "一班", score: 45 },
{ name: "王五", className: "一班", score: 62 },
{ name: "赵六", className: "一班", score: 30 },
{ name: "孙七", className: "二班", score: 55 },
{ name: "周八", className: "二班", score: 71 },
{ name: "吴九", className: "二班", score: 39 }
])接下来执行聚合查询:
db.students.aggregate([
{
$group: {
_id: "$className",
bottomStudents: {
$bottomN: {
input: "$$ROOT",
n: 3,
sortBy: { score: 1 }
}
}
}
}
])这里有一个实用技巧:input使用$$ROOT可以获取完整文档,而不是只取单个字段。排序按score升序,分数最低的排在前面,而$bottomN取的是底部,也就是分数最高的3条。如果需求确实是取分数最低的,应该把sortBy改为{ score: -1 },这样底部就是分数最低的文档。这一点初学者经常搞混,务必记住$bottomN取的是排序序列的末尾,$topN取的是序列的开头。
查询结果中,bottomStudents是一个数组,数组内元素的顺序与sortBy规则保持一致。如果后续还需要对结果做二次处理,可以在管道后面追加$unwind、$project等阶段,把数组展开成普通文档继续加工。
$bottomN与相关操作符的对比
理解$bottomN离不开和它的几个兄弟操作符做对比。$bottom是$bottomN的简化版,只取排序末尾的第一个文档,等价于n等于1的$bottomN,但它返回的是单个文档而不是数组。$top和$topN则取排序开头的数据,方向相反。选择哪个操作符,取决于业务需要头部还是尾部,以及需要一条还是多条。
和传统方案对比更能体现优势。在$bottomN出现之前,常见写法是在$group中用$push收集全部文档,再用$project配合$sortArray和$slice截取。这种写法有两个明显问题:一是$push会把分组内所有文档都放进内存,数据量大时容易触碰100MB的聚合内存限制;二是语义不清晰,代码可读性差。$bottomN在引擎层面做了优化,只需要维护N个元素的堆结构,内存占用与N相关而不是与分组总数据量相关,性能优势明显。
此外还有一个运算符$firstN和$lastN容易混淆。它们依赖$group阶段外部的文档顺序,即管道进入$group前的顺序,而$bottomN通过内部的sortBy自行排序,不受外部顺序影响。如果聚合管道前面已经排好序且只需按该顺序取前N条,用$firstN或$lastN即可;如果排序规则独立于管道顺序,$bottomN更合适。
使用中的常见问题与性能建议
使用$bottomN时常见的报错有几种情况。第一种是n传入了负数或者0,MongoDB会报错提示n必须是正整数。第二种是n超过了限制,$bottomN的N上限为1e10,一般业务场景不会触碰到,但要注意n如果通过表达式动态计算,务必保证结果为正整数。第三种是忘了写sortBy,这个前面已经强调过。
性能方面有几点建议。首先,如果查询带有过滤条件,尽量在$group之前使用$match提前过滤,减少参与分组的数据量,这能显著降低排序开销。其次,若sortBy的字段能够建立索引,配合合理的管道顺序,可以让聚合更快完成。不过要注意,$group阶段内部的排序通常无法直接利用索引,索引优化更多作用于前面的$match和$sort阶段。最后,N的值不要盲目设置过大,虽然底层是堆实现,但N越大内存占用和比较开销也越大,按实际业务需要取值即可。
还有一个细节值得留意:当sortBy的字段在部分文档中不存在时,这些文档会被视为缺失值参与排序,在升序规则下缺失值排在最前面,相应地会被$topN优先取到,而$bottomN更倾向于取到有值的文档。如果业务上不希望缺失字段干扰结果,应在管道前用$match或$exists过滤掉这类文档,保证统计结果的准确性。