游标分页($gt + _id)比skip()快一个数量级且性能不退化,因skip()需从头扫描前N条文档,而游标直接定位起始位置;百万级数据下skip()耗时随页码线性增长,游标则稳定高效。

直接结论:用 $gt + _id(或时间戳字段)做游标分页,比 skip() 快一个数量级,且不随页码增长而退化。
为什么 skip() 在百万级数据后会越来越慢
MongoDB 的 skip() 不是“跳过索引位置”,而是让游标从头开始遍历、逐条计数跳过。即使有索引,它仍要扫描前 N 条匹配结果——N 越大,CPU 和 I/O 开销越明显。官方文档明确指出:cursor.skip() requires the server to scan from the beginning of the input results set。
- 10 万数据查第 1000 页(
skip(9990)):约 11 秒 - 同数据量用游标法(
_id > ObjectId("...")):约 0.89 秒 - 跳过 86 万条记录时,
skip()耗时飙升至 37 秒以上
如何实现 Search After 模式(即游标分页)
核心是把“第几页”转换成“从哪条之后取”。需满足两个前提:排序字段唯一、有序(推荐 _id 或带索引的 createdAt)。
- 第一步:确保排序字段有索引,例如
db.users.createIndex({createdAt: 1})或复用默认_id索引 - 第二步:首次查询不带游标,但必须按排序字段升序/降序固定,例如
db.users.find().sort({_id: 1}).limit(20) - 第三步:取响应中最后一条文档的
_id值(如ObjectId("65a1b2c3d4e5f67890123456")),作为下一页的起点 - 第四步:后续请求改用范围查询:
db.users.find({_id: {$gt: ObjectId("65a1b2c3d4e5f67890123456")}}).sort({_id: 1}).limit(20)
注意:升序用 $gt,降序用 $lt;若排序字段非唯一(如 createdAt),需加复合条件防漏/重,例如 {createdAt: {$gt: ISODate("...")}, _id: {$gt: ObjectId("...")}}。
实际使用中容易踩的坑
游标分页不是“开箱即用”,几个关键细节决定是否真正生效:
- 前端必须保存并透传上一页最后一条的
_id(或复合游标值),不能只传页码;API 接口设计应接受lastId或cursor参数,而非page和size - 如果业务要求支持“跳转任意页码”,游标法无法直接满足——此时可折中:对前几百页仍用
skip()(加缓存),靠后的页强制引导为“加载更多”模式 - 删除或插入会影响游标连续性,但不影响正确性;只是可能导致某页少 1 条或多 1 条(因新插入文档可能挤进当前游标区间),这是最终一致性下的合理代价
- 聚合管道中用
$skip同样低效,应避免;如需在聚合后分页,先$sort+$limit,再用外部游标控制
什么时候还不得不回到 skip()
不是所有场景都适合游标分页。以下情况需谨慎评估:
- 需要精确总数(
countDocuments()在大数据集上本身也慢,且与游标分页逻辑冲突) - 排序字段无索引或高重复率(如按状态字段分页),会导致范围查询返回不稳定条数
- 前端 UI 强依赖页码输入框(如后台管理列表),且无法改造交互逻辑
- 集合启用了分片,且
shard key与分页排序字段不一致,mongos 合并结果时仍可能触发全局 skip
真正难处理的,从来不是“怎么写游标查询”,而是怎么让前后端协作把游标值可靠传递、持久化、失效清理——这部分往往比数据库调优更花时间。


















