不喜欢广告? 无广告 今天

基于游标的分页与偏移量分页 — 为何 page=2 会静默丢弃记录

更新于

偏移量分页存在一个可重现的错误:在页面请求之间插入的新行会静默地跳过记录。以下是证明该问题的SQL语句,以及何时应改用游标分页。

偏移分页有一个可复现的缺陷:在页面请求之间插入的新行会静默跳过记录。以下是证明这一问题的SQL语句,以及何时应改用游标分页。
广告 移除?

你正在构建一个内容流。第一页加载,用户向下滚动,系统获取第二页内容。但一些在第一页加载时已存在的项目在第二页中却消失了。没有错误,没有警告——它们只是没有显示出来。

这是偏移分页的缺陷,它并不是竞争条件。它是确定性的。每一个使用 LIMIT x OFFSET y 实时、写入密集型表的系统最终都会遇到这个问题。

导致这一问题的SQL语句

偏移分页直接翻译为:

-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;

-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;

数据库不会记住第一页加载时的内容。每次请求第二页时,它都会从位置0开始计数,基于当前存在的行进行计数。

导致问题的场景

最初有20条帖子,ID为1到20,按最新时间排序。用户加载第一页——获取ID为20到11的帖子。在这次请求和下一次请求之间,有三条新帖子被插入:ID为21、22、23。

现在用户滚动并获取第二页:

SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1

帖子10、9和8——用户尚未看到的帖子——刚好被推过了偏移窗口。三条新插入的记录导致了恰好三条被跳过的记录。计算始终是精确的。

用户看不到任何问题。你的日志中也没有任何问题。这些记录确实存在于数据库中,只是对当前会话不可见。

游标分页解决了这个问题

不再通过位置计数,而是将下一页锚定在你实际返回的最后一条记录上。查询变为“给我在 这条特定记录之后的接下来10条记录”而不是“给我第11到第20条记录”。

-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11

-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
   OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;

新的插入不会影响这一点。它们会出现在排序顺序的最前面,位于游标位置之前。从游标开始的所有内容在并发写入的情况下都是稳定的。

游标实际上是什么

游标是最后一条返回记录的编码位置。在实践中,它通常是以下之一:

  • 一个编码的时间戳+ID组合 ——最常见的方法。将 {"created_at": "...", "id": 11} 序列化为一个base64字符串并返回它作为 next_cursor.
  • 一个UUID或不可读的行ID ——某些系统在时间有序的情况下直接使用主键(ULIDs、UUID v7)。一个 UUID生成器 在你决定使用UUID格式时非常有用——v4是随机的,不会按时间顺序排序,v7是基于时间的,会按时间顺序排序。
  • 一个签名令牌 ——Stripe的方法,防止客户端伪造可以跳转到任意位置的游标值。

偏移分页仍然适用的情况

并非所有场景都需要游标。当偏移分页适用的情况是:

  • 数据集是静态的或仅在底部追加。 归档页面、文档、导出队列。如果在排序顺序的顶部没有新数据插入,偏移分页保持稳定。
  • 带有明确页码的管理仪表板。 用户期望跳转到“第5页”并看到特定的切片。游标无法实现这一点——当你的锚点是行而不是计数时,不存在“第N页”的概念。
  • 数据量小且由你完全控制。 在内部工具中分页200条记录,新数据通过每日批处理作业到达?游标的复杂性并不值得。
  • 插入只发生在排序顺序的末尾。 如果你的分页是按ID升序排列,而新记录获得的ID是逐步递增的,那么早期页面不会受到新插入的影响。

决定性问题:是否可以在请求之间在当前游标位置插入新行?如果可以,偏移分页会静默跳过记录。如果不行,那么它就没问题。 通知团队完成之前 偏移分页

比较

