示例说明
offset 分页在翻页间隙有新数据插入时会重复或跳过记录,深分页还需要扫描全部前序文档,性能与正确性双输。游标分页把查询条件锚定在上一页最后一条的排序键上,结果集永远从锚点之后开始,窗口滑动与数据变更天然解耦;_id 作为次级排序键保证排序键相同时仍有确定全序。
技术要点
- 复合索引与排序方向完全对应
- at 加 _id 构成无歧义全序
- 多取一条判断 hasMore 而非额外 count
代码解读
page 函数解析游标字符串还原时间与 ObjectId,$or 条件表达排在锚点之前的两种情况:时间更早,或时间相同但 _id 更小。查询多取一条来探测是否还有下一页,避免额外 count 查询;nextCursor 只在有更多数据时生成。
使用建议
排序键要选业务上单调且不易重复的字段,时间戳配自增语义最稳。游标要编码不透明化并加签名防客户端篡改。深翻页体验上应换成筛选或搜索而不是无限滚动。
代码示例
import mongoose from 'mongoose';
const EventSchema = new mongoose.Schema(
{ userId: Number, kind: String, at: Date },
{ timestamps: false },
);
EventSchema.index({ userId: 1, at: -1, _id: -1 });
const Event = mongoose.model('Event', EventSchema);
async function page(userId, cursor = null, limit = 20) {
const filter = { userId };
if (cursor) {
const [at, id] = cursor.split('|');
filter.$or = [
{ at: { $lt: new Date(at) } },
{ at: new Date(at), _id: { $lt: new mongoose.Types.ObjectId(id) } },
];
}
const docs = await Event.find(filter)
.sort({ at: -1, _id: -1 })
.limit(limit + 1);
const hasMore = docs.length > limit;
const items = hasMore ? docs.slice(0, limit) : docs;
const last = items[items.length - 1];
return {
items,
nextCursor: hasMore && last ? `${last.at.toISOString()}|${last._id}` : null,
};
}
const first = await page(42);
const second = await page(42, first.nextCursor);
console.log('page sizes:', first.items.length, second.items.length);