游标分页需用严格递增、全局唯一、写入即确定的字段(如seq_id或_id),索引须按等值→排序→去重顺序创建,查询须用$gt/$lt及完整复合条件,前端传游标值而非页码,且类型必须与索引字段完全一致。

分页字段必须单调、唯一、可索引
直接用 title 或 status 做分页主键几乎必然出错——它们重复率高、无序、常不建索引。真正能撑起游标分页的字段得满足三点:值严格递增(或递减)、全局唯一、写入即确定。最稳妥的是主动加一个 seq_id 字段,类型用 NumberLong,插入时自增或由服务端生成;次选是 _id(ObjectId 时间戳部分可控但不绝对可靠),或强约束的 created_at(必须由服务端写入、统一时区、毫秒级精度)。
复合索引必须覆盖查询条件 + 排序字段 + 去重字段
只给 created_at 建单字段索引没用,尤其当查询带 {status: "active"} 这类等值条件时。正确索引要按“等值字段 → 排序字段 → 去重字段”顺序声明:
db.collection.createIndex({status: 1, created_at: -1, _id: 1})- 查询必须严格匹配该顺序:
find({status: "active"}).sort({created_at: -1, _id: 1}) - 漏掉
_id,同秒创建的多条文档会导致下一页重复或跳过 - 如果排序方向写成
{created_at: 1},而索引是-1,MongoDB 就无法走索引
游标查询条件必须用 $gt / $lt,不能用 $gte / $lte
假设上一页最后一条是 {created_at: ISODate("2026-07-20T10:00:00Z"), _id: ObjectId("...")},下一页查询必须写成:
db.collection.find({
$or: [
{ created_at: { $lt: ISODate("2026-07-20T10:00:00Z") } },
{ created_at: ISODate("2026-07-20T10:00:00Z"), _id: { $lt: ObjectId("...") } }
]
}).sort({ created_at: -1, _id: -1 }).limit(10)
用 $gte 会把边界那条重复返回;单独用 created_at 不加 _id 复合判断,时间相同就乱序。
前端传参必须传游标值,不是页码
客户端不能传 page=5,而要传上一页最后一条的完整排序键值,比如:
- JSON body 中带
{"cursor": "2026-07-20T10:00:00Z|60a1b2c3d4e5f67890123456"} - 后端解析时按
|拆开,分别转成Date和ObjectId - 首次请求没游标,就查
find({status: "active"}).sort({created_at: -1, _id: -1}).limit(10) - 服务端返回数据时,顺手把最后一条的
created_at和_id拼成新游标,塞进响应 header 或 response body 的next_cursor字段里
最容易被忽略的是:游标值必须和索引字段类型完全一致,比如 created_at 存的是 ISODate,就不能传字符串格式的时间戳过去再 parse —— 类型不匹配会让索引失效。

















