0Pricing
MongoDB Academy · 课时

使用范围查询实现键集分页

您将基于 _id 或时间戳字段的范围筛选器构建基于游标的分页,实现稳定的 O(log n) 页面性能。

使用范围查询实现键集分页 是 CoddyKit 上的免费 MongoDB Academy 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 MongoDB Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 MongoDB Academy 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

What Is Keyset Pagination?

Keyset pagination—also called cursor pagination—avoids skip() entirely by using a range query on the sort key. Instead of telling MongoDB 'jump over the first N documents', you tell it 'give me documents where the sort key is greater than the last value I saw'. This is always O(log n) because it uses an index range scan, regardless of how far into the result set you are.

The Core Concept: A Range Filter as a Cursor

After fetching the first page, you remember the sort key value of the last document returned. For the next page, you filter documents where the sort key is strictly greater than (or less than, for descending) that remembered value. This filter combined with an index gives MongoDB an exact starting point—no skipping needed.

// First page — no cursor needed
const page1 = await db.collection('posts')
  .find({ isPublished: true })
  .sort({ createdAt: -1, _id: -1 })
  .limit(20)
  .toArray();

// Remember the last document's sort keys
const lastCreatedAt = page1[page1.length - 1].createdAt;
const lastId        = page1[page1.length - 1]._id;

Fetching the Next Page With a Range Query

Use the remembered sort key values in a $lt (or $gt for ascending) condition for the next page query. No skip() is needed—the range condition navigates the index directly to the right starting position. MongoDB fetches limit documents starting from that point.

// Next page: posts older than the last one seen
// Descending by createdAt means 'older' = $lt
const page2 = await db.collection('posts')
  .find({
    isPublished: true,
    $or: [
      { createdAt: { $lt: lastCreatedAt } },
      { createdAt: lastCreatedAt, _id: { $lt: lastId } }  // tiebreaker
    ]
  })
  .sort({ createdAt: -1, _id: -1 })
  .limit(20)
  .toArray();

Why Include _id as a Tiebreaker?

Multiple documents may have the same createdAt timestamp (e.g., many items inserted in the same second). Without a tiebreaker, the range boundary is ambiguous and you might skip or duplicate documents at the boundary. Adding _id as a secondary sort field and including it in the range condition makes the cursor uniquely deterministic—no document can have the same (createdAt, _id) pair.

// Compound index to support the keyset query
db.posts.createIndex({ createdAt: -1, _id: -1 });
// This index covers both the sort and the range filter

Keyset Pagination on _id Alone

If you sort purely by _id (default insertion order), keyset pagination is the simplest possible form. _id is always unique and already indexed. Each page returns documents where _id is greater than the last seen value. This works perfectly for feed-style queries where insertion order is the natural sort.

// First page
const page1 = await db.collection('events')
  .find({})
  .sort({ _id: 1 })
  .limit(50)
  .toArray();

const lastId = page1[page1.length - 1]._id;

// Next page — range filter on _id
const page2 = await db.collection('events')
  .find({ _id: { $gt: lastId } })
  .sort({ _id: 1 })
  .limit(50)
  .toArray();

Encoding the Cursor for API Responses

API clients should not need to know the internal cursor format. Encode the cursor as a Base64 or JWT string that the server can decode on the next request. This hides the implementation detail (whether you use createdAt, _id, or a composite key) from clients and lets you change the cursor format without breaking the API contract.

// Encode cursor
function encodeCursor(doc) {
  return Buffer.from(JSON.stringify({ createdAt: doc.createdAt, _id: doc._id })).toString('base64');
}

// Decode cursor
function decodeCursor(token) {
  return JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
}

// API response
const nextCursor = page.length === PAGE_SIZE ? encodeCursor(page[page.length - 1]) : null;
res.json({ data: page, nextCursor });

Keyset Pagination in an Express Handler

A complete keyset pagination handler decodes the incoming cursor (if provided), builds the range filter, runs the query, encodes the next cursor, and returns the response. If there is no next cursor to return (the page is smaller than the page size), the client knows it has reached the last page.

