排序这个动作在MongoDB里看着简单写起来也不过是一行.sort()真正吃透却要牵扯索引、内存、聚合管道的执行顺序甚至还有中文按什么规则排序这类细节。这是MongoDB系列笔记的第24篇正好轮到查询结果排序这个主题我来把sort()从语法到原理到实战里的坑位一次性讲清楚。适合谁看已经会用find()做基础查询、想在真实业务里把列表页、排行榜、报表查询写稳的开发者。如果你被“排序结果每次都不一样”“排序超过内存限制”“查询越来越慢”这些现象折磨过这篇文章的第三章和第五章应该能直接帮上忙。先记住一句话排序不是查询尾部的装饰它是参与执行计划的一个关键环节决定查询性能的往往不只是过滤条件还有那个排序字段能不能被索引直接“顺路”排好。1. 排序在查询链路里到底处在什么位置1.1 先想清楚按哪个字段、升序还是降序写排序之前我建议先确认一个看似废话的问题到底按哪个字段排升序还是降序。很多新手会以为既然查询结果默认按照某种顺序返回那排序字段选什么都无所谓。实际上排序字段的选择直接决定索引设计也决定了查询在数据量变大之后是稳如老狗还是慢成PPT。在MongoDB里升序用1表示降序用-1表示这个标记会出现在sort()参数、复合索引定义和explain输出里含义完全一致。比如商品列表里sort({ price: 1 })要求从低价往高价返回sort({ price: -1 })则是高价优先。语义清楚之后再去看数据分布如果价格字段有大量重复值单纯按price排序会导致结果顺序不稳通常需要在后面补一个唯一性好的字段比如_id。这和SQL里的ORDER BY price, id思路同源只是很多人在MongoDB里容易忽略。db.products.find({ category: 手机 }).sort({ price: 1 })我见过不止一次把排序参数误写成sort(price, 1)或者干脆把排序对象塞进find()的查询条件里。Mongo Shell里的sort接收一个文档比如{ price: 1 }而Python等驱动里需要传字段顺序列表比如[(price, 1), (createdAt, -1)]。多字段排序时顺序很重要一旦写错结果完全是另一回事。1.2 排序不是查询的“收尾动作”而是执行计划的一块“可选项”MongoDB执行一条查询时并不是先全表扫一遍把结果堆起来再在内存里像Excel那样点一下排序。查询优化器会先生成一个执行计划过滤、排序、投影这些操作都是计划中的节点。如果排序字段可以命中索引自然就不需要出现独立的SORT阶段直接顺着索引有序扫描返回来天然就是排好的如果索引帮不上忙服务器才会在内存中对结果排序。这也是为什么同样的查询条件在不同数据量和索引下性能天差地别。你手动给1000条数据排序可能没感觉但如果是1000万条文档要全部读进内存排序轻则CPU飙升重则直接触发内存限制报错。所以看排序问题不能只盯着.sort()那一个方法还要往后看索引、看执行计划。2. 三种最常用的排序写法2.1find().sort()日常查询的主力日常业务里绝大多数排序需求用find()后面的.sort()就能解决。在mongo shell里写法是db.users.find({ status: active }).sort({ createdAt: -1 }).sort()接收一个文档键是排序字段值是方向。这条命令的意思是先找出status为active的用户再按createdAt从新到旧返回。如果使用PyMongosort参数建议写成列表list(collection.find( {status: active} ).sort([(status, 1), (createdAt, -1)]).limit(20))列表能保证字段顺序稳定因为某些语言或驱动不保证字典的遍历顺序。多字段排序时前面的字段优先级更高只有当前面的值完全相等时后一个字段才有机会参与排序。2.2 聚合管道里的$sort排序可以出现在更多位置当数据不是直接从集合里取而是要先经过$match、$group、$project等阶段处理再用find().sort()就无能为力了这时要用聚合管道里的$sort。它的排序语义和sort()一致但它是管道中的一个stage可以多次出现也可以接在分组统计之后。举个例子订单表里先按用户分组算出每个用户的消费总额再按总额倒序取前十名db.orders.aggregate([ { $group: { _id: $userId, total: { $sum: $amount } } }, { $sort: { total: -1 } }, { $limit: 10 } ])关键点在于stage顺序$sort放前面还是放后面结果会随着管道语义变化。想在分组前排序就在$group之前加一个$sort想在分组后排序就放在$group之后。普通查询里没有“sort永远在最后”的固定说法聚合管道里更没有。另一个区别是find().sort()面对的是查询结果而$sort处理的是管道当前的数据流灵活性高很多。$sort还经常配合$skip、$limit做分页或者配合$project先算出一个排序字段再排。2.3 和limit、skip组合时的顺序问题列表页常见的写法是sort后面接skip再接limit用来跳到第N页。在Mongo Shell的链式调用里MongoDB会优化执行顺序以下写法通常能按预期工作db.logs.find().sort({ createdAt: -1 }).skip(20).limit(10)但在聚合管道里阶段顺序不会由优化器任意重排$skip写在$sort前面就是先跳过再排序结果往往不是想要的。我建议保持统一习惯先$sort再$skip和$limit。这样语义清楚也方便以后把普通查询迁移到聚合里。如果skip的数值很大性能会急剧下降这实际上是分页方案设计的问题。把大量文档丢给skip去扫描再从第N条开始返回本质上就是浪费。第四章分页部分会再展开讲这个事。3. 排序性能的关键索引怎么帮忙3.1 能走索引的排序才是好排序索引的本质是一种已经排好序的存储结构。MongoDB的createIndex在定义索引时每个字段同样可以指定1或-1含义和sort()里的方向一致。所以如果某个查询的排序字段正好是索引里的字段MongoDB只需要按索引顺序读取文档不需要额外做内存排序。我经常拿日志表举例子。日志这类数据量大、写入频繁业务上几乎总是按时间倒序去查。建这样一个索引db.logs.createIndex({ createdAt: -1 })再执行查询db.logs.find({}).sort({ createdAt: -1 })MongoDB会直接倒序扫描这个索引explain结果里看不到SORT性能几乎只由实际读取多少文档决定跟排序耗时基本无关。反过来如果没建索引数据一多内存排序就会成为瓶颈。3.2 复合索引与排序字段的匹配规则复合索引是排序设计里的重头戏。建立复合索引时核心原则是先放等值查询字段再放排序字段。比如文章集合经常按status筛选并且在筛选结果里按createdAt倒序返回典型索引是db.articles.createIndex({ status: 1, createdAt: -1 })这样status: published这个等值条件可以快速定位而createdAt在满足条件的文档内部天然有序。查询长这样db.articles.find({ status: published }).sort({ createdAt: -1 })如果查询没有等值条件只有find({}).sort({ status: 1, createdAt: -1 })这个复合索引也可以按照(status, createdAt)顺序扫描排序依然能命中索引。MongoDB有时还能对索引做反向遍历比如索引方向是{ status: 1, createdAt: -1 }排序是{ status: 1, createdAt: 1 }优化器可能用反向扫描实现。日常最稳妥的做法就是让“等值条件字段在索引里排前面排序字段顺序尽量和排序文档一致”。这个原则能覆盖绝大多数场景。等值条件字段没放在前面或者排序字段在复合索引里被中间字段隔断索引往往就帮不上排序的忙。3.3 内存限制和“允许使用磁盘”是怎么回事排序如果无法命中索引MongoDB会把候选文档加载到内存里做排序。这个内存不是无限的很多项目里会出现类似“Sort operation used more than the maximum 33554432 bytes of RAM”的报错意思是排序超过了内存上限。不同版本的默认限制略有差异常见的是32MB或100MB数字本身不值得死记硬背要背的是解决思路。面对内存排序报错第一选择永远是加索引让排序不再发生在内存里第二选择是缩小结果集比如先过滤掉不需要的文档、减少返回字段第三选择才是允许磁盘排序。从MongoDB 4.4开始find和聚合管道都支持显式允许临时文件db.users.find({ city: beijing }).sort({ visits: -1 }).allowDiskUse() db.orders.aggregate([{ $sort: { amount: -1 } }], { allowDiskUse: true })磁盘排序可以救急但会写临时文件IO压力明显比内存排序大不适合长期使用。一个需要稳定支撑百万级数据排序的查询最终还是要回到索引设计上。3.4 用explain验证排序走了哪条路判断一条排序查询是否健康光靠猜是不行的。最直接的办法是加上explain(executionStats)查看执行计划db.users.find({ status: active }).sort({ createdAt: -1 }).explain(executionStats)在返回内容里找到winningPlan如果计划中出现SORT阶段说明排序没有完全命中索引如果主要阶段是IXSCAN且没有SORT说明排序被索引直接消化了。同时把totalDocsExamined和nReturned对比如果前者远大于后者说明扫描了很多用不上的文档查询代价偏高。这一步是我做排序调优时必做的动作。慢查询日志只能告诉你哪条语句慢但慢在哪里只有explain能把根因挖出来。4. 排序的进阶玩法多字段、中文排序与稳定分页4.1 多字段排序排序键的优先级是怎么定的多字段排序在业务里很常见比如先按分类展示再按创建时间倒序db.articles.find({}).sort({ category: 1, createdAt: -1 }).limit(50)排序规则是先看category值相同的再看createdAt。如果category不同后面的时间字段完全不参与比较。这和SQL的ORDER BY category ASC, createdAt DESC等价。设计复合索引时“等值查询字段放前、排序字段放后”和这条规则天然对应。但也要留意数据分布如果一个字段只有两三个值把它放在排序前面意义不大还会让稳定排序变得更难。排序键尽可能选择区分度高、业务语义明确的字段这样排序结果稳定索引也能发挥更大的作用。4.2 按中文习惯排序需要 collation 出马很多开发者在MongoDB里第一次对中文姓名字段排序时会很意外结果既不是按拼音也不是按笔画看起来像是按字符编码乱排的。这是正常现象因为MongoDB默认按Unicode代码点比较字符串中文也不例外。想让名字按拼音顺序出现需要指定排序规则collation。db.students.find().sort({ name: 1 }).collation({ locale: zh })locale: zh会让MongoDB使用针对中文的ICU排序规则排序结果更接近字典里的拼音顺序。collation还可以配合numericOrdering等参数专门处理那些以字符串存储的版本号比如v1、v2、v10。不加这个选项时v10会排在v2前面因为字符串逐字符比较时1在2前面。需要注意如果一个集合在创建时带有默认collation查询里没传相同的collation可能会影响比较结果和索引使用。所以“按中文排序”不是改一行代码就完事的先确认集合的collation设置再决定是在查询里临时指定还是统一在集合上配置。4.3 大数据量分页用排序键做游标别硬碰skip排序和分页经常混在一起出现而skip加limit是很多项目的默认选择。数据量小时无所谓一旦翻到第几十页skip会把前面所有文档都扫描一遍再丢弃速度会越来越慢。更糟糕的是排序字段有重复值或文档发生新增、删除时翻页很容易出现重复或漏数据。更稳的做法是游标分页也叫keyset分页。核心思路是把上一页最后一条记录的排序键记下来下一页从这条记录之后继续取。以createdAt倒序加_id兜底举例第一页正常取const firstPage db.posts.find().sort({ createdAt: -1, _id: -1 }).limit(20).toArray() const last firstPage[firstPage.length - 1] const secondPage db.posts.find({ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] }).sort({ createdAt: -1, _id: -1 }).limit(20).toArray()这里的逻辑是要么时间比上一页最后一条更早要么时间相同但_id更小这样下一页能严格接在上一条之后不会重复也不会因为普通skip造成巨大的扫描成本。前提是排序键具备唯一性这也是我反复建议排序字段补一个_id的原因。5. 常见问题与排查技巧实录5.1 排序结果不稳定原因通常只有一个好几次有同事跑来找我说同一句查询昨天和今天返回顺序不一样甚至同一秒执行两次结果也不同。我第一个问题永远是排序字段是不是有大量重复值比如只按status排而状态只有三五种那同一状态内部的几十万条文档MongoDB并不保证前后顺序。解决方式很朴素给排序加一个唯一性兜底字段。最常用的是_id因为_id全局唯一且自带索引。于是排序从sort({ status: 1 })改成sort({ status: 1, _id: 1 })顺序立刻稳定下来。代价很小收益很明确。5.2 从“排序报错”到“排序变慢”的排查路径如果遇到排序相关的报错或性能恶化我一般按下面这个顺序排查先看有没有内存超限报错有的话确认是否能用索引消除排序阶段而不是急着开磁盘排序。对目标查询执行explain(executionStats)看winningPlan里是否有SORT。如果有SORT对比查询条件和排序字段按“等值字段在前、排序字段在后”的规则建立复合索引。确认排序方向索引字段和排序字段方向一致或者依赖反向扫描。如果结果集很大考虑先用条件把数据范围收窄再配合分页返回。这套流程每一步都有现成命令可以验证不需要拍脑袋。养成习惯以后许多看起来玄学的排序问题都能快速定位。5.3 我自己的几条排序索引设计习惯最后分享几个我落库和写接口时的习惯都是踩过坑之后固定下来的。第一任何排序查询尽量让排序字段可命中索引尤其是列表接口。第二排序键里一定要有唯一字段兜底最简单的是_id。第三中文排序需求提前在集合设计阶段确认locale不要在业务代码里到处加collation。第四分页接口从第一天就按游标分页设计避免数据量大了以后还要改协议。这几点整理成一张速查表贴到团队文档里很实用现象可能原因推荐解法排序结果每次都不一样排序键重复值多缺少唯一兜底字段排序键追加_id数据一多就报内存超限排序没有命中索引结果集过大建匹配索引或先过滤再 limit深分页越来越慢skip扫描并丢弃大量文档改成游标分页按排序键续页中文排序不是拼音顺序默认按代码点比较字符串指定collation({ locale: zh })explain 出现 SORT 阶段索引无法满足排序调整复合索引等值字段放前实际操作中我还会把每个慢查询的explain结果截图存下来过两周对比一次看索引调整是否真的生效。排序不是写出来就完事它是需要持续观察的东西。这个系列后面还会继续聊索引优化先把排序这块地基打好很多概念到时候就能一通百通。