skip() 在大数据量下越来越慢,因其需逐条扫描并丢弃前n条匹配文档,i/o和cpu开销随偏移量线性增长;应改用基于_id的游标分页,前提是_id唯一、有序、不可变,并配合复合索引优化复杂查询。

为什么 skip() 在大数据量下会越来越慢
MongoDB 的 skip() 不是跳过内存里的结果,而是让存储引擎逐条扫描并丢弃前 N 条匹配文档。数据量越大、跳得越远,它就要越多次磁盘 I/O 或内存遍历——哪怕你只想要第 100 万条之后的 20 条,MongoDB 仍得先“数”完前面所有匹配项。
常见错误现象:db.collection.find().skip(999999).limit(20) 在千万级集合中可能耗时数秒甚至超时;配合 sort() 时更糟,因为排序本身已需全量内存或临时文件。
- 使用场景:分页列表(尤其是用户手动拖到底部、无限滚动加载)
- 根本问题:skip 是偏移量语义,不是位置语义;它不依赖数据分布,但代价随偏移线性增长
- 性能影响:跳页越深,CPU 和 I/O 压力越明显;副本集主节点压力尤其高
用 _id 当游标必须满足的三个前提
_id 能当游标用,不是因为它“默认存在”,而是因为它天然满足游标连续性的三个硬条件:唯一、有序、不可变。但如果你用的是自定义 _id(比如字符串 UUID),就很可能踩坑。
- 必须是 ObjectId 类型(或严格单调递增的整数/时间戳字符串),否则排序无意义
- 插入顺序必须和业务时间顺序强一致(例如用
ObjectId()自动生成,它前 4 字节是时间戳) - 不能在业务中更新
_id字段——MongoDB 禁止修改,但有人误以为“重写整个文档”等于更新_id,实际是删+插,破坏连续性
反例:{ _id: "user_123" } 这种字符串 _id 无法保证插入先后与字典序一致,find({ _id: { $gt: "user_123" } }).limit(20) 可能漏数据或重复。
真实可用的游标分页写法(含边界处理)
核心逻辑是“上一页最后一条的 _id 作为下一页查询起点”,但要注意边界条件:空结果、重复数据、并发插入。
- 首次请求:用
sort({ _id: 1 })+limit(20),拿到第 20 条的_id(记为last_id) - 后续请求:查
find({ _id: { $gt: last_id } }).sort({ _id: 1 }).limit(20) - 如果返回不足 20 条,说明到底了;不要补
skip()回退,那会回到低效模式 - 并发插入可能导致新文档
_id比last_id小但还没被读到——这是最终一致性下的正常现象,前端应接受“可能少一条”,而非强求绝对连续
示例(Node.js + MongoDB Driver):
collection.find({ _id: { $gt: new ObjectId(last_id) } })
.sort({ _id: 1 })
.limit(20)
.toArray()
什么时候不能只靠 _id 游标
当你的查询带复杂过滤条件(比如 { status: "active", category: "news" })且没有对应复合索引时,_id 游标会失效:MongoDB 先按 _id 扫描,再回表过滤,效率反而不如全索引扫描。
- 必须建复合索引,把过滤字段放前面、
_id放最后,例如:db.collection.createIndex({ status: 1, category: 1, _id: 1 }) - 如果排序字段不是
_id(比如按updated_at倒序),就不能直接用_id游标——得用updated_at+ 其他唯一字段(如_id)组合游标 - 聚合管道分页(
$facet或$skip/$limit)本质上仍是偏移量模型,不解决大跳页问题,别被语法迷惑
真正难的不是写对一行代码,而是判断当前查询路径是否真的走到了索引最末位——explain("executionStats") 里看 nReturned 和 totalDocsExamined 是否接近,差太多就说明游标没生效。










