不能直接用 $skip + $limit 做深度分页,因为 $skip 会强制扫描并丢弃前 N 条文档,导致 CPU 和内存压力线性增长、响应时间从毫秒级升至秒级;仅适用于前 10 页等小页码场景。

为什么不能直接用 $skip + $limit 做深度分页
因为 $skip 会强制 MongoDB 扫描并丢弃前 N 条文档,跳过 10 万条时,它仍得把前 10 万条全读进内存再扔掉。CPU 和内存压力陡增,响应时间可能从毫秒级变成秒级。这不是“慢一点”,而是随着页码增大呈线性退化。
常见错误现象:aggregate([... $skip: 50000, $limit: 20]) 在第 2500 页开始明显卡顿;监控显示 executionTimeMillis 持续上涨;explain() 显示 totalDocsExamined 高达几十万。
- 只在页码很小时(比如前 10 页)才可接受
$skip/$limit - 必须配合
$match尽早过滤,否则$skip是在原始集合上跳,不是在过滤后结果上跳 - 不要在
$group后直接$skip—— 分组结果集通常很小,但若分组前没过滤,代价已在上游付出了
$facet 一次性拿到数据和总数,但要注意内存限制
$facet 允许你在单次聚合中并行执行多个子管道,典型用法是:一个子管道取分页数据($skip+$limit),另一个子管道只做 $count。这样避免了两次 round-trip,也省去应用层额外查总数的开销。
但关键限制是:$facet 的所有子管道输出必须能放进 100MB 内存(默认),且不能有 $sort 后跟大 $skip —— 因为排序结果要全驻留内存才能跳。
[
{ $match: { status: "active", createdAt: { $gte: ISODate("2026-01-01") } } },
{ $facet: {
"data": [ { $sort: { createdAt: -1 } }, { $skip: 40 }, { $limit: 20 } ],
"total": [ { $count: "count" } ]
}
}
]
- 务必把
$match放在$facet外面,否则两个子管道都会重复扫描全量数据 - 如果
total子管道只计数,就别加$sort——$count不关心顺序 - 当预估总数超 10 万且需频繁分页时,
$facet可能因内存溢出失败,此时应切回游标分页
游标分页(cursor-based)才是大数据集的标配
真正 scalable 的方案不是“第几页”,而是“从某条记录之后取下 N 条”。核心是利用索引字段(如 _id、createdAt)做范围查询,完全避开 $skip。
例如,上一页最后一条文档是 { _id: ObjectId("..."), createdAt: ISODate("2026-09-05T10:20:00Z") },下一页查询就是:
[
{ $match: {
createdAt: { $lt: ISODate("2026-09-05T10:20:00Z") },
status: "active"
}
},
{ $sort: { createdAt: -1 } },
{ $limit: 20 }
]
- 必须确保
sort字段有索引,且$match条件能命中该索引前缀(例如复合索引{ createdAt: -1, status: 1 }) - 游标值必须唯一且稳定——用
_id最安全;若用时间戳,需叠加_id防止并发写入导致重复或遗漏:{ createdAt: { $lt: ... }, _id: { $lt: ObjectId("...") } } - 无法跳转到任意页(比如直接点“第 100 页”),但对无限滚动、下拉加载等场景更高效、更实时
总数统计要不要?多数业务其实不需要精确总数
用户看到“共 238,412 条”并不会提升体验,反而拖慢首屏。更务实的做法是:
- 前端显示“已加载 20 条,还有更多…” 或 “正在加载…”
- 只在管理后台等少数场景查总数,且用
countDocuments()(带查询条件)而非聚合$count,前者走索引更快 - 如果非要用聚合拿总数,确保
$match足够严格,且避开$unwind、$lookup等高开销阶段后再计数 - 对超大数据集,考虑采样估算(如
$sample配合比例推算),误差 5% 换取 10 倍性能提升
最容易被忽略的一点:分页逻辑和总数统计,从来不是同一个性能瓶颈。前者卡在排序+跳过,后者卡在全量扫描。把它们揉进一个聚合里,等于主动把两个问题绑在一起解决——往往哪边都解决不好。

