游标分页在并发插入下的一致性
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
否——静默跳过记录是——位置稳定跳转到任意页面
否——仅支持向前遍历是(OFFSET = page * size)总页数
容易(无法在不进行完整扫描的情况下实现COUNT(*) / size)大偏移下的性能
性能下降——扫描10万行以丢弃它们 OFFSET 100000 稳定——始终使用索引范围扫描客户端复杂性
低——只需一个页码较高——必须存储并传递游标令牌GitHub和Stripe如何处理

GitHub和Stripe在其API中都使用基于游标的分页——并且有各自特定的理由。

GitHub的REST API在大多数端点上仍使用

GitHub

但其GraphQL API使用正确的游标分页,使用 pageper_page 该游标( afterbefore:

query {
  repository(owner: "vercel", name: "next.js") {
    issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
      pageInfo {
        endCursor
        hasNextPage
      }
      nodes {
        title
        number
      }
    }
  }
}

)被base64编码——解码后你得到一个内部行引用,而不是页码。在像next.js这样的仓库中,你的第一页和第二页请求之间可能有数十个问题被提交。使用偏移分页,这些记录会被静默跳过。Y3Vyc29yOnYyOpHOAABGPQ==Stripe的列表API使用

Stripe

——你传递你收到的最后一条对象的ID: starting_afterending_before Stripe的对象具有时间有序的ID(

# First page
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10

# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10   -d starting_after=ch_1ABC123def456

),因此ID本身可以作为游标。他们的API从未提供基于偏移的分页,因为账单数据正是沉默跳过问题变得严重的地方——一个被遗漏的收费不仅仅是用户体验问题。ch_, cus_, pi_实现游标的最小模式

以下是一个使用复合时间戳+ID游标的最小Node.js/PostgreSQL实现:

需要注意的一点是:如果你使用UUID作为主键而不是自增整数,复合排序只有在UUID是时间有序的情况下才有效。UUID v4是随机的——按

function encodeCursor(row) {
  return Buffer.from(JSON.stringify({
    created_at: row.created_at,
    id: row.id
  })).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}

async function fetchPage(db, cursor = null, limit = 10) {
  let rows;

  if (!cursor) {
    rows = await db.query(
      'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
      [limit]
    );
  } else {
    const { created_at, id } = decodeCursor(cursor);
    // PostgreSQL supports tuple comparison natively.
    // MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
    rows = await db.query(
      `SELECT * FROM posts
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`,
      [created_at, id, limit]
    );
  }

  const nextCursor = rows.length === limit
    ? encodeCursor(rows[rows.length - 1])
    : null;

  return { rows, nextCursor };
}

排序是有效的,但你失去了自增ID提供的排序保证。UUID v7或ULID是时间有序的,完全避免了这个问题。 (created_at, uuid_v4) 偏移分页的实现简单,直到数据是实时且写入密集的。一旦用户可以在分页请求之间触发插入操作——如帖子、账单、问题等——偏移分页会静默跳过记录。跳过的数量正好等于排序顺序顶部新插入的记录数量。没有错误,没有警告。

简要版本

游标分页以“跳转到第N页”为代价换取了位置的稳定性。对于面向用户的流和任何其他开发者将构建的API,这是正确的权衡。OFFSET适用于管理工具和静态导出——不要在无法发生错误的地方添加游标复杂性。

游标分页与偏移分页——为何第2页会静默跳过记录 2

想要享受无广告的体验吗? 立即无广告

安装我们的扩展

将 IO 工具添加到您最喜欢的浏览器,以便即时访问和更快地搜索

添加 Chrome 扩展程序 添加 边缘延伸 添加 Firefox 扩展 添加 Opera 扩展

记分板已到达!

记分板 是一种有趣的跟踪您游戏的方式,所有数据都存储在您的浏览器中。更多功能即将推出!

广告 移除?
广告 移除?
广告 移除?

新闻角 包含技术亮点

参与其中

帮助我们继续提供有价值的免费工具

给我买杯咖啡
广告 移除?