MongoDB系列写到第37篇。这次聊聚合排序,看起来是个不起眼的小功能,但我在团队里接过好几个因为排序不对来排查的case:上线的榜单数据乱序、翻页出现重复记录、聚合跑了几秒直接报内存超限。这些问题十有八九都出在对$sort的理解不透。这篇不打算抄文档,把聚合管道里排序的底层逻辑、高频玩法、组合姿势和排障思路一并过一遍,适合刚用 MongoDB 的朋友,也给写过一段时间聚合的开发者再查漏补缺。
1. 先把排序这个法器摸清楚:聚合管道里的 $sort 到底怎么工作
1.1 一条最简单的聚合排序长什么样
聚合管道里的排序,标准写法是$sort阶段,传一个文档,字段名对应要排序的字段,值是1(升序)或-1(降序)。比如:
db.orders.aggregate([ { $sort: { createdAt: -1 } } ])这段代码把订单按创建时间倒序输出。看起来和db.orders.find().sort({ createdAt: -1 })效果一样,但两者有本质区别:find().sort()是查询层的排序修饰,而聚合管道里的$sort是个独立的处理阶段,有明确的输入和输出,可以放在管道任意位置,也可以在一个管道里出现多次。
实际项目中,单独一个$sort的聚合管道意义不大,因为这种场景用find().sort()更简洁。$sort的真正价值在于和其他阶段组合:先$match过滤一部分数据再排序、先排序再$group取每组最新记录、排序后接$limit截断 Top N。这也是为什么很多从关系型数据库转过来的人会踩坑,因为他们习惯把排序当成最后一步,但在聚合管道里,排序的位置决定了后续阶段拿到的数据是什么状态,位置放错,结果就完全不对。
1.2 排序方向、多字段排序与组合逻辑
多字段排序的语法非常直观:
db.orders.aggregate([ { $sort: { status: 1, priority: -1, createdAt: -1 } } ])这段的意思是先按status升序排,status相同的记录再按priority降序排,status和priority都相同的情况下,最后按createdAt降序排。可以类比成人先按性别分桶,再在桶内按身高排序:字段出现的顺序就是优先级顺序,越靠前权重越高。
这里有个常被忽略的点:排序方向是每个字段独立指定的,同一个文档里可以混用1和-1。很多时候我看到新手写的代码是{ $sort: { status: 1, priority: 1 } },但实际上业务想要的是状态升序、紧急程度降序,这就是理解上的偏差。另一个容易踩的坑是字段缺失。MongoDB 在排序时会把缺失字段当作null处理,升序时排在最前面,降序时排在最后面。我见过一次线上事故,某个文档漏写了排序字段,结果那条记录在榜单置顶位置待了一整天,运营截图投诉的时候我们才反应过来。
数组字段的排序规则也值得一提:如果排序字段是数组,升序时使用数组里的最小值参与比较,降序时使用最大值。比如{ tags: ["a", "c"] }和{ tags: ["b"] }按tags升序排列,前者会排在前面,因为"a"比"b"小。这个行为文档里有写,但不是一眼能想到的。
1.3 复用索引:让 $sort 从内存排序变成顺序扫描
这是聚合排序性能最核心的一条。$sort并不是只能在内存里硬排,如果条件合适,MongoDB 可以直接走索引把数据按顺序取出来,压根不需要排序这一步。能走索引的前提是:排序发生在管道的靠前位置,且排序字段上有匹配的索引。
举个例子,建立一个复合索引:
db.orders.createIndex({ status: 1, createdAt: -1 })然后执行:
db.orders.aggregate([ { $match: { status: 1 } }, { $sort: { createdAt: -1 } } ])这时 MongoDB 发现$match用到了status,$sort用到了createdAt,而复合索引的顺序恰好和管道要求的顺序一致,于是直接按索引顺序扫描,连真实排序都不需要做。
但反过来,如果先$group、$project、$unwind,再$sort,索引基本就失效了,因为管道已经改变了文档结构,索引指向的原始字段顺序没法直接帮上忙。这种场景下 MongoDB 只能把数据拉到内存里做一次真实排序。
怎么确认是否走了索引?最简单的方式是看执行计划:
db.orders.explain("executionStats").aggregate([ { $match: { status: 1 } }, { $sort: { createdAt: -1 } } ])详细的执行计划里如果出现SORT字样,说明做了内存排序;如果只看到IXSCAN或FETCH,说明走了索引扫描。我在排查慢查询时,第一件事永远是看执行计划里有没有SORT,而不是先怀疑服务器配置。
2. 三个高频聚合排序场景深度拆解
2.1 排行榜:sort + limit 快速取 Top N
排行榜是聚合排序最常见的业务场景,比如游戏里取积分最高的前十个玩家:
db.user_scores.aggregate([ { $match: { gameId: "match-2025", season: 2025 } }, { $sort: { score: -1 } }, { $limit: 10 } ])这里的顺序是严格固定的:必须先$sort再$limit。有人会把$limit放在前面想减少排序量,这是错误的,因为先$limit意味着只取任意 10 条再排序,语义完全变了。
MongoDB 对$sort后面紧跟$limit的情况有一个专门优化:排序过程只保留前 N 条记录,而不需要排完整份数据。也就是说$sort + $limit本质上在做“Top-K”操作,内存开销远小于全量排序。这个优化在你把$limit提前的时候反而会失效,所以不要自作聪明调换位置。
如果要做排行榜分页,常见写法是$sort + $skip + $limit:
db.user_scores.aggregate([ { $sort: { score: -1 } }, { $skip: 20 }, { $limit: 10 } ])这种写法在数据量小的时候没问题,但$skip跳到较深位置时性能会明显变差,因为它要把前面的记录全部过一遍。我自己的经验是:榜单类需求尽量用“范围翻页”代替“页码翻页”,也就是记住上一页最后一名的score和_id,下一页直接查score小于该值的记录。这个思路在第 4 部分还会详细说。
2.2 字符串排序:中英文与大小写怎么排才符合直觉
字符串排序是聚合排序里最容易让人困惑的地方。MongoDB 默认的字符串排序是二进制排序,直接按 UTF-8 编码的字节序比较。这个规则下,大写字母排在所有小写字母前面,因为大写字母的码点更小。所以"Banana"会排在"apple"前面,这种结果显然不符合大多数人脑中的字典序直觉。
实际项目中如果需要按字母顺序忽略大小写地排序,就得在聚合选项里指定 collation(排序规则):
db.articles.aggregate( [ { $sort: { title: 1 } } ], { collation: { locale: "en", strength: 2 } } )strength: 2表示忽略大小写差异和重音差异。这里要注意 collation 不是$sort阶段内部的参数,而是整个聚合操作的选项,它会影响管道内所有字符串比较逻辑,不只是排序。
中文排序就更微妙了。默认二进制排序会把中文字符按 Unicode 码点排列,既不是拼音序也不是笔画序。想按拼音排序,可以尝试指定中文 collation:
db.articles.aggregate( [ { $sort: { title: 1 } } ], { collation: { locale: "zh" } } )MongoDB 依赖 ICU 库来实现语言排序规则,不同版本的 ICU 对中文排序的细节处理有差异,所以这种写法在大部分版本上能按拼音排,但我不敢拍胸脯保证每个版本都符合你的预期。如果是生产环境的关键排序需求,我更推荐在应用层维护一个冗余的拼音字段或拼音首字母字段,写入时计算好,排序时直接按这个字段排,结果完全可控,也免得被 collation 版本差异坑到。
2.3 数值排序:隐藏的类型落差与缺失字段行为
数值排序的坑往往不在数值本身,而在“这个字段存得到底是不是数值”。最经典的一个案例是价格排序把"10"排到了"2"前面。原因很简单:这两个值是字符串,按字典序比较时,"1"小于"2",所以"10"反而排在前面。MongoDB 对字符串数字和真正的数字一视同仁吗?不会,排序时字符串和数字属于不同 BSON 类型,比较规则里有明确的类型优先级,字符串排在任何数字类型后面。
如果发现线上数据里混了字符串和数字,要么修正数据源,要么在管道里先把类型统一:
db.products.aggregate([ { $project: { name: 1, priceNum: { $toInt: { $ifNull: ["$price", "0"] } } } }, { $sort: { priceNum: 1 } } ])$toInt会把字符串数字转成真正的整型,解决排序错乱的问题。但要注意,如果$price里存在无法转换的值,$toInt会直接报错,所以稳妥的方案是先$match过滤掉脏数据,或者用$convert并指定onError默认值。
另一个隐蔽问题出现在字段缺失时。集合里大部分文档都有排序字段,但个别文档没写这个字段,排序时缺失字段按null参与比较,升序在最前、降序在最后。这个行为必须记牢,否则很容易出现“明明按分数降序排,第一名却是个没有分数的人”这样的怪事。
3. 排序从来不是单打独斗:几个实用的组合打法
3.1 sort + group 取每组最新记录
业务场景很常见:要查每个用户的最近一笔订单、每篇文章的最新一条评论、每台设备的最后一次心跳时间。这类“分组取最新”的需求在 MongoDB 里标准解法是先排序后分组:
db.orders.aggregate([ { $sort: { userId: 1, createdAt: -1 } }, { $group: { _id: "$userId", latestOrder: { $first: "$$ROOT" } } } ])为什么必须先$sort再$group?因为$group处理文档时是按管道顺序逐个累积的。排序后每个用户的文档按时间倒序排列,$first拿到的就是组内第一条,也就是最新一条。如果顺序反过来,先$group再$sort,那就只能把组内所有文档都塞进$push数组,最后再想办法取数组最后一个元素,内存开销大不少,写法也更绕。
$$ROOT是这里的关键技巧,它代表整条原始文档。如果不加$$ROOT,$first只能拿某个字段,比如{ $first: "$createdAt" },那就丢掉其他字段了。用了$$ROOT,整个文档完整保留,后端直接就能返回,不用二次查询。
这里有个小提醒:$sort + $group如果数据量很大,排序本身会占内存,但不要为了省内存把排序字段换成别的,这个组合的正确性依赖排序结果,不能简配。如果单组数据量特别大,比如一个用户有十万条订单,可以考虑增加$match先按时间范围裁剪数据。
3.2 $sortByCount:一次搞定分组、排序、计数
$sortByCount是个浓缩了“分组 + 排序 + 计数”三个阶段的操作符,用来做词频统计、URL 排行、标签热榜这类需求非常顺手:
db.logs.aggregate([ { $sortByCount: "$requestUrl" } ])这段代码等价于先按requestUrl分组,统计每组数量,再把结果按数量降序排列。用普通阶段写就是这样:
db.logs.aggregate([ { $group: { _id: "$requestUrl", count: { $sum: 1 } } }, { $sort: { count: -1 } } ])用过 Elasticsearch 的读者可以把它理解为 MongoDB 版的 terms 聚合。做日志分析时我经常用它,比如统计一段时间内访问量最高的接口、错误日志里出现最多的异常类型,一行管道就出来了,比把数据拉回应用层再数要省太多事。
$sortByCount输出的文档结构是固定的{ _id: 分组值, count: 数量 },如果想继续取 Top 10,后面再接一个$limit就行:
db.logs.aggregate([ { $sortByCount: "$requestUrl" }, { $limit: 10 } ])需要注意$sortByCount的输出字段名就是_id和count,如果下游想改字段名,要用$project再处理一次。
3.3 unwind、facet 与 unionWith 场景下的排序
$unwind将数组字段展开成多条文档后,排序逻辑有一个常见用途:按数组内元素字段排序。比如商品有多种规格,每个规格有独立价格,想按最低规格价排序:
db.products.aggregate([ { $unwind: "$specs" }, { $sort: { "specs.price": 1 } }, { $group: { _id: "$_id", minPrice: { $first: "$specs.price" } } } ])这里的思路和第 3.1 节类似,展开数组后先按价格排序,再分组取第一条,就拿到了每个商品的最低价规格。
$facet是聚合管道里的并行处理工具,可以在同一个输入数据上跑多个子管道,每个子管道可以独立排序。但要注意,$facet内的排序只作用于各自的子管道,不会影响其他分面,如果最终结果需要全局排序,得在$facet之后把结果$unwind再$sort:
db.orders.aggregate([ { $facet: { byStatus: [ { $group: { _id: "$status", count: { $sum: 1 } } }, { $sort: { count: -1 } } ], byMonth: [ { $group: { _id: { $month: "$createdAt" }, amount: { $sum: "$amount" } } }, { $sort: { amount: -1 } } ] } }, { $unwind: "$byMonth" }, { $sort: { "byMonth.amount": -1 } } ])$unionWith可以把两个集合的数据合并到同一个管道里处理,排序时有个隐藏问题:两个集合里同一个排序字段的类型可能不一致,或者某个集合里根本没有这个字段。排序对缺失字段按null处理,混合类型按 BSON 类型优先级排列,结果可能不符合预期。合并前先检查两边字段口径是省心的做法。
4. 大结果集排序的硬性门槛与优化手段
4.1 100MB 内存限制与 allowDiskUse
聚合排序最著名的限制是内存。$sort阶段默认最多使用 100MB 内存,一旦排序的数据量超过这个阈值,MongoDB 会直接报错:
Exceeded memory limit for $sort, but didn't allow disk use. Set allowDiskUse to true.翻译过来就是“排序内存超了,而且你没允许我用磁盘”。解决办法很直接,在聚合选项里打开磁盘使用:
db.collection.aggregate(pipeline, { allowDiskUse: true })这里必须说清楚的是,打开allowDiskUse并不是无代价的。MongoDB 会把中间结果写入临时文件,做外部排序,性能可能比内存排序慢一个数量级,磁盘 IO 压力也会明显上升。我遇到过同事开了allowDiskUse之后聚合任务跑了十几秒,而加了合适的索引之后同样的查询几十毫秒就返回了,高下立判。
所以正确思路不是“超限了就开磁盘”,而是先问自己三个问题:数据能不能先在$match里过滤掉一部分?排序能不能走索引?管道里有没有不必要的字段在拖大数据量?这三个问题解决了,大多数排序根本碰不到 100MB 的上限。需要补充的是,不同 MongoDB 版本对排序内存的处理细节有差异,比如 5.0 之后调整了外部排序的块大小,但“默认有内存门槛、超限需要显式允许磁盘”这个基本逻辑没变。
4.2 稳定排序与翻页重复数据坑
MongoDB 的排序并不保证稳定性。意思是,如果排序键相同的文档有多条,它们的相对顺序在多次查询里可能不一致。这个特性平时感知不强,但一做分页就露馅了。
举个例子,积分排行榜里有几千个人的分数相同,你用{ $sort: { score: -1 } }加$skip和$limit做分页。第一页返回了第 1 到第 10 名,第二页查询时,由于同分记录的顺序不稳定,第一页里某条记录可能又出现在第二页开头,用户就会看到同一个人重复上榜。
解决办法是给排序键增加一个唯一字段作为“决胜因子”,最省事的就是_id:
db.user_scores.aggregate([ { $sort: { score: -1, _id: 1 } } ])加上_id之后,同分的记录也有一个确定的顺序,翻页就不会出现重复或漏数据。这个规则我建议直接写进团队规范:聚合排序涉及分页时,排序键最后必有_id。
另外,深分页的$skip性能问题在这里再强调一次。跳过 10000 条意味着 MongoDB 要扫描并丢掉前 10000 条数据才能到目标位置,数据量越大越慢。榜单场景更推荐基于排序键的范围翻页,客户端传过来上一页的最后一个(score, _id),下一页用$match过滤,性能和稳定性都更好。
4.3 驱动侧构建排序规则:C#/Java 快速参考
实际开发里,聚合管道通常不是手写 JSON,而是用驱动构建。C# 里可以用SortDefinitionBuilder链式拼接多个排序字段:
var sort = Builders<BsonDocument>.Sort .Descending("score") .Ascending("_id"); var pipeline = new BsonDocument[] { new BsonDocument("$match", new BsonDocument("season", 2025)), new BsonDocument("$sort", sort.ToBsonDocument()), new BsonDocument("$limit", 10) }; var options = new AggregateOptions { AllowDiskUse = true }; var result = collection.Aggregate<BsonDocument>(pipeline, options).ToList();Java 驱动里用Sorts工具类:
import com.mongodb.client.model.Sorts; import org.bson.Document; List<Bson> pipeline = List.of( new Document("$match", new Document("season", 2025)), new Document("$sort", Sorts.orderBy(Sorts.descending("score"), Sorts.ascending("_id"))), new Document("$limit", 10) ); collection.aggregate(pipeline).into(new ArrayList<>());驱动侧排序容易遇到一个隐蔽问题:如果通过 LINQ 或 Queryable 接口写排序,驱动生成的$sort阶段可能不是你预期的位置。排查这种问题最直接的办法是在阶段性调试时打印管道定义,C# 里可以用pipeline.ToString(),Java 里直接打印pipeline列表,能看到最终发给 MongoDB 的完整结构。我在实际项目里就用这个方法发现过驱动把排序挪到了分组后面的情况,打印出来一看就明白了。
5. 聚合排序常见问题与排查实录
5.1 问题速查表
平时答疑遇到最多的问题,整理成一张表方便快速定位:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
排序结果里"10"排在"2"前面 | 字段是字符串类型,按字典序比较 | 用$toInt/$convert转成数值再排序 |
| 升序时没有某字段的文档排在最前面 | 缺失字段按null参与排序 | 排序前补默认值,或$match过滤掉缺失文档 |
| 字符串排序结果不符合字典序 | 默认二进制排序区分大小写 | 聚合选项指定 collation,如locale: "en", strength: 2 |
| 聚合排序报内存超限错误 | $sort数据量超过 100MB 内存阈值 | 开allowDiskUse,更优解是加索引或先$match裁剪数据 |
排序慢但find().sort()很快 | 聚合管道里排序走了内存而不是索引 | 用explain查看是否出现SORT,调整管道顺序或加索引 |
| 分页出现重复或漏数据 | 排序键相同导致顺序不稳定 | 排序键末尾加_id保证唯一顺序 |
$sortByCount结果想自定义字段名 | 输出固定为_id和count | 后面接$project重命名 |
| 数组字段排序顺序看不懂 | 数组按最小/最大元素参与比较 | 先用$unwind展开数组再排序 |
5.2 三个真实排查案例复盘
第一个案例是商品价格排序错乱。运营反馈后台商品列表价格忽高忽低,我看了一下排序条件,按price升序,结果第一名是 9999 元,第二名是 3 元。查了数据发现price字段既有数值型又有字符串型,历史导入时部分商品的价格存成了字符串。定位过程很快:用db.products.find({ price: { $type: "string" } })统计出脏数据,然后在管道里统一用$toInt转换,排序恢复正常。
第二个案例是排行榜聚合很慢。需求是统计赛季积分 Top 100,数据量 200 万条,管道结构是$match过滤赛季、$group按用户聚合积分、$sort排序、$limit100。问题出在$sort在$group之后,无法走索引,只能把 200 万条聚合结果全部拉到内存里排。优化思路是把管道拆一下:先用$match过滤赛季字段并走索引,让参与聚合的数据量尽量小,同时确认$match的字段上有索引。改造后同样的查询从几秒降到了几百毫秒。
第三个案例是翻页榜单出现重复。这个问题在第 4.2 节已经解释过根因,当时实际排查中发现同一个用户在第一页和第二页都出现了。修复就是在排序键上加_id:
{ $sort: { score: -1, _id: 1 } }同时把前端的翻页方式从$skip改成范围翻页,彻底解决了问题。
5.3 关于聚合排序,我最想叮嘱的几件事
聚合排序写多了,我养成了一个条件反射:只要管道里出现$sort,先问自己能不能走索引。能走索引就尽量把$sort安排在管道靠前的位置,让它跟在$match后面;不能走索引就评估数据量,别等触发 100MB 报错才想办法。
第二个经验是不要为了用聚合而用聚合。单表排序需求,直接用find().sort()最省事,聚合管道真正适合的是多阶段组合,比如排序配合分组、统计、展开数组。把简单问题复杂化,只会给自己埋坑。
第三个经验是如果排序规则比较复杂,比如多维度算出的热度值,与其每次在管道里现算排序键,不如提前在写入时算好一个冗余字段。比如点赞数、评论数、时间衰减因子组合出来的热力值,写入时更新,排序时直接按这个字段排,既快又稳,还容易走索引。这个思路在榜单、推荐、排行榜类场景里非常实用,算是我的保留招数。
最后提一句 collation。它确实能解决大小写和中英文排序的不少痛点,但它是整个聚合操作的全局配置,会影响管道里所有字符串比较逻辑,上线前记得确认影响范围。我自己在项目里用locale: "zh"按拼音排序时踩过 ICU 版本差异的坑,后来改成冗余拼音字段才彻底安心。