深度分页变慢是 skip() 的固有逻辑导致,必须改用游标分页:用 _id 或排序字段作为游标,结合 $gt/$lt 和严格匹配索引实现高效分页,避免磁盘 I/O 与排序开销。

深度分页变慢不是 MongoDB 的 bug,是 skip() 本身的执行逻辑决定的——它必须逐条扫描并丢弃前 N 条文档,哪怕只返回 20 条。优化方向很明确:停用 skip(),改用游标分页。
为什么 skip() + limit() 在页码靠后时会卡住
skip(100000) 不是“跳到第 100001 条”,而是让存储引擎从头开始读、判断、丢弃前 10 万条匹配文档。这些文档若不在内存里,就会触发大量磁盘 I/O;如果查询没走索引或带 sort(),还要额外做内存排序。常见现象:db.collection.find().sort({ createdAt: -1 }).skip(50000).limit(20) 从几毫秒涨到数秒,且页码越大越慢。
必须确保排序字段有对应索引(如 { createdAt: -1 }),否则游标方案也无效。
用 _id 游标替代 skip() 的实操要点
_id 默认有序、唯一、自带索引,是最稳妥的游标字段。核心是“不跳,只续”:
- 第一页:直接
db.collection.find().sort({ _id: 1 }).limit(20) - 后续页:取上一页最后一条的
_id字符串(不是 ObjectId 对象),构造{ _id: { $gt: ObjectId("...") } }查询 - 排序方向必须和游标一致:用
$gt就配sort({ _id: 1 });倒序分页则用$lt+sort({ _id: -1 }) - 客户端必须原样保存并传回
_id字符串(如"65a1b2c3d4e5f67890123456"),别转成 ObjectId 再传,否则服务端可能解析失败
按业务字段(如 score、status)排序时怎么写游标
不能只靠 _id,否则会跨排序值漏数据。正确做法是用“排序键”本身当游标,并严格按索引顺序构造 $or 条件:
假设索引为 { status: 1, createdAt: -1 },上一页末尾文档是 { status: "active", createdAt: ISODate("2024-01-01T10:00:00Z") },下一页查询应为:
db.collection.find({
$or: [
{ status: { $gt: "active" } },
{ status: "active", createdAt: { $lt: ISODate("2024-01-01T10:00:00Z") } }
]
}).sort({ status: 1, createdAt: -1 }).limit(20)
注意:$or 分支顺序必须和索引字段顺序完全一致,否则无法命中索引;每个分支都得有独立索引支撑,不能指望一个大复合索引覆盖所有 $or 组合。
事务内分页更要禁用 skip()
事务中 skip() 危害更大:它会拉长锁持有时间、暴涨内存、甚至触发 maxTransactionLockRequestTimeoutMS 超时。而且事务查询无法利用覆盖索引跳过文档读取——必须走 snapshot 隔离。
务必改用游标分页 + 严格复合索引。例如查 { status: "active", category: "electronics" } 并按 created_at 倒序,索引必须建全:{ status: 1, category: 1, created_at: -1, name: 1, price: 1 },查询时带上 created_at 游标条件,并用 explain("executionStats") 验证 totalDocsExamined === 0 才算真正走覆盖索引。
最容易被忽略的一点:游标分页不是“换了个函数就完事”,它要求前端安全传递游标值、后端严格校验格式、索引与查询条件完全对齐——少一个环节,性能就打折扣。

















