不能只用 skip() + limit() 做分页,因其性能线性下降且易导致漏/重数据;应采用游标分页,配合唯一排序字段(如 { createdAt: -1, _id: -1 })和覆盖索引。

为什么不能只用 skip() + limit() 做分页
因为数据实时写入时,skip() 会随着总文档数增长而线性变慢;更关键的是,如果中间有增删改,同一页面可能漏掉或重复显示文档。这不是 bug,是游标“无状态跳转”的本质决定的。
sort() 必须带唯一字段,否则排序不稳定
比如按 { createdAt: -1 } 排序,但多个文档 createdAt 相同(毫秒级时间戳在高并发下极易重复),MongoDB 就会按内部存储顺序补位——这个顺序不保证跨查询一致,导致翻页错乱。
- 推荐组合:
{ createdAt: -1, _id: -1 }——_id是 ObjectId,默认带时间戳且全局唯一,能兜底排序稳定性 - 如果业务有天然唯一键(如订单号
orderNo),优先用它代替_id,语义更清晰 - 避免用
{ name: 1, age: 1 }这类非唯一字段组合,除非你 100% 确保联合值不重复
游标分页实际怎么写:用上一页最后一条的排序值
不是算 skip = (page - 1) * size,而是记录上一页最后一条文档的排序键值,下一页查“比它更小(或更大)的前 N 条”:
- 升序分页(如按
createdAt: 1):下一页条件加{ createdAt: { $gt: lastCreatedAt } },再.sort({ createdAt: 1 }).limit(N) - 降序分页(如
createdAt: -1):用{ createdAt: { $lt: lastCreatedAt } },注意方向要反 - 复合排序(如
{ createdAt: -1, _id: -1 }):必须同时比较两个字段,例如{ $or: [ { createdAt: { $lt: lastCreatedAt } }, { createdAt: lastCreatedAt, _id: { $lt: lastId } } ] }
索引必须覆盖排序+查询字段
否则 sort() 会触发内存排序(errmsg: "Sort exceeded memory limit"),尤其数据量大时直接失败。
- 对
{ createdAt: -1, _id: -1 }分页,建复合索引:db.collection.createIndex({ createdAt: -1, _id: -1 }) - 如果查询还带条件(如
{ status: "active" }),索引要把查询字段放前面:{ status: 1, createdAt: -1, _id: -1 } - 用
explain("executionStats")检查是否命中索引、是否出现stage: "SORT"
真正麻烦的不是写法,而是排序键的设计——它得既是业务可解释的,又能在数据库层面提供确定性顺序。很多人卡在“为什么第 3 页突然少了条数据”,其实问题不在分页逻辑,而在排序字段本身就不够唯一。











