因为skip(N)需扫描并丢弃前N条文档,N越大开销越高;应改用游标分页,基于上一页末尾排序字段值(如_id)构造$gt/$lt范围查询,配合索引实现O(log n)性能。

为什么 skip/limit 在深度分页时会变慢
因为 skip(N) 不是“跳到第 N 条”,而是让 MongoDB 先扫描、加载并丢弃前 N 条匹配文档。N 越大,CPU 和内存开销越明显;当页码达到 1000+(即 skip(10000) 以上),响应时间可能从毫秒级飙升到数秒甚至超时。
执行计划里能看到 "stage": "SKIP",且 skipAmount 直接等于你传的偏移量。这不是索引失效,而是设计使然——它必须逐条迭代游标才能“数够”要跳过的数量。
- 即使
_id有默认索引,skip仍需遍历索引节点 - 复合查询(如带
status和createdAt过滤)下,skip会在过滤后的结果集上生效,实际扫描量可能远超预期 - WiredTiger 缓存压力增大,容易触发 page eviction,毛刺明显
用游标分页替代 skip/limit
核心思路:不靠“跳过多少条”,而靠“从某条记录之后开始取”。前提是排序字段必须有索引、值唯一且有序(如 _id 或带索引的 createdAt)。
例如按 _id 升序分页,第一页查出最后一条的 _id 值(记为 last_id),第二页就用:
db.collection.find({ "_id": { "$gt": last_id } }).sort({ "_id": 1 }).limit(20)
注意:$gt 配合升序、$lt 配合降序,方向必须一致,否则漏数据或重复。
- 首次查询不能带游标条件,必须完整走一次
sort().limit() - 前端需保存上一页末尾的
_id(或时间戳),作为下一页请求参数 - 若排序字段非
_id,务必确认该字段已建索引,比如db.collection.createIndex({ "createdAt": -1 }) - 不要混用
skip和游标逻辑——两者语义冲突,极易出错
处理跳页(如直接跳到第 100 页)的现实约束
游标分页天然不支持随机页码跳转。强行实现跳页,本质还是绕回 skip,性能不会改善。
生产中更务实的做法是限制可跳转范围(如只允许页码 ≤ 50),或改用估算+缓存策略:
- 对总记录数不做精确
count(),改用db.collection.estimatedDocumentCount()(快,但不实时) - 把高频访问页的起始
_id缓存在 Redis,键形如page:articles:100:asc→"ObjectId('...')" - 用户点第 100 页时,先查缓存拿到起始
_id,再执行游标查询;缓存未命中则 fallback 到低频容忍的skip查询并回填
这种空间换时间的折中,在亿级数据场景下比硬扛 skip 更可控。
别忽略索引和查询投影的影响
游标分页快不快,不只取决于是否用了 $gt,还看整个查询是否能走索引覆盖(IXSCAN + COVERED)。
用 explain("executionStats") 检查时重点关注:
-
executionStats.executionStages.stage === "IXSCAN"(而非 COLLSCAN 或 FETCH) -
executionStats.nReturned === executionStats.totalDocsExamined(说明没回表) - 如果返回字段多于索引字段,WiredTiger 会额外 fetch 文档,拖慢速度
建议显式指定投影,比如只查列表页需要的字段:
db.collection.find({ "_id": { "$gt": last_id } }, { projection: { "_id": 1, "title": 1, "createdAt": 1 } }).sort({ "_id": 1 }).limit(20)
尤其是字段含大文本、二进制时,省掉它们能显著降低网络和内存开销。
游标分页不是银弹——它解决了深度翻页的性能问题,但把状态管理(游标值传递、缓存一致性、边界处理)推给了业务层。最容易被忽略的是:**排序字段的单调性必须严格保证**。比如用 createdAt 分页时,若存在毫秒级相同时间戳的多条记录,$gt 可能跳过部分数据;此时要么加 _id 作为第二排序字段,要么接受少量重复/遗漏,视业务容忍度而定。











