MongoDB 的 skip() 超过 10 万变慢是因为存储引擎需从头线性扫描并逐条计数过滤,无法跳过前 N 条;索引仅加速匹配,不加速跳过,导致高偏移量下 CPU 和 I/O 浪费严重。

为什么 skip() 超过 10 万就明显变慢
MongoDB 的 skip() 不是跳过内存里的前 N 条,而是让存储引擎从头扫描、逐条计数过滤——哪怕你只想要第 100001 条。索引能加速匹配,但无法跳过计数过程;一旦偏移量大,CPU 和磁盘 I/O 都在为“丢弃”数据干活。
- 即使有复合索引(如
{status: 1, created_at: -1}),skip(100000)仍需定位到排序后的第 100001 个位置,本质是线性推进 - 文档体积越大、查询条件越宽泛(比如只按
status过滤),实际扫描的文档数可能远超skip值 - 副本集上,主节点执行完再同步结果,延迟放大更明显
用延迟关联(Deferred Join)绕过 skip
核心思路:先用高效查询拿到目标页的 _id 列表(轻量),再用这些 _id 批量回查完整文档。避免在主查询里做深度跳过。
- 第一步查 ID:
db.orders.find({status: "shipped"}, {_id: 1}).sort({created_at: -1}).skip(100000).limit(20)—— 只返回_id,字段少、内存占用低、索引覆盖充分 - 第二步查详情:
db.orders.find({_id: {$in: [ObjectId("..."), ...]}})—— 利用_id索引快速定位,不依赖排序或跳过 - 注意:
$in数组长度建议控制在 1000 以内,否则可能触发查询计划退化;超限时可分批或改用聚合管道$lookup模拟
Seek Pagination(游标分页)彻底去掉 skip
放弃“第 N 页”思维,改用“从某条记录之后取下一页”。只要用户不跳页、不输页码,这是最稳的方案。
- 关键依赖:排序字段必须有唯一性或足够区分度。推荐组合
{created_at: -1, _id: -1},避免时间相同导致顺序不确定 - 翻页时传上一页最后一条的游标值,例如:
db.orders.find({$or: [{created_at: {$lt: ISODate("2024-05-01T12:00:00Z")}}, {created_at: ISODate("2024-05-01T12:00:00Z"), _id: {$lt: ObjectId("...")}}]}).sort({created_at: -1, _id: -1}).limit(20) - 前端必须保存上一页末尾的
created_at和_id,不能只记一个;否则时间重复时会漏/重数据 - 不支持跳转到任意页码(比如直接点“第 87 页”),这是设计取舍,不是 bug
聚合管道里用 $facet + $skip 依然很慢?
有人想用 $facet 同时查总数和分页数据,但里面套 $skip 并没解决问题——$facet 的每个分支仍是独立执行,total 分支的 $count 很快,但 data 分支的 $skip 还是得扫一遍。
- 真正有效的做法是:把
$skip替换成$expr+ 游标条件,或者干脆拆成两个独立查询(一个查游标范围内的 ID,一个查总数) - 如果非要用聚合,优先考虑
$lookup关联 ID 列表,而不是在主 pipeline 里$skip - 聚合阶段越多、文档越宽,内存压力越大;
allowDiskUse: true能防 OOM,但磁盘临时文件本身也拖慢响应
游标分页看着多传两个字段,其实省掉了最难扛的随机跳转成本;而延迟关联适合管理后台那种允许稍慢但必须支持任意页码的场景。选哪个不取决于“高级不高级”,取决于你敢不敢让用户不输页码、不点数字跳转。











