MongoDB 聚合管道里的 top 累加器解决的是分组后取每组前 N 条记录的问题。它通常写在 group 阶段,能够在分组收集文档时按照指定排序条件保留靠前的若干文档,而不是等分组完成后再用 push 收集全部元素、再用 slice 截断。这样既能减少内存占用,也能避免在后续阶段进行重复排序。本文会从语法参数、实际场景、操作符对比和性能调优几个角度展开说明。

一、$top 的语法与参数拆解
$top 是 $group 阶段的累加器表达式,作用是返回分组内按指定排序条件排在前面的 N 个元素。它接收一个文档参数,这个参数通常包含三个字段:sortBy、output 和 n。sortBy 用来定义组内排序规则,可以针对一个字段排序,也可以同时传入多个字段;排序值为 1 表示升序,-1 表示降序。output 指定返回数组中每个元素包含哪些字段,既可以直接裁剪字段,也可以放入表达式生成新的计算字段。n 必须是正整数,表示最多保留多少个元素。
从语法上看,$top 只能出现在 $group 阶段,不能直接放到 $project 或 $match 阶段。如果分组中有 100 条文档,而 n 设置为 3,那么 $top 只会保留排序后最靠前的 3 条,其余 97 条不会进入结果数组。这比先使用 $push 收集全部文档再在后续阶段做 $slice 要轻量很多。
下面是一个基础语法示例,表示按照 score 降序取每个部门的前 3 名员工。
{
$group: {
_id: "$department",
topEmployees: {
$top: {
sortBy: { score: -1 },
output: { name: 1, score: 1, title: 1 },
n: 3
}
}
}
}
这里 output 只返回 name、score 和 title 三个字段,避免把整个原始文档塞进结果数组。如果不写 output,$top 通常会返回整个原始文档,字段更多时会增加内存和网络开销,所以建议在实际项目中明确指定输出字段。
二、用 $top 实现部门绩效排行榜
假设有一个员工集合,每条文档包含姓名、部门、绩效分数和入职日期。现在需要按部门分组,找出每个部门绩效最高的 3 名员工。先构造测试数据。
db.employees.insertMany([
{ name: "陈锋", department: "研发", score: 96, joinDate: ISODate("2023-03-15") },
{ name: "李敏", department: "研发", score: 92, joinDate: ISODate("2024-01-09") },
{ name: "王震", department: "研发", score: 88, joinDate: ISODate("2022-11-02") },
{ name: "赵青", department: "研发", score: 91, joinDate: ISODate("2023-07-21") },
{ name: "周雨", department: "市场", score: 85, joinDate: ISODate("2024-02-14") },
{ name: "吴凡", department: "市场", score: 90, joinDate: ISODate("2023-05-30") },
{ name: "郑爽", department: "市场", score: 79, joinDate: ISODate("2024-03-22") }
])
接下来在 $group 阶段使用 $top,按 department 分组,并在组内按 score 降序取前 3 条。最后再用 $sort 对部门名称做一次输出排序,结果看起来会更稳定。
db.employees.aggregate([
{
$group: {
_id: "$department",
top3: {
$top: {
sortBy: { score: -1 },
output: { name: 1, score: 1, joinDate: 1 },
n: 3
}
}
}
},
{ $sort: { _id: 1 } }
])
运行后可以得到类似下面的结果。每个部门的 top3 都是数组,数组内部已经按照绩效分数从高到低排列。
[
{ "_id": "研发", "top3": [
{ "name": "陈锋", "score": 96, "joinDate": "2023-03-15T00:00:00Z" },
{ "name": "李敏", "score": 92, "joinDate": "2024-01-09T00:00:00Z" },
{ "name": "赵青", "score": 91, "joinDate": "2023-07-21T00:00:00Z" }
]},
{ "_id": "市场", "top3": [
{ "name": "吴凡", "score": 90, "joinDate": "2023-05-30T00:00:00Z" },
{ "name": "周雨", "score": 85, "joinDate": "2024-02-14T00:00:00Z" },
{ "name": "郑爽", "score": 79, "joinDate": "2024-03-22T00:00:00Z" }
]}
]
这种写法适合报表、排行榜等需求。因为排序和截取都发生在 $group 内部,不需要在管道中额外添加 $unwind 和 $sort,管道阶段更少,中间数据也更容易控制。
三、$top 与 $firstN、$lastN 的差异
很多聚合场景里还会看到 $firstN 和 $lastN,它们与 $top 的功能有相似之处,但侧重点不同。$firstN 直接返回分组内前 N 个元素,不会先做排序;$lastN 返回分组内最后 N 个元素,同样不做排序。换句话说,$firstN 和 $lastN 依赖的是文档进入分组时的自然顺序,而 $top 则先根据 sortBy 排序,再截取前 N 条。
可以把 $top 理解成“先排序再取前 N 条”,把 $bottom 理解成“先排序再取后 N 条”。如果你希望按照绩效分数降序取前三名,就要用 $top;如果只是想取每个分组中最早插入的 3 条记录,那么 $firstN 更直接,但前提是插入顺序确实符合你的业务含义。
| 操作符 | 行为 | 典型用途 |
|---|---|---|
$firstN | 取分组内前 N 个元素,不排序 | 取最早进入分组的 N 条记录 |
$lastN | 取分组内最后 N 个元素,不排序 | 取最近进入分组的 N 条记录 |
$top | 按 sortBy 排序后取前 N 个元素 | 取分组内排名靠前的 N 条记录 |
$bottom | 按 sortBy 排序后取后 N 个元素 | 取分组内垫底的 N 条记录 |
$top 返回的结果始终是数组,即使 n 设置为 1 也不例外。如果你只想要一个单值或单条文档,可以在后续使用 $arrayElemAt 取数组第一个元素。比如只取每个部门绩效最高的一名员工,可以像下面这样处理。
db.employees.aggregate([
{
$group: {
_id: "$department",
top1: {
$top: {
sortBy: { score: -1 },
output: { name: 1, score: 1 },
n: 1
}
}
}
},
{
$project: {
_id: 1,
bestEmployee: { $arrayElemAt: ["$top1", 0] }
}
}
])
这样 bestEmployee 会是一个对象,而不是包含单个对象的数组。如果后续还要对这些最佳员工做排序,也可以再展开处理。关键是要记住 $top 的输出层是数组结构,字段形状会影响后面阶段的写法。
四、多字段排序与表达式输出
实际业务中,仅按一个字段排序往往不够。比如绩效分数相同的员工,可能希望再按照入职日期升序排列,让资历更深的人排在前面。这时可以在 sortBy 中放入多个字段,MongoDB 会按照字段先后顺序依次比较。
下面的示例在研发部门内部先按 score 降序,分数相同再按 joinDate 升序,并且取前 2 名。返回时还通过 output 添加了一个计算字段 bonus,它由 score 乘以 100 得到。
db.employees.aggregate([
{
$match: { department: "研发" }
},
{
$group: {
_id: "$department",
top2: {
$top: {
sortBy: { score: -1, joinDate: 1 },
output: {
name: 1,
score: 1,
joinDate: 1,
bonus: { $multiply: ["$score", 100] }
},
n: 2
}
}
}
}
])
output 的灵活性让 $top 不仅能返回已有字段,还能承担一部分投影和计算职责。但需要注意,表达式计算会在分组内部对每个候选文档执行,如果计算很复杂,可能会拉长聚合时间。通常建议先用 $match 缩小范围,再让 $top 处理更小的数据集。
五、内存与性能注意事项
$top 在 $group 阶段执行时,所有需要保留的候选元素都会先进入内存。MongoDB 对 $group 阶段默认有 100MB 内存限制,一旦超出就会报错。这在分组基数很大、每个分组内文档很多时尤其明显。解决方式之一是在聚合命令中开启 allowDiskUse,让聚合阶段在必要时写入磁盘,避免任务直接失败。
db.employees.aggregate(
[
{
$group: {
_id: "$department",
top10: {
$top: {
sortBy: { score: -1 },
output: { name: 1, score: 1 },
n: 10
}
}
}
}
],
{ allowDiskUse: true }
)
开启磁盘支持后,大盘子上的分组任务更容易完成,但速度会有所下降,因为数据需要落在磁盘上做额外排序。另一个优化方向是尽量在进入 $group 前使用 $match 过滤无关文档。如果在过滤条件对应的字段上建有索引,比如 department 和 score,那么初步筛选效率会更高。虽然 $group 内部的排序不一定直接复用普通索引,但前置过滤缩小数据量后,后续排序和保留操作的压力会明显减小。
在分片集群场景下,$group 通常会在各个分片先做局部聚合,再由 mongos 或主分片合并。如果分组键与分片键不一致,数据会被大量搬迁,网络开销很高。遇到这种情况,可以评估是否能调整分片键,或者把分析任务放到离线只读节点上执行,减少对线上业务的影响。总之,$top 本身很轻量,但它依赖的 $group 阶段是聚合任务中最消耗资源的部分,合理控制分组规模和数据范围比单纯调大内存更重要。