skip+limit深度分页变慢是因为mongodb需逐条扫描并丢弃前n条文档,引发大量磁盘i/o和内存排序;应建匹配排序的复合索引并用游标分页替代。

为什么 skip + limit 在深度分页时越来越慢
因为 skip(N) 不是跳过索引位置,而是让 MongoDB 逐条扫描并丢弃前 N 条匹配文档。当 skip=100000 时,即使只返回 20 条,也要先读取并扔掉 10 万条——这些文档可能不在内存里,触发大量磁盘 I/O;更糟的是,如果排序没走索引,还要额外做内存排序。
常见错误现象:db.collection.find().sort({ createdAt: -1 }).skip(50000).limit(20) 执行时间从几毫秒飙升到数秒,且越往后越慢;explain("executionStats") 显示 nReturned 很小,但 totalDocsExamined 高达几十万。
必须建对索引:排序字段+查询条件要覆盖
MongoDB 执行 sort() + skip() + limit() 的固定顺序是:先排序 → 再 skip → 最后 limit。如果排序无法利用索引,就会触发 inMemorySort,而内存排序无法跳过中间数据,导致 skip 成本爆炸。
- 单字段排序(如
createdAt),建单字段索引:db.collection.createIndex({ createdAt: -1 }) - 多字段排序(如
{ status: 1, createdAt: -1 }),索引字段顺序必须严格匹配,且包含等值查询字段(如status: "active"):db.collection.createIndex({ status: 1, createdAt: -1 }) - 务必在排序中加入
_id去重,例如.sort({ status: 1, createdAt: -1, _id: 1 }),否则重复值会导致分页错位
用游标替代 skip:基于上一页末尾值构造查询条件
游标分页本质是“记住位置”,不是“跳过多少条”。它把上一页最后一条文档的关键排序字段值(如 createdAt 和 _id)作为下一页的查询起点,绕过 skip 的扫描开销。
- 若按
_id正序分页,上一页最后一条是{ _id: ObjectId("65a1b2c3d4e5f67890123456") },则下一页查:db.collection.find({ _id: { $gt: ObjectId("65a1b2c3d4e5f67890123456") } }).sort({ _id: 1 }).limit(20) - 若按
createdAt降序,且存在时间相同的情况,必须联合_id:db.collection.find({ $or: [ { createdAt: { $lt: ISODate("2024-01-01T10:00:00Z") } }, { createdAt: ISODate("2024-01-01T10:00:00Z"), _id: { $lt: ObjectId("...") } } ] }).sort({ createdAt: -1, _id: -1 }).limit(20) - 前端必须安全传回上一页末尾的
createdAt和_id字符串,不能转成对象再传,否则 ObjectId 解析失败
游标分页最容易被忽略的前提
游标分页不是“开了就快”,它依赖几个隐性前提:
- 排序字段必须有索引,否则游标查询也走全表扫描
-
$or条件中字段顺序必须和复合索引字段顺序完全一致,否则无法命中索引 - 首次请求仍需完整排序 +
limit,但后续全部是范围查询,性能稳定在 O(log n);这意味着客户端必须能保存并传递游标值,不能只传页码 - 写入活跃的集合中,如果上一页查完到下一页发起之间有新文档插入到“游标位置之前”,会导致漏数据——这是业务可接受的 trade-off,不是 bug











