← 中文资源

简体中文版本

MongoDB 游标分页查询

基于复合排序键做稳定的 MongoDB 游标分页:排序键加 _id 兜底保证全序,下一页以上一页末尾键为起点,杜绝偏移漏页。

阅读英文原文

示例说明

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);