skip+limit 深分页必然变慢,根本原因是 mongodb 需逐条扫描丢弃前n条;应改用基于排序字段的游标查询,如\_id 或 {createdat, \_id} 复合游标实现高效分页。

skip + limit 在页码变深时必然变慢,这不是写法问题,是 MongoDB 查询模型决定的——它必须逐条扫描并丢弃前 N 条匹配文档。真正可行的优化,是彻底放弃页码,改用基于排序字段的范围游标查询。
为什么 skip() 在分片集群里特别慢
在分片环境下,skip(N) 不是“跳过前 N 条”,而是每个 shard 都要各自执行 skip(N) + limit(M),再由 mongos 合并结果。N 越大,每个 shard 扫描的数据越多,网络传输和内存压力越重;若某 shard 实际只存了 5 条匹配数据,它仍要扫描几千条来凑够 skip(1000) 的数量。
- 分片键与查询条件不匹配(如分片键是
{ userId: 1 },但分页查的是全站sort({ createdAt: -1 })),所有 shard 都会被广播查询,skip完全无法下推 - 即使加了索引,
skip仍需遍历索引项并计数,I/O 和 CPU 开销随偏移量线性增长 - 返回空结果时,
skip仍会完整执行,浪费资源
用 _id 游标实现连续分页(最简可靠方案)
前提是使用默认 ObjectId 类型的 _id 字段——它天然唯一、单调递增、不可变,且写入时间隐含在其中。只要按 _id 升序或降序分页,就能避开排序和重复问题。
- 第一页:
collection.find({}).sort({ _id: 1 }).limit(20) - 后续页:取上一页最后一条的
_id值(记为last_id),查collection.find({ _id: { $gt: last_id } }).sort({ _id: 1 }).limit(20) - 必须确保
_id字段有索引(默认已有),且不手动修改或重写_id(删+插等于破坏连续性) - 如果返回不足 20 条,说明到底了;不要 fallback 到
skip()补足,那会回到低效模式
用复合时间戳 + _id 实现精确时间分页
当业务要求按 createdAt 排序(比如动态流、日志列表),单靠时间字段会因重复值导致漏/重数据,必须组合 _id 消除歧义。
- 索引必须建为
{ createdAt: -1, _id: -1 },且sort()顺序严格一致 - 下一页查询逻辑:
find({ $or: [ { createdAt: { $lt: last.createdAt } }, { createdAt: last.createdAt, _id: { $lt: last._id } } ] }).sort({ createdAt: -1, _id: -1 }).limit(20) - 注意:如果
createdAt是用户可控字段(如前端传入),需校验其合理性,避免被恶意构造导致游标失效 - 该方案在分片环境下依然有效,但前提是查询能命中分片键——否则仍是全 shard 广播
C# 和 Python 中游标分页的典型陷阱
驱动层不会自动帮你规避 skip,关键在你写的查询逻辑是否真绕开了它。
- C# 中
Find().Skip(n).Limit(m)看似链式调用,但只要用了Skip(),底层就还是走传统路径;应改用FilterBuilder构造$gt条件 - Python PyMongo 里
collection.find(...).skip(n)同理,别被语法糖迷惑;要用find({"_id": {"$gt": last_id}})显式表达起点 - 前端传来的游标值(如
last_id)必须做类型校验:ObjectId字符串要能被正确解析,否则查不到任何数据且无报错 - 并发写入场景下,新插入文档的
_id可能比当前游标小但尚未落库——这是最终一致性下的正常现象,前端应接受“可能少一条”,而非强求绝对连续
游标分页真正的难点不在写法,而在业务语义对齐:你得确认“下一页”在用户心智中是否允许跳过极短时间窗口内的新数据;以及后端能否稳定提供那个“最后一条”的完整字段值(尤其是复合游标场景)。一旦这两点没对齐,再漂亮的代码也会在上线后暴露一致性裂缝。











