用_id范围查询替代skip()是最简单可靠的加速手段,因其避免线性扫描、利用objectid天然单调性实现高效分页,前提是分页顺序与_id一致且不篡改_id。

直接结论:用 _id 范围查询替代 skip() 是最简单、最可靠、开箱即用的加速手段,前提是分页顺序与 _id 的天然顺序一致(升序或降序),且不手动篡改 _id。
为什么 skip() 在高偏移量下必然变慢
MongoDB 执行 collection.find().skip(N).limit(M) 时,并不会“跳转”到第 N 条,而是从头扫描、逐条计数、丢弃前 N 条——N 越大,I/O 和内存消耗越线性增长。即使加了索引,skip() 仍需遍历索引项并逐条计数,无法跳过物理位置。在 Node.js 高并发接口中,这会迅速拖垮响应时间,甚至触发连接池耗尽或超时。
分片集群下更糟:每个 shard 都要独立执行 skip(N),再由 mongos 合并,N 大时大量无效扫描。返回空结果时,skip() 依然完整执行,纯属浪费资源。
用 _id 范围查询实现连续分页(原生驱动 & Mongoose)
_id 是 ObjectId 类型,默认有唯一索引,且天然单调递增(隐含写入时间戳),无需额外排序即可保证顺序稳定。
- 第一页:
collection.find({}).sort({ _id: 1 }).limit(20) - 取响应中最后一条文档的
_id(记为lastId) - 下一页:
collection.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(20)
注意:lastId 前端传回是字符串,但 MongoDB 驱动要求传 ObjectId 实例:new ObjectId(lastId);Mongoose 中 doc._id 是对象,JSON 序列化后变字符串,后端接收必须显式转换。
删 + 插等于重置 _id,破坏写入连续性;务必用 updateOne 替代 deleteOne + insertOne。不要 fallback 到 skip() 补足不足 limit 条数——那一步就让整个优化归零。
当必须按其他字段排序时(如 createdAt)
若业务强制要求按 createdAt 时间线分页,而 _id 顺序与之不一致,单靠 _id 无法满足语义。此时必须组合字段:
- 建复合索引:
{ createdAt: -1, _id: -1 }(确保覆盖查询和排序) - 下一页条件:
{ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] } - 必须配合
.sort({ createdAt: -1, _id: -1 }),否则索引失效
这种写法比纯 skip() 快得多,但比单字段 _id 分页多一次字段比较,且索引大小略增。
配套性能加固点(常被忽略)
仅换分页方式还不够。真实线上场景中,这几个点不处理,加速效果会被抵消:
- 启用
.lean():Mongoose 查询加.lean()返回 POJO,避免 Document 实例带来的内存和 GC 开销;必须写在.exec()或await之前,不能补加 - 连接池调优:生产环境建议
maxPoolSize: 50–100,minPoolSize: 10–20;socketTimeoutMS至少设为 45000,防止长查询中断 - 聚合分页慎用
$skip:聚合管道里{ $skip: N }同样低效;优先用$facet或拆成两次查询(先查 ID 列表再查详情)
最容易被忽略的是:范围分页只解决“查得快”,不解决“查得多”。如果一次返回几百条,哪怕每条都 .lean(),序列化和网络传输仍是瓶颈——这时候该考虑前端虚拟滚动或服务端流式响应。











