$skip本质是逐条丢弃而非跳转,需真实读取并丢弃前n条文档,导致i/o和内存开销巨大;游标分页通过\_id或复合索引+范围查询实现o(log n)高效分页。

$skip不是跳转,是逐条丢弃
MongoDB 的 skip() 从不“跳到第 N 条”,而是从头开始扫描、计数、丢弃前 N 条匹配文档。哪怕你只想要 20 条,skip(100000) 也得真实读取并扔掉 10 万条——这些文档若不在内存里,就会触发大量磁盘 I/O。更糟的是,如果查询带 sort() 且没走索引,还得额外做内存排序。
索引不能加速$skip本身
即使你在排序字段(如 createdAt)上建了索引,skip() 仍需遍历索引项逐条计数,无法跳过物理位置。它依赖的是前序阶段的输出量,不是索引的 B-tree 跳转能力。典型卡点:db.collection.find({ status: "active" }).sort({ createdAt: -1 }).skip(50000).limit(20) 执行时间从几毫秒飙升到数秒,且越往后越慢。
- 聚合管道中
$skip同样吃力:即使$group后只剩几百条,$skip: 10000还是得把前 10000 条“逻辑上存在”的中间结果生成出来再丢弃 - 分片集群下更差:每个 shard 都要独立执行
skip(N),再由 mongos 合并,N 大时大量无效扫描 - 返回空结果时,
skip()依然完整执行——纯属浪费资源
为什么_id游标能绕过这个问题
_id 是 ObjectId 类型,默认有唯一索引,且天然单调递增(隐含写入时间戳),无需额外排序即可保证顺序稳定。用 $gt 或 $lt 直接定位起点,MongoDB 可以利用索引快速定位,查询复杂度稳定在 O(log n)。
- 第一页:
collection.find({}).sort({ _id: 1 }).limit(20) - 下一页:取上一页最后一条的
_id(字符串),转成ObjectId实例,查{ _id: { $gt: lastIdObj } }+.sort({ _id: 1 }) - 必须用
$gt(不是$gte),否则会重复返回最后一条 - 前端传回的
_id是字符串,后端接收必须显式转为ObjectId,Mongoose 中doc._id是对象,JSON 序列化后变字符串,这点极易漏掉
按其他字段排序时游标怎么写
如果业务强制要求按 createdAt 分页,而 _id 顺序与之不一致,就得组合字段构造游标条件,且必须严格匹配索引顺序。
- 建复合索引:
{ createdAt: -1, _id: -1 } - 下一页条件:
{ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] } - 必须配
.sort({ createdAt: -1, _id: -1 }),否则索引失效 - 删+插等于重置
_id,破坏写入连续性;务必用updateOne替代deleteOne + insertOne - 不要 fallback 到
skip()补足不足limit条数——那一步就让整个优化归零
skip() 原点。











