用_id范围查询替代skip()是最简单可靠的优化手段,因ObjectId天然单调递增且默认有索引,只需首页sort({ _id: 1 }).limit(20),后续页用{ _id: { $gt: lastId } }.sort({ _id: 1 }).limit(20)即可高效分页。

直接结论:用 _id 范围查询替代 skip() 是最简单、最可靠、默认就生效的优化手段,前提是分页顺序与 _id 天然顺序一致(升序或降序),且不手动篡改 _id。
为什么 skip() 在 Node.js + MongoDB 场景下必然变慢
Node.js 层调用 collection.find().skip(N).limit(M) 时,MongoDB 并不会“跳转”到第 N 条,而是从头开始扫描、计数、丢弃前 N 条——N 越大,I/O 和内存消耗越线性增长。在 Node.js 高并发接口中,这会迅速拖垮响应时间,甚至触发连接池耗尽或超时。
- 即使加了索引,
skip()仍需遍历索引项并逐条计数,无法跳过物理位置 - 分片集群下更糟:每个 shard 都要独立执行
skip(N),再由mongos合并,N 大时大量无效扫描 - 返回空结果时,
skip()依然完整执行,纯属浪费资源
用 _id 范围查询实现连续分页(Node.js 原生驱动)
利用 ObjectId 的时间戳+机器码+自增特性,它天然单调递增(写入时间隐含其中),无需额外排序即可保证顺序稳定。
- 第一页:
collection.find({}).sort({ _id: 1 }).limit(20) - 取响应中最后一条文档的
_id(记为lastId) - 下一页:
collection.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(20) - 必须确保使用
.toArray()或流式消费(如cursor.next()),避免游标未释放
注意:_id 默认已有唯一索引,无需额外建索引;但若手动插入非 ObjectId 类型的 _id(如字符串、数字),该方案失效。
常见误操作和兼容性陷阱
这个方案看着简单,但在 Node.js 实际集成中容易踩坑:
- 前端传回的
lastId是字符串,但 MongoDB 驱动要求传ObjectId实例:new ObjectId(lastId),否则查不到 - 使用 Mongoose 时,
doc._id是ObjectId对象,但 JSON 序列化后变成字符串,后端接收需显式转换 - 删+插等于重置
_id,破坏写入连续性;用updateOne替代deleteOne + insertOne - 不要 fallback 到
skip()补足不足limit条数——那一步就让整个优化归零
何时不该只用 _id 分页?
当业务强制要求按其他字段排序(如 createdAt 时间线),而 _id 顺序与之不一致时,单靠 _id 无法满足语义。此时必须组合字段,例如:
- 建复合索引:
{ createdAt: -1, _id: -1 } - 下一页条件:
{ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] } - 校验
createdAt是否被客户端恶意篡改(比如填个极大负数),否则游标逻辑崩塌
真正难的不是写对第一行代码,而是守住「不混用 skip」和「不破坏游标连续性」这两条底线——一旦松动,性能收益瞬间清零。


















