直接结论:别用 skip(),改用基于排序字段的游标查询——最简方案是用 _id 范围查询,性能稳定、开箱即用,且不随页码增长而退化。

直接结论:别用 skip(),改用基于排序字段的游标查询——最简方案是用 _id 范围查询,性能稳定、开箱即用,且不随页码增长而退化。
为什么 skip() 在高偏移量下必然变慢
MongoDB 执行 skip(N) 时,并不是跳到第 N 条记录,而是从头开始扫描、逐条计数、丢弃前 N 条。N 越大,I/O、CPU 和内存消耗越线性增长。即使有索引,它仍需遍历索引项并计数,无法跳过物理位置。
- 分片集群下更糟:每个 shard 都要独立执行
skip(N),再由mongos合并结果,N 大时大量无效扫描 -
explain("executionStats")显示totalDocsExamined高达几十万,但nReturned只有 20 条 - 返回空结果时,
skip()依然完整执行,纯属浪费资源 - 聚合管道中用
$skip同样低效,不能靠索引优化
用 _id 范围查询替代 skip()(最简可靠方案)
_id 是 ObjectId 类型,默认有唯一索引,且天然单调递增(隐含写入时间戳),只要按 _id 升序或降序分页,就能避开排序和重复问题。
- 第一页:
collection.find({}).sort({ _id: 1 }).limit(20) - 取响应中最后一条文档的
_id(记为lastId),前端传回的是字符串,后端必须转成ObjectId实例:new ObjectId(lastId) - 下一页:
collection.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(20)(升序用$gt,降序用$lt) - 如果返回不足 20 条,说明到底了;绝不能 fallback 到
skip()补足,否则优化归零 - 手动插入非
ObjectId类型的_id(如字符串、数字),该方案立即失效
必须按其他字段排序时(如 createdAt)怎么写游标
当业务强制要求按 createdAt 分页,而 _id 顺序与之不一致时,单靠 _id 无法满足语义,必须组合字段去重。
- 建复合索引:
{ createdAt: -1, _id: -1 }(顺序必须严格匹配sort()) - 下一页条件:
{ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] } - 必须配合
.sort({ createdAt: -1, _id: -1 }),否则索引失效 - 若
createdAt是用户可控字段(如前端传入),需校验其合理性,避免被恶意构造导致游标失效 - 删 + 插操作会破坏写入连续性,务必用
updateOne替代deleteOne + insertOne
实际部署中最容易被忽略的点
游标分页不是“开了就快”,几个隐性前提不满足,加速效果会被抵消:
- 前端必须透传上一页最后一条的完整排序键(如
{ createdAt, _id }),不能只传页码;API 接口应接受cursor或lastId参数,而非page和size - Mongoose 查询加
.lean()返回 POJO,避免 Document 实例带来的内存和 GC 开销;必须写在.exec()或await之前 - 分片环境下,若分片键与分页查询不匹配(如分片键是
{ userId: 1 },但查全站sort({ createdAt: -1 })),所有 shard 仍会被广播查询,游标只是“不更差”,不是“变快” - 聚合后分页不能直接
$skip,应先$sort + $limit输出有限结果,再由应用层控制游标


















