根本原因是skip()必须从头扫描计数,i/o和内存开销随跳过条数线性增长;应改用基于_id或复合索引的范围查询实现游标分页。

深分页越往后越慢,根本原因不是数据多,而是 skip() 本身在 MongoDB 里必须从头扫描计数——第 100 页要跳过前 999 条,第 1000 页就得跳过前 9999 条,I/O 和内存开销线性增长。这不是 Node.js 的锅,是查询模式选错了。
为什么 skip() 在 Node.js + MongoDB 中必然变慢
MongoDB 驱动调用 collection.find().skip(N).limit(M) 时,服务端不会“定位”到第 N 条,而是:从第一条开始逐条读、逐条计数、丢弃前 N 条,再取 M 条。哪怕加了索引,skip() 仍需遍历索引项并计数,无法跳过物理位置。分片集群下更糟:每个 shard 都得独立执行 skip(N),再由 mongos 合并结果。返回空结果时,skip() 依然完整执行,纯属浪费资源。
- 日志里反复出现
MongoWaitQueueFullException或Timed out after 5000ms,大概率就是skip()卡住连接没释放 -
db.currentOp()显示大量secs_running > 1000的操作,且planSummary是COLLSCAN或IXSCAN但totalDocsExamined远大于nReturned - 用
explain("executionStats")查,executionTimeMillis随skip值增大而明显上升,说明瓶颈就在跳过阶段
用 _id 范围查询替代 skip()(最简单可靠)
_id 是 ObjectId 类型,默认有唯一索引,且天然单调递增(含时间戳),只要不手动插入字符串或数字类型的 _id,就能直接用于升序/降序分页。
详细的 Three.js 3D 图形参考,涵盖场景设置、相机、几何体、材质、光照、动画、控制器、加载器、数学工具和调试。
- 第一页:
collection.find({}).sort({ _id: 1 }).limit(20) - 取响应中最后一条文档的
_id(记为lastId) - 下一页:
collection.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(20) - 前端传回的
lastId是字符串,Node.js 驱动必须转成ObjectId实例:new ObjectId(lastId),否则查不到 - Mongoose 场景下,
doc._id是对象,JSON 序列化后变字符串,后端接收后需显式转换,不能直接当条件用
当业务要求按其他字段排序(如 createdAt)时怎么办
如果必须按 createdAt 倒序展示,而 _id 顺序与之不一致,单靠 _id 分页会错乱。此时要建复合索引,并组合查询条件:
- 先建索引:
db.posts.createIndex({ createdAt: -1, _id: -1 })(注意字段顺序和方向) - 第一页:
collection.find({}).sort({ createdAt: -1, _id: -1 }).limit(20) - 取最后一条的
{ createdAt, _id }(记为last) - 下一页:
collection.find({ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] }).sort({ createdAt: -1, _id: -1 }).limit(20) - 别用
$gt混搭createdAt和_id,MongoDB 无法高效利用索引;$or写法才能让 B-tree 索引真正生效
游标分页库能省事,但别盲目封装
像 srylax/mongodb-cursor-pagination 这类库本质就是帮你自动拼上面的 $or 条件和索引逻辑,适合快速上线。但它掩盖了底层细节:
- 它默认依赖
createdAt字段,如果你集合里该字段缺失或类型不一致(比如有时是 string),会静默失败 - 它生成的游标是 base64 编码字符串,前端不该解析或修改,否则可能破坏排序语义
- 它不解决索引缺失问题——如果没提前建好
{ createdAt: -1, _id: -1 },库照样慢 - 调试时,你得知道它最终发给 MongoDB 的是什么查询,而不是只看库的 API 返回
最容易被忽略的是:深分页优化不是“换一个函数”,而是整个查询逻辑的重构。一旦用了 skip(),哪怕只在 fallback 场景下补几条数据,那一整页的性能优势就归零。真正的分页边界必须由客户端严格维护,服务端只做校验,不兜底。










