在MongoDB聚合管道中,数组排序一直是个常见需求。比如学生成绩列表、商品标签、事件日志等,都需要把数组内部元素按某个顺序整理好。MongoDB 5.2版本之后新增的$sortArray操作符,可以直接在$project或$set阶段对数组进行排序,而不需要像早期版本那样先展开数组再合并。

$sortArray 的基本语法与参数
$sortArray的基本语法非常直观。它接收一个包含两个字段的文档:input表示要排序的数组表达式,sortBy表示排序规则。排序规则可以是一个数字,也可以是一个文档。当sortBy为1时表示升序排序,为-1时表示降序排序。如果数组中的元素本身是文档,还可以通过sortBy写成一个文档,指定按照哪个字段进行排序,以及每个字段的排序方向。
下面先看一个最简单的数字数组排序示例。假设集合students中有一份文档,字段scores的值为[3, 1, 2]。我们可以在聚合管道的$project阶段使用$sortArray生成一个新字段sortedScores,不会改动原始文档。
db.students.aggregate([
{
$project: {
sortedScores: {
$sortArray: {
input: '$scores',
sortBy: 1
}
}
}
}
])
上面的管道输出结果是sortedScores: [1, 2, 3]。如果希望得到降序结果,只需要把sortBy改为-1即可。这个操作符的核心优势是它只在一个管道阶段内完成排序,返回的是一个新数组,原始字段保持不变。
对于元素为文档的数组,$sortArray也支持按字段排序。例如logs字段保存的是事件对象数组,每个对象包含timestamp和level字段。如果想先按时间倒序,再按级别升序,可以这样写。
db.apps.aggregate([
{
$project: {
orderedLogs: {
$sortArray: {
input: '$logs',
sortBy: { timestamp: -1, level: 1 }
}
}
}
}
])
这里的sortBy文档中,timestamp设置-1表示新的日志排在前面,level设置1表示同一时间戳时错误级别从小到大排列。这种写法比在$sort阶段处理展开后的文档要简洁很多。
在$project和$set阶段中的实际应用
$sortArray最常出现在$project阶段,用来输出排序后的新字段,但它同样可以在$set阶段使用。两者的区别在于,$project会重新定义输出文档的字段集合,而$set是在保留原字段的基础上新增或覆盖指定字段。如果只想增加一个排好序的字段而不想丢失其他字段,使用$set会更加合适。
假设有一份商品文档,其中tags是一个权重数值数组,业务上需要把权重从高到低展示在前端。我们可以这样使用$set阶段。
db.products.aggregate([
{
$set: {
sortedTags: {
$sortArray: {
input: '$tags',
sortBy: -1
}
}
}
}
])
输出文档会保留原来的tags字段,同时增加sortedTags字段。这种方式不会改变已有数据结构,对下游应用更加友好。相比之下,如果使用$project,则需要手动列出所有需要保留的字段,维护成本更高。
另一个典型场景是排行榜数据。比如每个用户文档中有一个roundScores数组,记录每局比赛的得分。直接用$sortArray按降序排序后,可以立刻得到最佳成绩列表。如果数组元素是文档,还可以结合文档字段进行排序。例如每局数据同时包含score和playedAt,想先按分数降序,再按时间升序,只需要把sortBy写成{ score: -1, playedAt: 1 }。整个管道阶段仍然只有一个$set,不需要额外的展开和重组逻辑。
处理空数组、null和非数组值
使用$sortArray时需要特别注意输入值的类型。如果input是空数组[],$sortArray会直接返回空数组,不会报错。这一点在数据清洗中很实用,因为很多文档可能本来就缺少某些数组类型的字段,用空数组占位时排序逻辑可以正常工作。
但如果input是null、缺失字段或者一个非数组值,例如字符串、数字、对象,$sortArray会直接抛出错误,导致整个聚合查询失败。为了避免这种情况,可以在调用前使用$isArray结合$cond进行兜底处理。
db.students.aggregate([
{
$set: {
safeSortedScores: {
$cond: [
{ $isArray: '$scores' },
{ $sortArray: { input: '$scores', sortBy: 1 } },
[]
]
}
}
}
])
上面这段代码会先判断scores字段是不是数组。如果是数组,就返回排序后的结果;如果不是数组,就返回一个空数组。这样即使集合中存在部分文档的scores字段为null或缺失,整个聚合管道也能正常执行。
此外,还要注意数组元素本身的类型。如果数组里既有数字又有字符串,排序结果会遵循MongoDB的BSON类型比较顺序,而不是简单的数值或字典序比较。这个行为在下一节会详细说明。对于业务数据来说,最好在写入阶段就保证数组元素类型一致,否则排序结果可能不符合预期。
排序规则与数据类型比较
MongoDB在比较不同BSON类型的值时有一套固定的顺序。数字类型排在字符串前面,字符串排在对象前面,日期类型也有自己的位置。当数组包含混合类型元素时,$sortArray会按照这个类型顺序先分类型再比较值。例如数组[3, 'apple', 1, 'banana']在升序排序时,数字会先出现,字符串会排在后面。
如果数组中的元素是文档,并且sortBy指定了某个字段,那么该字段的类型会比较一致。但如果某些文档缺少这个字段,MongoDB会用一个特殊类型MinKey或Missing来处理缺失值,在升序时缺失字段通常排在前面。这可能导致排序结果和预期不一致,因此在写入数据时建议为排序字段设置默认值,或者在聚合前使用$ifNull填充默认值。
还需要了解的是,sortBy使用文档形式时,字段的顺序是有意义的。MongoDB会按照文档中字段的书写顺序依次比较,先比较第一个字段,如果相同再比较第二个字段。比如{ score: -1, name: 1 }和{ name: 1, score: -1 }虽然包含相同的字段,但优先级完全不同。前者先按分数降序,分数相同再按名字升序;后者先按名字升序,名字相同再按分数降序。因此写排序规则时一定要把最关心的字段放在最前面。
与$unwind加$sort加$group方案的对比
在$sortArray出现之前,如果要对数组排序,常用的做法是先用$unwind将数组拆分成多个文档,再用$sort对单字段进行排序,最后用$group按原文档_id重新把元素组合成数组。这套流程虽然能解决问题,但管道阶段较多,而且$unwind会导致中间文档数量成倍增长,数据量大时性能开销明显。
下面是一个传统做法的示例。假设每个学生文档有scores数组,需要按升序输出排序后的数组。传统管道需要三个阶段。
db.students.aggregate([
{ $unwind: '$scores' },
{ $sort: { scores: 1 } },
{
$group: {
_id: '$_id',
sortedScores: { $push: '$scores' }
}
}
])
这段代码首先拆开数组,每个元素变成一条独立文档;然后按scores字段升序排序;最后按原来的_id分组,重新把成绩推入数组。这样做不仅阶段更多,还需要小心保留原文档的其他字段,否则$group阶段会丢失它们。而使用$sortArray只需要一个$set或$project阶段,代码更短,执行计划也更简单。
当然,$sortArray也有自己的限制。它只在MongoDB 5.2及更高版本中可用,如果还在使用更早的版本,就无法直接使用这个操作符。另外,$sortArray处理的是单个文档内的数组排序,不会改变文档之间的顺序。如果业务上需要在全局范围对多个文档的数组元素统一排序,还是需要结合$unwind和$sort来实现。但对于绝大多数针对文档内数组的排序需求,$sortArray是更简洁、更高效的选择。

