应使用 range-based 分页替代 $skip:通过记录上一页最后一条的排序字段值,下一页查询“比它更小/更大”的数据,配合 $sort 和对应索引实现高效分页。

直接用 $skip + $limit 在聚合管道里做分页,数据量稍大就卡死或超时——这不是配置问题,是设计缺陷。
为什么 $skip 在聚合后分页会慢到不可用
因为 $skip 不跳“物理文档”,而是跳“逻辑输出流中的前 N 条”。哪怕 $group 后只剩 50 条结果,你写 $skip: 10000,MongoDB 仍要模拟生成并丢弃那 10000 条(根本不存在的)中间文档。
- 真实瓶颈常不在
$skip本身,而在它前面的$lookup或$unwind:这两个阶段极易把 1 万条输入放大成百万级中间文档,$skip就得在百万级流上数到第 10000 条 - 索引对
$skip完全无效——它只依赖前序阶段的输出量,不接触原始集合 -
allowDiskUse: true解不了这个问题,只是让磁盘撑住 OOM,不解决本质的线性扫描
用 range-based 分页替代 $skip(推荐方案)
核心思路:不跳行数,而是记住上一页最后一条的排序字段值,下一页查“比它更小/更大”的数据。适用于有唯一、有序字段的场景(如 _id、createdAt、seqNo)。
- 聚合管道末尾必须加
$sort,且排序字段要有索引(复合索引优先覆盖$match条件) - 示例:按时间倒序分页,上一页最后一条是
createdAt: ISODate("2026-07-20T10:30:00Z"),下一页查询写成:[{"$match": {"createdAt": {"$lt": ISODate("2026-07-20T10:30:00Z")}}}, {"$sort": {"createdAt": -1}}, {"$limit": 20}] - 注意:不能混用
$skip和 range 查询;首次加载可用$limit取第一页,后续全部走 range
聚合阶段顺序不当会放大分页压力
错误写法:先 $unwind 或 $lookup,再 $match 过滤,最后 $skip——这等于把全量关联结果都算出来才开始裁剪。
- 正确顺序:尽可能早地用
$match过滤原始集合(利用索引),再$lookup关联,再$unwind(如果必须),最后$group/$sort,$limit放最后 - 特别注意
$lookup的pipeline参数:里面也可以嵌套$match,避免拉回整张被关联表 - 测试执行计划时重点看
executionStats.nReturned和executionStats.totalDocsExamined,两者差距过大就是阶段顺序有问题
游标分页(cursor pagination)不是银弹
用 find().sort().limit() 配合 min()/max() 游标能规避 $skip,但聚合管道不支持原生游标分页——你得把聚合结果存到临时集合,再对临时集合做游标分页,或者用应用层缓存“上一页末位值”。
- 临时集合方案适合离线报表类场景,实时性差但稳定;应用层缓存适合用户端分页,需处理排序字段重复、更新丢失等问题
- 如果排序字段非唯一(比如多个文档
createdAt相同),range 查询可能漏数据或重复,必须加二级排序键(如{createdAt: -1, _id: -1})并确保复合索引存在 - 别指望
maxTimeMS能“兜底”——它只中断执行,不改变$skip的计算代价;超时前 MongoDB 已经在白忙活了


















