基于游标的分页与偏移量分页 — 为何 page=2 会静默丢弃记录
偏移量分页存在一个可重现的错误:在页面请求之间插入的新行会静默地跳过记录。以下是证明该问题的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是逐步递增的,那么早期页面不会受到新插入的影响。
决定性问题:是否可以在请求之间在当前游标位置插入新行?如果可以,偏移分页会静默跳过记录。如果不行,那么它就没问题。 通知团队完成之前 偏移分页
比较
| 游标分页 | 在并发插入下的一致性 | |
|---|---|---|
| SQL | LIMIT x OFFSET y | WHERE (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使用正确的游标分页,使用 page 且 per_page 该游标( after 且 before:
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_after 且 ending_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
