现代 API 的分页模式
面向现代 API,比较 offset、cursor、keyset、page token 和 Relay GraphQL 分页,并附 TypeScript 与 SQL 示例。
对于新的列表端点,默认应采用基于键集查询(keyset query)的游标分页——无论数据集大小如何,它都能提供接近恒定的查询时间,并且在页面获取之间发生行插入或删除时依然保持稳定。仅在以下情况下才使用偏移量分页:用户需要跳转到任意页码、数据集较小且更新不频繁,或者总页数是 UI 的硬性需求。这一决策取决于偏移量分页无法规避的两种失效模式:深偏移量下的线性性能下降,以及在数据遍历过程中发生变更时导致条目重复或跳过的滑动窗口问题。本文涵盖该系列的所有模式——偏移量、游标、键集、页面令牌以及 Relay 风格的 GraphQL 连接——并配有可运行的 TypeScript 代码及其背后的 SQL 实现。
核心要点
- 游标(cursor)与键集(keyset)并非同义词:游标是 API 返回给客户端的不透明令牌;键集是 SQL 技术——一种复合
WHERE (sort_col, id) < (val, id)谓词——通过让数据库直接定位索引而非扫描并丢弃行,使游标分页变得高效。 - 带有
OFFSET 500000 LIMIT 20的查询并不会跳过 500,000 行;PostgreSQL 会在返回 20 行之前先读取并丢弃这些行,因此无论排序列上是否存在索引,查询时间都会随偏移量值的增大而增长。 - 当行在页面获取之间被插入时,偏移量分页会发生窗口偏移:客户端会收到重复条目,同时悄无声息地跳过另一条目,且响应中不会出现任何错误。
- 在每次响应中返回
total_count会强制执行一次额外的COUNT(*);更好的做法是返回has_next_page,通过获取limit + 1行并检查额外行是否存在来低成本地推导该值。 - 游标编码的是特定有序过滤结果集中的位置,因此每当排序或过滤参数发生变化时,必须丢弃游标并重置到第一页。
为什么分页策略至关重要:两种失效模式
分页策略之所以重要,是因为最朴素的默认方案——LIMIT 加 OFFSET——存在两种结构性失效模式,只有在大规模场景或并发写入时才会显现。第一种是性能问题:深偏移量会迫使数据库读取并丢弃大量行。第二种是一致性问题:偏移量是基于位置的,因此页面获取之间的任何插入或删除都会导致窗口偏移,进而破坏结果集。这两个问题在小型静态表中都不会出现,这也是为什么偏移量分页在开发环境中表现良好,却在生产环境中出现问题。
以下是本文其余部分所要论证的对比表:
| 模式 | 随机页面访问 | 写入下的稳定性 | 深度时的数据库性能 | 总数量 | 最适用于 |
|---|---|---|---|---|---|
| 偏移量 / 限制 | 是 | 否 | 线性下降 | 较低成本(额外查询) | 小型静态数据、管理表、需要页码的搜索 UI |
| 游标(API 层面) | 否 | 是 | 使用键集时为常数 | 省略;使用 has_more | 无限滚动、信息流、公共列表 API |
| 键集(数据库技术) | 否 | 是 | 常数(索引定位) | 困难 | 游标 API 的底层实现 |
| 页面令牌 | 否 | 是 | 取决于实现 | 可选 | 希望隐藏并演进其策略的 API |
| Relay 连接 | 否 | 是 | 使用键集时为常数 | 可选 | 使用 connections 规范的 GraphQL 客户端 |
右侧两列——性能与稳定性——正是区分各模式的关键所在。以下内容将逐一解释原因。
偏移量分页:滑动窗口如何导致问题
Discover how at OpenReplay.com.
偏移量分页使用 page 和 limit(或 offset 和 limit)来计算数值位置:offset = (page - 1) * limit。它是唯一支持跳转到任意页码的模式,因此适用于用户直接点击”第 7 页”的管理表和搜索结果场景。其弱点均源于这种基于位置的寻址方式。
性能弱点是有据可查的行为,而非实现上的怪癖。根据 PostgreSQL 关于 LIMIT 和 OFFSET 的文档,“被 OFFSET 子句跳过的行仍然需要在服务器内部计算。“带有 OFFSET 500000 LIMIT 20 的查询并不会跳过 500,000 行——数据库会在返回 20 行之前先读取并丢弃这些行,这意味着无论排序列上是否存在索引,查询时间都会随偏移量值的增大而增长。这一语义在近期各主要 PostgreSQL 版本(16 至 18)中均未发生变化。
一致性弱点体现为滑动窗口问题。当行在页面获取之间被插入到信息流顶部时,偏移量分页会发生窗口偏移:第 N+1 页偏移量 offset 处的条目,正是第 N 页偏移量 offset - 1 处的条目,因此客户端会收到重复条目——而落在边界之外的条目则被悄无声息地跳过,响应中不会出现任何错误。删除操作则产生镜像效果:窗口收缩,导致某个条目被跳过。
以下是一个使用 TypeScript、Express 和 pg 8.22.x 在 Node 24(Active LTS)上运行的正确偏移量端点实现:
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
import express from "express";
import { Pool } from "pg";
const pool = new Pool();
const app = express();
interface OffsetPage<T> {
data: T[];
pagination: {
page: number;
limit: number;
has_next_page: boolean;
};
}
function clampLimit(raw: unknown): number {
const n = Number(raw) || 20;
return Math.min(Math.max(Math.trunc(n), 1), 100); // clamp 1–100
}
app.get("/posts", async (req, res) => {
const limit = clampLimit(req.query.limit);
const page = Math.max(Number(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
// Fetch limit + 1 to learn has_next_page without a COUNT query
const { rows } = await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2`,
[limit + 1, offset]
);
const has_next_page = rows.length > limit;
const data = rows.slice(0, limit);
const body: OffsetPage<typeof data[number]> = {
data,
pagination: { page, limit, has_next_page },
};
res.json(body);
});
这里有两个细节值得关注。clampLimit 函数将页面大小上限设为 100,防止客户端请求 limit=1000000 从而耗尽服务器资源。而获取 limit + 1 行则允许端点在不执行第二次 COUNT(*) 查询的情况下报告 has_next_page——这与下文游标实现中使用的技巧相同。如果额外的行存在,则说明至少还有一页。
游标分页:API 层面
游标分页用一个不透明令牌替代数值偏移量,该令牌标记结果集中的固定位置。客户端将令牌回传以获取下一页,由于令牌锚定的是行的实际排序值而非计数,表中其他位置的插入和删除不会破坏窗口。其代价是失去随机页面访问能力:不存在”第 7 页”,只有”此游标之后的页面”。
游标与键集并非同义词,混淆两者是分页相关文章中最常见的错误。游标是 API 返回给客户端的不透明令牌,隐藏了分页策略;键集是 SQL 技术——一种复合 WHERE (sort_col, id) < (val, id) 谓词——通过让数据库使用索引定位而非扫描并丢弃行,使游标分页变得高效。你可以在游标 API 底层使用偏移量查询(不推荐——会继承深偏移量的性能下降问题),也可以使用键集查询(推荐)。游标是接口,键集是高效的引擎。
令牌本身只是一个编码后的位置——通常是排序列和一个用于打破平局的字段,经过 base64 编码后客户端将其视为不透明值,不依赖其内部结构。保持其不透明性,这样你以后可以更改其内部内容而不会在遍历过程中破坏客户端。
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
interface CursorPage<T> {
data: T[];
pagination: {
next_cursor: string | null;
has_more: boolean;
};
}
type Cursor = { created_at: string; id: number };
function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(raw: string): Cursor {
return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}
app.get("/feed", async (req, res) => {
const limit = clampLimit(req.query.limit);
const cursor = req.query.cursor
? decodeCursor(String(req.query.cursor))
: null;
// Keyset predicate: compound row comparison on the sort tuple
const { rows } = cursor
? await pool.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor.created_at, cursor.id, limit + 1]
)
: await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1`,
[limit + 1]
);
const has_more = rows.length > limit;
const data = rows.slice(0, limit);
const last = data[data.length - 1];
const body: CursorPage<typeof data[number]> = {
data,
pagination: {
has_more,
next_cursor:
has_more && last
? encodeCursor({ created_at: last.created_at, id: last.id })
: null,
},
};
res.json(body);
});
该端点将游标解码为 (created_at, id) 元组,针对其执行键集查询,获取 limit + 1 行以计算 has_more,并从最后返回的行中编码下一个游标。无需 COUNT(*),无需偏移量,无需扫描被丢弃的行。
键集分页:复合谓词
键集分页是使游标分页变得高效的数据库技术:它不使用偏移量,而是通过复合谓词对排序键本身进行过滤。PostgreSQL 支持行值比较,因此 (created_at, id) < ($1, $2) 会按字典序比较该元组,并允许匹配的复合索引直接定位到边界位置。
平局打破字段不是可选的。仅凭 created_at 并不唯一——两篇文章可能共享同一毫秒级时间戳——而对非唯一列的排序会产生不确定的顺序,这意味着锚定到 created_at 的游标可能会跳过或重复共享该值的行。追加主键(id)使排序元组唯一,边界也因此变得确定。
-- PostgreSQL 18
-- Composite index matching the sort tuple, including its direction.
CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);
-- Keyset query: compound predicate seeks into the index.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
索引方向至关重要。根据 PostgreSQL 关于索引与 ORDER BY 的文档,B 树索引可以双向扫描,但将索引的声明顺序与查询的 ORDER BY 匹配,可以让查询计划器通过单次索引扫描同时满足谓词和排序需求。对两种形式在大型表上执行 EXPLAIN ANALYZE,可以在执行计划中清晰看到结构差异:OFFSET 500000 查询显示服务器在计算并丢弃前导行,而键集查询则显示启动成本极低的索引扫描,仅返回所需的行。这种成本差异是结构性的,而非偶然的——它直接源于上文提到的 OFFSET 语义。
页面令牌:隐藏策略
页面令牌是游标分页的不透明策略变体,最典型的应用见于 Google Cloud 的 API 设计指南。根据 AIP-158,请求携带 page_size 和 page_token,响应返回 next_page_token,且规范要求页面令牌对客户端保持不透明。将完整的分页状态编码在服务端,使服务器可以在不破坏正在遍历的客户端的情况下更改其内部策略——例如从偏移量切换到键集。
Stripe 经常与 Google 一起被提及,但两者并非相同的机制,这一区别值得厘清。根据 Stripe 分页文档,Stripe 的列表 API 使用对象 ID 游标(starting_after 和 ending_before,配合 limit 和 has_more 标志),而非不透明页面令牌;其搜索 API 才是返回不透明 next_page 令牌的那个。因此,准确的梳理如下:Google Cloud 使用不透明页面令牌,Stripe 的列表端点使用对象 ID 游标,Shopify 通过其 GraphQL Admin API 使用基于游标的连接,而 GitHub 的 REST API 则通过 Link 头进行分页——大多数端点基于页码,仅在特定端点上使用基于游标的方式(before/after)。它们的共同点是游标式遍历,而非单一统一的机制。
Relay 风格的 GraphQL 连接
Relay 连接是游标分页的 GraphQL 专用化形式,经过规范化后使 schema 中的每个列表字段都以相同方式分页。Relay 游标连接规范将连接定义为一组 edges 列表,每个 edge 包含一个 node(条目)和一个 cursor(其不透明位置),以及一个 pageInfo 对象,暴露 hasNextPage、hasPreviousPage、startCursor 和 endCursor。
query {
posts(first: 10, after: "cursor123") {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
每个 edge 的游标与 REST 章节中的不透明令牌概念相同,pageInfo.hasNextPage 也是通过多取一行技巧推导出的相同 has_more 标志。这种结构比普通的 { data, pagination } 信封更重,但它为客户端提供了统一的约定:通过传递 first/after 并读取 pageInfo 来分页任何连接。在底层,Relay 连接仍应由键集查询支撑,原因与前文相同。
选择模式并避免常见陷阱
对于任何新的列表端点,默认使用基于键集的游标分页,仅在有特定需求时才使用偏移量分页。决策矩阵如下:
| 使用场景 | 模式 |
|---|---|
| 无限滚动信息流、活动流、通知 | 游标 + 键集 |
| 可能需要后续重新实现的公共列表 API | 页面令牌(不透明) |
| GraphQL 列表字段 | Relay 连接(底层使用键集) |
| 大型表的批量导出 | 游标 + 键集 |
| 带有”跳转到第 N 页”功能的管理表 | 偏移量 |
| 显示页码的搜索 UI | 偏移量 |
| 小型(< 1 万行)、极少变更的参考数据 | 偏移量(两种均可) |
以下几项实践可确保所有这些场景在生产环境中的正确性:
- 限制页面大小。 将
limit钳制在固定最大值(100 是常见上限),防止客户端请求无限制的页面。 - 为完整排序元组建立索引。 复合索引必须与
ORDER BY的列和方向完全匹配,否则键集查询会退化为全表扫描。 - 优先使用
has_next_page而非总数量。 在每次响应中返回total_count会强制执行一次额外的COUNT(*),其在大型表上的成本取决于表膨胀和可见性映射;改为通过limit + 1行低成本地推导has_next_page,仅在 UI 确实需要时才提供总数量。 - 保持游标不透明。 对其进行编码,使客户端无法依赖内部格式,从而保留更改底层策略的自由。
- 在过滤或排序变更时重置游标。 游标编码的是特定有序过滤结果集中的位置;当用户更改排序顺序或应用新过滤条件时,该游标对新查询无效,必须被丢弃——每当排序或过滤参数发生变化时,都应重置到第一页。在客户端,这意味着将游标状态与过滤状态绑定,使过滤条件的变更能清除已累积的页面并重新获取数据。
滑动窗口和深偏移量问题在测试中很容易被忽视,因为它们只在并发写入或深度访问时才会出现,且其中几种情况永远不会进入你的错误日志。滑动窗口问题在服务器日志中是不可见的——响应是一个包含正确数量条目的有效 200 响应——但在会话回放中,我们将其识别为一种特定的渲染异常:一个已经在视口中可见的条目在下一批次中再次渲染,紧邻其之前的位置。深偏移量性能下降的表现方式也类似:不是超时或 4xx 错误,而是客户端仍在等待慢速 200 响应时永远不消失的”加载更多”转圈动画。由于这些是交互序列层面的症状而非离散错误,会话回放往往是我们发现它们的途径——它们存在于滚动、获取和渲染的完整序列中,而非单次失败的请求里。
结论
在你构建的下一个列表端点上,优先选择基于键集查询的游标分页,并将偏移量分页视为需要理由才能使用的例外,而非默认继承的选项——其深偏移量的线性成本和滑动窗口不稳定性是结构性问题,无法通过调优解决。为排序元组添加复合索引,返回 has_next_page 而非总数量,并保持游标不透明,这样你就可以在不引入破坏性变更的情况下替换底层引擎。
常见问题
我能在不破坏现有客户端的情况下,为基于偏移量的 API 添加游标分页吗?
可以,但应将其视为新增参数而非直接替换。在现有的 page 和 limit 参数旁边接受一个 cursor 查询参数,将携带游标的请求路由到键集查询,同时保留基于页码的请求走偏移量路径。保持游标不透明以确保内部格式可自由变更,并将偏移量路径标记为已废弃(针对大型或写入密集型端点),而非立即移除。
当游标所指向的行被删除时会发生什么?
不会出现任何问题,因为键集游标编码的是排序值而非行引用。复合谓词 (created_at, id) < (val, id) 会选取在该边界元组之后排序的所有行,因此即使精确的锚定行不再存在,下一页也能正确返回。这是相对于偏移量分页的核心优势——在偏移量分页中,删除操作会使每个后续位置发生偏移,导致客户端悄无声息地跳过一个条目。
为什么要使用 base64 编码的 JSON 作为游标,而不是直接发送时间戳和 ID?
编码使游标保持不透明,向客户端表明该令牌不应被手动解析或构造。如果客户端读取原始时间戳和 ID,它们就会与你的内部分页策略耦合,一旦你更改策略就会出现问题。不透明的 base64 令牌让你可以添加字段、切换排序键,或从键集迁移到页面令牌,而无需客户端做任何改动。编码本身不会带来任何有意义的性能开销。
当按 created_at 以外的列(例如热度分数)排序时,游标分页是否仍然有效?
有效,前提是游标编码的排序元组与查询的排序依据一致,并以唯一的平局打破字段结尾。按热度分数排序时,谓词和索引需要覆盖 (score, id),游标也需要携带这两个值,因为分数的碰撞频率远高于时间戳。如果没有唯一的 id 平局打破字段,共享相同分数的行会产生不确定的顺序,可能导致跨页面的条目跳过或重复。