在使用MongoDB做列表查询时,排序几乎是绕不开的操作。按时间倒序取最新数据、按销量排商品、按分数排榜单,这些场景背后都依赖sort能力。但不少人都遇到过这样一个报错:Sort exceeded memory limit, but didn't opt in to external sort。这个报错意味着排序操作在内存中占用的空间超过了限制,MongoDB直接终止了这次查询。要解决这个问题,光把限制调大是不够的,关键在于理解MongoDB排序的执行机制,知道什么时候会走内存排序,什么时候能走索引,才能从根上优化。

一、MongoDB排序为什么会消耗内存
要理解排序内存限制,先要知道MongoDB执行排序的两种方式:索引排序和内存排序。如果查询的排序键正好命中一个索引,MongoDB可以直接按索引的顺序遍历文档,边遍历边返回结果,这个过程几乎不额外占用内存,性能也是最好的。但如果排序键没有索引支持,MongoDB就只能把符合条件的文档取出来,在内存中做完整的排序,这就是内存排序。
内存排序的具体过程是:MongoDB会把参与排序的每个文档的关键信息装载到一个内部缓冲区中,这个缓冲区的大小受到限制。普通查询的默认限制是32MB,而聚合管道中的$sort阶段默认限制是100MB。一旦待排序的数据量超过这个上限,且没有允许溢出到磁盘,就会抛出错误。这里有一个很容易被忽略的细节:占用的内存不是文档的完整大小,而是排序键加上投影字段的大小总和,所以即使集合里文档很大,如果只返回少量字段,能排序的数据量也会相应变多。
可以通过执行计划来确认一条查询是否走了内存排序。用explain加上executionStats参数,观察执行计划的_stage字段。如果看到SORT或者SORT_KEY_GENERATOR这样的阶段,说明走了内存排序;如果看到IXSCAN后面直接跟FETCH,没有SORT阶段,说明排序被索引覆盖了。
db.orders.find({status: "paid"}).sort({createTime: -1}).explain("executionStats")
// 关注输出中的 winningPlan 部分:
// 出现 "stage": "SORT" 表示内存排序
// 出现 "stage": "IXSCAN" 且无 SORT 阶段,表示索引排序二、allowDiskUse是解药还是止痛药
遇到排序超限报错时,网上最常见的答案是加上allowDiskUse参数。对于聚合管道,在执行方法时传入这个选项;对于普通查询,MongoDB 4.4之后的版本也支持了hand语句的allowDiskUse()链式方法。开启之后,排序缓冲区满了会把数据写到磁盘上的临时文件,继续处理剩余数据,查询不再因为超限而失败。
// 聚合管道开启磁盘溢出
db.orders.aggregate(
[{ $match: { status: "paid" } },
{ $sort: { createTime: -1 } },
{ $limit: 100 }],
{ allowDiskUse: true }
)
// 普通查询开启磁盘溢出(4.4+)
db.orders.find({status: "paid"})
.sort({createTime: -1})
.allowDiskUse(true)但必须清楚,allowDiskUse只是让查询能跑通,不是让它跑得快。磁盘的读写速度远低于内存,一旦排序溢出到磁盘,查询耗时会成倍增加,在数据量大的情况下可能从几百毫秒恶化到几十秒。更重要的是,每个溢出到磁盘的排序操作会额外占用临时目录的空间,并发量大时磁盘压力会非常明显。所以这个参数适合作为应急手段或者低频的后台任务,绝对不应该成为线上高频查询的常规配置。
另外还有一个思路是调大内存限制,比如在聚合管道中使用$sort时通过setParameter调整内部参数。但直接修改服务器参数影响的是全局行为,风险较高,而且本质上是延缓问题爆发,数据量继续增长还是会触顶,一般不推荐在生产环境使用。
三、索引才是排序优化的根本方案
真正优雅的解法是让排序走索引。原理很简单:MongoDB的B树索引本身就是有序的,如果排序键能被索引覆盖,数据库就无需在内存中重新组织数据。设计索引时要遵循一个原则:等值查询字段在前,排序字段在后。比如查询条件是status等于paid,按createTime降序,那么索引应该建在{status: 1, createTime: -1}上。
// 创建支持查询加排序的复合索引
db.orders.createIndex({status: 1, createTime: -1})
// 这个查询将直接通过索引顺序返回,无内存排序
db.orders.find({status: "paid"}).sort({createTime: -1})这里有几个容易踩的坑需要说明。第一,排序方向必须与索引方向一致,索引是{status: 1, createTime: -1},查询排序写成{createTime: 1}就不能利用索引了,除非两个键一起反向,即{status: -1, createTime: 1}也可以匹配,单键反号不行。第二,如果查询中有范围条件,比如createTime大于某个时间点,同时还要按createTime排序,这种情况下索引依然有效。但如果范围条件是另一个字段,排序字段放在它后面,索引只能部分生效,需要结合执行计划具体分析。第三,分页场景下建议用基于排序键的游标分页替代skip,比如记住上一页最后一条的createTime,下一页用$lt过滤,这样能避免深分页导致的性能塌陷。
对于排序键不固定、用户可以自选排序字段的场景,比如商品列表既可按价格排也可按销量排,可以针对每个高频排序组合分别建立索引,MongoDB的查询计划器会自动选择。如果排序维度实在太多,就要在业务层面做取舍,限制可选的排序字段,或者利用预热缓存、物化视图等方式把排序结果提前算好。
四、定位与验证排序问题的实操方法
优化之前先要能定位问题。除了看报错信息,还可以通过几个途径发现慢排序。第一是开启慢查询日志,设置profile级别为1,把慢查询阈值调到一个合适的值,所有超过阈值的操作会被记录到system.profile集合,查看其中的planSummary字段,如果包含SORT字样,说明存在内存排序。第二是定期跑explain分析核心接口的执行计划,把这一步纳入上线前的检查流程。
// 设置慢查询阈值为200毫秒
db.setProfilingLevel(1, {slowms: 200})
// 查看慢查询记录中的排序信息
db.system.profile.find(
{planSummary: /SORT/},
{ns: 1, command: 1, millis: 1, planSummary: 1}
).sort({ts: -1}).limit(10)建立索引后一定要验证效果。重新执行explain,确认执行计划中SORT阶段消失了,同时关注totalDocsExamined和nReturned的比值,理想情况下这个比值应该接近1,说明扫描的文档都有效返回了。此外还要留意新索引对写入性能的影响,复合索引每多一个,每次插入和更新都要多维护一棵B树,写多读少的集合要控制索引数量。排序优化本质上是在读性能、写开销和存储成本之间找平衡,理解了内存排序和索引排序的切换逻辑,就能针对自己的业务场景做出正确选择。