skip() + limit() 深度分页变慢是因为 skip(n) 需逐条扫描并丢弃前 n 条文档,n 越大开销越高;若 sort 无索引还会触发 inmemorysort,退化为 o(n)。

为什么 skip() + limit() 在深度分页时越来越慢
因为 skip(N) 不是“跳到第 N 条”,而是让 MongoDB 逐条扫描、加载并丢弃前 N 条匹配文档。N 越大,CPU、内存和磁盘 I/O 开销越高;如果还搭配没索引的 sort(),就会触发 inMemorySort,性能直接退化为 O(N)。
常见现象包括:db.collection.find().sort({ createdAt: -1 }).skip(50000).limit(20) 执行时间从几毫秒涨到数秒;同一请求多次执行,返回顺序不一致(尤其写入活跃时);explain("executionStats") 显示 nReturned 很小,但 totalDocsExamined 高达几十万。
必须先建对索引,否则游标也无效
游标分页不是“开了就快”,它依赖排序字段能走索引。否则连范围查询都命中不了索引,照样慢。
- 单字段排序(如
createdAt):建db.collection.createIndex({ createdAt: -1 }) - 多字段排序(如
{ status: 1, createdAt: -1 }):索引字段顺序必须严格一致,且等值查询字段(如status: "active")要包含在索引最左前缀中 - 务必在排序里加
_id去重,例如.sort({ status: 1, createdAt: -1, _id: 1 }),否则时间相同会导致分页错位
用 $gt / $lt + _id 实现最简游标分页
这是最稳定、最容易落地的方案,适合按插入顺序分页。
假设上一页最后一条是 { _id: ObjectId("65a1b2c3d4e5f67890123456") },下一页查法:
db.collection.find({ _id: { $gt: ObjectId("65a1b2c3d4e5f67890123456") } })
.sort({ _id: 1 })
.limit(20)
要点:
- 用
$gt,不是$gte(否则重复返回最后一条) -
sort方向必须和游标方向一致:用$gt就配{ _id: 1 };倒序分页则用$lt+{ _id: -1 } - 第一页不能用游标,直接
find().sort({ _id: 1 }).limit(20) - 客户端传回的
_id必须是字符串格式,别转成ObjectId再传——服务端解析容易失败
复合排序下的游标写法(如 status + createdAt)
不能只靠 _id,因为不同 status 下 createdAt 可能相同,仅比 _id 会跨状态漏数据。
正确做法是把上一页末尾的排序键拆解为 $or 条件:
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,否则无法命中索引 - 前端需传回两个字段值:
status和createdAt(以及必要时的_id) - 所有参与排序的字段,只要可能重复,就得进游标条件——漏一个,分页就错位
真正难的不是写法,而是确保每次查询的排序键组合能唯一标识位置。业务逻辑越复杂,游标条件越容易出疏漏,这点比性能本身更值得盯紧。











