用 sort() + limit() 做分页必须配合范围查询和复合索引,否则 skip() 万级后不可用;因 skip(n) 需扫描丢弃前 n 条,而范围查询利用索引直接跳转,复杂度 o(log n)。

直接结论:用 sort() + limit() 做分页没问题,但必须配合范围查询(如 $lt/$gt)和复合索引,否则 skip() 一上万就卡死,不是慢,是不可用。
为什么游标分页必须用范围查询,而不是 skip()
因为 skip(N) 不是“定位到第 N 条”,而是让 MongoDB 从头开始扫描、加载、再丢弃前 N 条文档。N 越大,CPU 和内存开销越爆炸——尤其当排序字段没走索引时,还会触发 inMemorySort,性能直接 O(N) 下滑。
而范围查询(比如 { createdAt: { $lt: "2024-01-01" } })能利用 B-tree 索引直接跳到目标位置,后续只读取 limit() 指定数量的文档,复杂度稳定在 O(log n)。
- 常见错误现象:
explain("executionStats")显示totalDocsExamined高达几十万,但nReturned只有 10 - 真实代价:第 1000 页比第 1 页慢 50 倍以上,且响应时间抖动剧烈
- 根本原因:MongoDB 执行顺序固定为
sort → skip → limit,中间环节无法跳过
复合索引怎么建才真正生效
索引字段顺序必须严格匹配查询模式,且要覆盖等值条件、排序字段、去重字段三者。推荐按 ESR 规则组织:Equality → Sort → Range(含 _id 去重)。
- 如果查询是
.find({ status: "active" }).sort({ createdAt: -1 }),索引应为db.collection.createIndex({ status: 1, createdAt: -1, _id: 1 }) - 不能只建
{ createdAt: -1 }:一旦并发写入导致多条记录时间相同,分页会漏数据或重复 - 不能把
_id放前面:它几乎无过滤能力,会大幅降低索引选择率 - 验证是否命中索引:
db.collection.find(...).explain("executionStats")中executionStages.stage应为IXSCAN,且totalDocsExamined === nReturned
游标分页的实际查询写法
核心是把上一页最后一条文档的排序字段值(如 createdAt 和 _id)作为下一页的查询起点。注意:必须用 $or 处理时间相同时的 _id 边界。
假设上一页最后一条是 { createdAt: ISODate("2024-01-01T10:00:00Z"), _id: ObjectId("60a1b2c3d4e5f67890123456") },下一页查询应写成:
db.collection.find({
$or: [
{ createdAt: { $lt: ISODate("2024-01-01T10:00:00Z") } },
{
createdAt: ISODate("2024-01-01T10:00:00Z"),
_id: { $lt: ObjectId("60a1b2c3d4e5f67890123456") }
}
]
}).sort({ createdAt: -1, _id: -1 }).limit(10)
- 前端必须传回两个值:
lastCreatedAt和lastId,不能只传页码 - 首次请求仍需完整排序 + limit,但后续全是范围查询,性能恒定
- 升序/降序方向必须和索引一致,否则索引失效(例如索引是
{ createdAt: -1 },查询却用1)
最容易被忽略的隐性前提
游标分页不是“开了索引就自动快”,它依赖几个硬性条件:
- 排序字段必须单调或近似单调(如时间戳、自增 ID),不能是用户可随意修改的字段(如
score) - 集合写入必须相对稳定;高并发更新排序字段会导致游标“跳跃”或“停滞”
- 客户端必须严格保存并传递上一页末尾的完整排序键,少一个字段(比如漏掉
_id)就会错位 - 如果业务需要支持“跳转任意页”,游标分页不适用,得换方案(比如预计算 + 缓存偏移量)