async function listPosts(req, res) {
  const limit = 20;
  let filter = { isPublished: true };

  if (req.query.cursor) {
    const { createdAt, _id } = decodeCursor(req.query.cursor);
    filter['$or'] = [
      { createdAt: { $lt: new Date(createdAt) } },
      { createdAt: new Date(createdAt), _id: { $lt: _id } }
    ];
  }

  const posts = await Post.find(filter).sort({ createdAt: -1, _id: -1 }).limit(limit).lean();
  const nextCursor = posts.length === limit ? encodeCursor(posts[posts.length - 1]) : null;

  res.json({ data: posts, nextCursor });
}

Keyset vs Offset: Performance Comparison

Imagine a collection with 1,000,000 posts. Offset pagination to page 1000 (20 items/page) executes skip(19980)—MongoDB walks 19,980 index entries. Keyset pagination uses a range filter: { createdAt: { $lt: someDate } }—MongoDB does a binary search on the index to find the starting point and scans exactly 20 entries. The difference at scale: milliseconds vs seconds.

Limitations of Keyset Pagination

Keyset pagination has two notable limitations: (1) you cannot jump to an arbitrary page number—you can only go forward or backward one page at a time; (2) the sort field must be part of the cursor, so sorting by non-unique, non-indexed fields requires careful tiebreaker selection. These trade-offs make keyset pagination unsuitable for applications that require page-number navigation, but it is the correct choice for infinite scroll and API cursor patterns.

Bidirectional Keyset Pagination

To support both 'next page' and 'previous page' navigation, store both the cursor for the first document and the cursor for the last document on each page. Use $gt with the first document's cursor to go backward. Reverse the sort direction for the backward query, then re-reverse the results before returning them.

// Previous page — documents newer than the first item on the current page
const prevPage = await db.collection('posts')
  .find({
    isPublished: true,
    createdAt: { $gt: firstDocCreatedAt }
  })
  .sort({ createdAt: 1, _id: 1 })  // reverse sort for previous page
  .limit(20)
  .toArray();

prevPage.reverse(); // flip back to descending display order

Index Design for Keyset Pagination

The compound index for a keyset pagination query should include: filter fields first (equality conditions), then the sort fields. For example, if you filter by isPublished and sort by createdAt DESC, _id DESC, the ideal index is { isPublished: 1, createdAt: -1, _id: -1 }. This index covers the equality filter and the range sort without any in-memory operations.

// Ideal covering index for keyset pagination on posts
db.posts.createIndex({ isPublished: 1, createdAt: -1, _id: -1 });

// Verify with explain — expect IXSCAN, no SORT stage
db.posts.find({ isPublished: true, createdAt: { $lt: new Date() } })
  .sort({ createdAt: -1, _id: -1 })
  .limit(20)
  .explain('executionStats');

Quick Check

Test your understanding of MongoDB & NoSQL Databases concepts from this lesson.

Lesson Recap

In this lesson you learned: keyset pagination uses a range filter on the last seen sort key instead of skip(), this achieves O(log n) performance regardless of page depth, and including _id as a tiebreaker prevents duplicate or missing documents at sort boundaries. Next up we practice combining sort, skip, limit, and projections into a complete query chain.

常见问题解答

「使用范围查询实现键集分页」课时是免费的吗?

是的 — 「使用范围查询实现键集分页」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 MongoDB Academy 课程的其余内容,请升级到 CoddyKit PRO。 MongoDB Academy 课程共包含 4 节课。

「使用范围查询实现键集分页」这节课中我会学到什么?

您将基于 _id 或时间戳字段的范围筛选器构建基于游标的分页,实现稳定的 O(log n) 页面性能。 你通过在浏览器中直接运行的动手代码来练习 MongoDB Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 MongoDB Academy 需要有经验吗?

无需任何先前经验。CoddyKit 上的 MongoDB Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。

「使用范围查询实现键集分页」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 MongoDB Academy 课中编写并运行代码吗?

能。每节 MongoDB Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 使用 sort() 按多个键排序
  2. Skip 和 Limit:偏移分页
  3. 使用范围查询实现键集分页
  4. 组合排序、Skip、Limit 和投影
← 返回 MongoDB Academy