分页是每个列表接口都绕不开的问题。REST 时代我们习惯 ?page=1&pageSize=20,但进入 GraphQL 后,页面、App、管理后台对分页的需求更加多元:有的要无限滚动、有的要跳页、有的要精确总数。Relay Connection(连接)规范用 edges / node / pageInfo 三层结构加不透明游标,成为 GraphQL 世界最主流的分页方案。本文从 offset 分页的缺陷讲起,系统讲解游标分页的原理、实现、与缓存、增量加载的配合,并列出生产环境最常见的分页坑。
一、offset vs cursor 分页
1.1 offset 分页的直觉与缺陷
# offset 分页:page = 2, size = 20
query {
users(offset: 20, limit: 20) {
id
name
}
}
offset 分页直觉、易实现,但有两个结构性缺陷:数据漂移(第 1 页期间有数据插入/删除,第 2 页会重复或漏掉记录)与深度分页性能(OFFSET 1000000 LIMIT 20 需扫描并丢弃前 100 万行,数据库随页深线性退化)。
1.2 cursor 分页的核心思路
游标分页不依赖"跳过多少条",而是锚定一条具体记录:客户端把上一页最后一条记录的游标传给服务端,服务端从游标位置继续取数,天然免疫数据漂移。
query {
users(first: 20, after: "YXJyYXljb25uZWN0aW9uOjE5") {
edges { node { id name } }
pageInfo { hasNextPage endCursor }
}
}
1.3 两者的适用场景对比
| 维度 | offset 分页 | cursor 分页 |
|---|---|---|
| 数据漂移 | 有(插入/删除错位) | 无(锚定记录) |
| 深度分页性能 | O(n) 扫描,页深即慢 | O(log n),始终快 |
| 随机跳页 | 天然支持 | 不支持(无页号) |
| 实现复杂度 | 低 | 中 |
| 典型场景 | 管理后台、页码 UI | Feed、无限滚动、消息流 |
原则:面向人的"页码 UI"用 offset;面向流的"滚动/增量"用 cursor。两者可以共存于同一 Schema(
users(offset, limit)与usersConnection(first, after))。
二、中继连接规范(edges/node/pageInfo)
2.1 Connection 的标准形态
Relay Connection 的完整结构:
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
各部分的职责:
| 部分 | 职责 |
|---|---|
edges[].node | 实际的业务数据 |
edges[].cursor | 该条记录的不透明游标(用于继续翻页) |
pageInfo.hasNextPage | 是否还有下一页(驱动"加载更多"按钮) |
pageInfo.startCursor/endCursor | 首尾游标,便于反向遍历 |
totalCount | 总条数(非 Relay 规范字段,按需提供) |
2.2 为什么叫 Connection / Edge / Node
这套命名来自图论:Connection 是一条"边",Edge 连接 Node。语义上的收益是:分页信息(游标)与业务数据(node)解耦,客户端可以只关注 node,游标由 Edge 承载。
2.3 参数的正交性
Relay 规范要求 first/after 与 last/before 成对使用:
| 参数组合 | 语义 |
|---|---|
first: 20, after: cursor | 从游标之后取 20 条(向后翻) |
last: 20, before: cursor | 从游标之前取 20 条(向前翻) |
first: 20(无 after) | 从头取前 20 条 |
last: 20(无 before) | 从尾取最后 20 条 |
服务端应校验:first 与 last 不能同时为空(二者至少一个),且 first/last 必须有上限(如 100)。
三、游标编码(base64/不透明)
3.1 游标的"不透明"原则
游标对客户端必须完全不透明:客户端只负责原样传递,永远不解析、不构造。这样服务端可以随时改变游标内部编码而不破坏兼容性。
3.2 常见编码方案
// 方案一:Base64 编码(Relay 默认,明文可读但可逆)
export function encodeCursor(offsetOrId: string | number): string {
return Buffer.from(`arrayconnection:${offsetOrId}`).toString('base64');
}
export function decodeCursor(cursor: string): string | number {
const raw = Buffer.from(cursor, 'base64').toString('utf8');
return raw.replace(/^arrayconnection:/, '');
}
// 方案二:键集编码(多列排序时编码完整排序键)
export function encodeKeysetCursor(row: { createdAt: Date; id: string }): string {
const payload = JSON.stringify([row.createdAt.toISOString(), row.id]);
return Buffer.from(payload).toString('base64url');
}
3.3 各种编码方式的取舍
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| Base64(offset) | 简单、调试方便 | 可逆、暴露偏移量 | 中小型单列分页 |
| Base64(id) | 无偏移语义 | 仅适合按 ID 顺序 | 按主键排序 |
| Base64URL(键集) | 支持多列稳定排序 | 实现略复杂 | 复杂排序 Feed |
| 无意义随机串 | 完全不可逆 | 需存储映射 | 高安全要求 |
原则:游标内容的编码可简可繁,但"不透明"是不可妥协的。客户端一旦开始解析游标,你就失去了演进游标格式的自由。
四、排序稳定性与过滤
4.1 为什么需要稳定排序
游标分页的根基是"游标之后的记录"。如果排序不稳定(相同 createdAt 的两条记录每次顺序不同),翻页会出现重复或漏数据。解决方法是总排序键 = 业务排序键 + 唯一键(如 created_at DESC, id DESC),保证全序。
4.2 键集分页(Keyset Pagination)的实现
基于排序键的键集分页是游标分页的高效落地方式:
async function usersConnection(args, ctx) {
const { first = 20, after } = args;
let where = '';
const params: unknown[] = [];
if (after) {
const { createdAt, id } = decodeKeysetCursor(after);
params.push(createdAt, id);
// keyset 条件:created_at DESC, id DESC 的"在其后"
where = `WHERE (created_at, id) < ($1::timestamptz, $2::text)`;
}
// 多取一条判断 hasNextPage
const rows = await db.query(
`SELECT id, name, created_at
FROM users
${where}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length + 1}`,
[...params, first + 1],
);
const hasNextPage = rows.length > first;
const pageRows = rows.slice(0, first);
return {
edges: pageRows.map((row) => ({
node: row,
cursor: encodeKeysetCursor(row),
})),
pageInfo: {
hasNextPage,
startCursor: pageRows[0] ? encodeKeysetCursor(pageRows[0]) : null,
endCursor: pageRows[pageRows.length - 1] ? encodeKeysetCursor(pageRows[pageRows.length - 1]) : null,
},
totalCount: await countUsers(ctx), // 见第五节
};
}
4.3 过滤与排序的组合
过滤条件会改变"游标之后"的语义,实现上必须把过滤条件同时作用于游标位置与查询:
| 过滤类型 | 实现要点 |
|---|---|
| 等值过滤(status、categoryId) | WHERE 增加条件,游标仍基于排序键 |
| 范围过滤(价格区间) | WHERE 增加范围,注意与游标条件用 AND 连接 |
| 全文搜索(keyword) | 排序键可能变为相关性分数,游标需编码分数 |
| 软删除/权限过滤 | 过滤条件必须也作用于翻页后的查询,否则越界泄露 |
一个经典 bug:第一页过滤了 status = 'published',翻页时忘记带过滤条件,导致后续页混入草稿。过滤条件必须内聚在同一个取数函数中,翻页时整体复用。
五、总条数获取策略(count 开销)
5.1 totalCount 的代价
totalCount 不是 Relay 规范必需字段,但它常被业务要求(“共 1280 条”)。问题在于 SELECT count(*) 在大表上是全表扫描,对高频 Feed 查询是灾难。
5.2 获取 totalCount 的三种策略
| 策略 | 实现 | 代价 | 适用 |
|---|---|---|---|
| 每次实时 count | SELECT count(*) | 高,表大时不可用 | 小表、低频 |
| 缓存 count | Redis 缓存,按写失效 | 中,有短暂延迟 | 高频只读列表 |
| 估算/不提供 | 采样估算或省略字段 | 低 | Feed、无限滚动 |
// 缓存 totalCount 的模板(写路径失效)
async function getCachedTotalCount(key: string, freshCount: () => Promise<number>) {
const cached = await redis.get(key);
if (cached != null) return Number(cached);
const total = await freshCount();
await redis.set(key, total, 'EX', 300); // 5 分钟
return total;
}
// 任何写操作成功后
await redis.del(`list:users:total`);
5.3 totalCount 与游标语义的一致性
注意:totalCount 与游标分页天然不完全一致——游标锚定的是"快照之后",而 count 是"当前时刻"。高频写入下两者未必对得上;若业务需要精确一致性,应引入版本化快照(如 asOf 参数),否则建议明确 totalCount 是"近似值"。对于无限滚动场景,根本不需要 totalCount——只需要 hasNextPage。是否提供 totalCount 应基于产品需求而非惯性。
六、无限滚动/增量加载
6.1 无限滚动的客户端状态
基于 Relay Connection 的无限滚动,客户端只需要维护两个状态:已加载的 node 列表与最后的 endCursor(配合 fetchMore 追加):
import { useCallback, useState } from 'react';
import { gql, useQuery } from '@apollo/client';
const FEED_QUERY = gql`
query Feed($first: Int!, $after: String) {
feed(first: $first, after: $after) {
edges { node { id title } cursor }
pageInfo { hasNextPage endCursor }
}
}
`;
export function useInfiniteFeed() {
const [items, setItems] = useState<FeedItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const { data, fetchMore, loading } = useQuery(FEED_QUERY, {
variables: { first: 20, after: null },
});
const loadMore = useCallback(async () => {
if (loading || !data?.feed.pageInfo.hasNextPage) return;
const { data: more } = await fetchMore({
variables: { first: 20, after: data.feed.pageInfo.endCursor },
});
setCursor(more.feed.pageInfo.endCursor);
}, [data, fetchMore, loading]);
return { items, loadMore, hasNextPage: data?.feed.pageInfo.hasNextPage };
}
6.2 fetchMore 与 cache 的 merge
Apollo Client 的 fetchMore 会把新结果合并进缓存,需要自定义 merge 策略,否则新页会覆盖旧页:
import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
feed: {
keyArgs: false, // 忽略分页变量,按同一列表缓存
merge(existing: any = { edges: [], pageInfo: {} }, incoming: any) {
return {
edges: [...existing.edges, ...incoming.edges],
pageInfo: incoming.pageInfo,
};
},
},
},
},
},
});
6.3 增量加载的三种触发方式
| 触发方式 | 实现 | 适用 |
|---|---|---|
| 手动按钮 | “加载更多"按钮 | 列表页、兼容 SEO |
| 滚动监听 | IntersectionObserver | 移动端 Feed |
| 虚拟列表 + 预取 | 滚近底部提前 fetchMore | 长列表高性能场景 |
// IntersectionObserver 触发 loadMore
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadMore();
});
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [loadMore]);
七、分页与缓存配合
7.1 分页查询的缓存键设计
分页查询的缓存失效是难点:某条记录更新后,所有包含它的列表缓存都可能过期。两条路线:
| 路线 | 做法 | 代价 |
|---|---|---|
| 短 TTL | 列表缓存 60–300s 自动过期 | 可接受延迟 |
| 精准失效 | 按 userId 维度做列表缓存 key,写路径失效 | 需要维护失效映射 |
多租户场景下,列表缓存 key 必须包含租户维度:feed:v3:{tenantId}:{userId}。
7.2 Redis 分页缓存模板
// 游标分页 + Redis 排序集(ZSET)缓存
const KEY = `feed:${tenantId}:${userId}`;
// 写入时维护 ZSET:score = createdAt, member = postId
await redis.zadd(KEY, post.createdAtMs, post.id);
await redis.zremrangebyrank(KEY, 0, -5000); // 只保留最近 5000 条,防膨胀
// 翻页:ZREVRANGEBYSCORE 从游标 score 之后取
const nextPosts = await redis.zrevrangebyscore(KEY, cursorScore - 1, '-inf', 'LIMIT', 0, first);
7.3 游标与缓存的天然契合
游标分页与"append-only 缓存"高度契合:新数据追加在列表头,旧页数据不变。这比 offset 分页更适合 CDN/Redis 缓存——只要排序键稳定,某一页的游标结果可以被安全缓存一段时间。
原则:分页缓存的关键是"排序键稳定”。一旦排序键(如按热度动态排序)变化,所有基于旧游标的缓存都失去意义,此时应缩短 TTL 或直接禁用列表缓存。
八、常见坑
8.1 坑位清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 游标解析失败 | 客户端传了非法/过期游标 | 解码后校验,失败返回 INVALID_CURSOR 错误 |
| 排序不唯一 | 翻页重复/漏数据 | 排序键补唯一键(id) |
| 过滤条件丢失 | 第二页混入不该出现的记录 | 过滤内聚在统一取数函数 |
first 无上限 | 一次取 10 万条 | 运行时 clamp 到 max(如 100) |
| fetchMore 覆盖旧数据 | 无限滚动只显示最后一页 | 配置 cache merge |
| totalCount 实时扫描 | 大表拖垮查询 | 缓存/省略/估算 |
| 游标编码格式变更 | 旧游标全部失效 | 编码版本化,兼容解码旧格式 |
| 深链分享无法定位 | 分享"第 300 条"链接 | 提供 search 定位或接受 offset 混合 |
8.2 游标过期与数据删除
游标指向的记录被删除时,翻页不应报错,而是从游标位置继续向后取——这正是键集分页 WHERE (created_at, id) < (...) 的优势:条件本身不依赖记录是否存在。对于按 ID 偏移的游标,删除会导致"跳过一条",可用 isDeleted 软删除 + 过滤来规避。
8.3 混合模式:cursor + offset 并存
某些产品需要页码 UI(管理后台)+ 无限滚动(App 端)并存。此时可提供两套根字段,而非在同一个字段上叠加两种参数:
type Query {
# 面向页码 UI
users(page: Int = 1, pageSize: Int = 20): UsersPage!
# 面向滚动流
usersConnection(first: Int, after: String): UserConnection!
}
九、综合案例
9.1 一个消息流接口的完整实现
需求:即时通讯的消息流,按时间倒序,无限滚动,每条消息可变状态(已读/撤回)。
type MessageConnection {
edges: [MessageEdge!]!
pageInfo: PageInfo!
}
type MessageEdge {
node: Message!
cursor: String!
}
type Message {
id: ID!
content: String!
status: MessageStatus!
createdAt: String!
}
enum MessageStatus {
SENT
DELIVERED
READ
RECALLED
}
type Query {
messages(conversationId: ID!, first: Int = 20, after: String): MessageConnection!
}
实现要点:排序键为 created_at DESC, id DESC 全序稳定;游标用键集编码 [createdAt, id] 做 base64url;状态可变的 status 字段用 @defer 或独立订阅(见 mutation 设计专题)做增量更新;新消息插入在列表头,after 游标不受影响;totalCount 用 Redis ZCARD 获得(ZSET 已维护),零额外查询。
9.2 性能对比示例
以下是一个 100 万行 users 表的实测对比(第一页均为 20 条):
| 方案 | 第 1 页 | 第 50000 页 |
|---|---|---|
OFFSET 999980 LIMIT 20 | 1ms | 850ms |
键集分页((created_at, id) <) | 1ms | 1.2ms |
游标分页在深分页场景下性能恒定,这正是它成为主流分页方案的根本原因。
9.3 十条分页铁律
- 面向滚动/增量用 cursor,面向页码 UI 用 offset,不混在一个字段;
- 排序键 = 业务键 + 唯一键,保证全序;
- 游标对客户端不透明,编码格式可随时演进;
- 过滤条件必须内聚,翻页时整体复用;
first/last至少一个且设上限;- totalCount 能不提供就不提供,提供则缓存或估算;
- fetchMore 必须配置 cache merge;
- 多租户列表缓存 key 必须带租户维度;
- 游标指向的记录删除不应导致翻页报错;
- 深链定位需求另行设计,不强行塞进游标分页。
FAQ
Q1: 游标分页能支持随机跳页吗?
原生游标分页不支持"跳到第 N 页",因为它没有页号概念。若产品需要页码 UI,可提供独立的 offset 字段或为游标附加 page 信息。不要试图用 after 做随机跳页。
Q2: totalCount 每次查询都 count 一遍可以吗?
小表可以;大表不要。高频列表用 Redis 缓存 count(写路径失效),或明确告知前端 totalCount 为近似值。无限滚动场景通常根本不需要 totalCount。
Q3: 游标 Base64 编码会不会暴露业务数据?
Base64 是可逆的,如果游标内含 createdAt、id,客户端解码即可看到。若业务敏感,用不可逆的随机串作为游标并映射到记录;若不敏感(多数场景),Base64 足够且便于调试。
Q4: 为什么用 last/before 向前翻时游标语义容易出错?
before: cursor 取的是游标之前的记录,但返回顺序仍需按业务排序键(倒序时即自然倒序)。容易错的是"取出来了但顺序反了"——先按 DESC 取、再 reverse 才是正确的展示顺序。
Q5: 分页列表中的记录被更新后,游标会失效吗?
取决于排序键。若记录更新改变了排序键(如 createdAt 变更、热度值变更),它可能"跑到"游标另一侧,导致翻页重复或遗漏。稳定的业务排序键(如 createdAt)加上唯一 id 作为 tie-breaker 是规避此类问题的最优解。
一句话总结
游标分页用"锚定记录的不透明游标 + 稳定的全序排序键"替代"跳过 N 条",天然免疫数据漂移且深分页性能恒定;配合 Relay Connection 的 edges/node/pageInfo 结构与 fetchMore 合并策略,它成为无限滚动与增量加载的事实标准。
相关阅读
- GraphQL Schema 设计进阶:Relay Connection 建模
- GraphQL 客户端状态管理与缓存策略
- GraphQL Resolver 性能与 N+1 问题根治
- GraphQL 持久化查询与生产安全
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。