分页方案对比
GraphQL API 中最常见的两种分页方案各有适用场景:
Offset 分页(skip/limit)
传统分页方式,通过跳过 N 条记录实现。实现简单,支持跳转到任意页(如"第15页")。适合数据变化不频繁、需要"跳页"功能的场景(如管理后台列表)。
Cursor 分页(Relay Connection)
基于不透明游标(Cursor)的分页,游标通常是对记录 ID 或排序字段的编码。不会受并发写入影响,大数据集性能稳定(不需要 OFFSET 扫描)。适合无限滚动、实时流、大数据集的场景(如社交动态、新闻流)。
Offset 分页的局限
# Offset 分页:skip/take
type Query {
posts(skip: Int = 0, take: Int = 20): [Post!]!
}
query {
posts(skip: 0, take: 20) # 第1页
posts(skip: 20, take: 20) # 第2页
}
并发写入问题
当用户浏览第2页时,如果有新帖子插入,会导致数据"漂移"——原来第1页的最后一条可能变成第2页的第一条,产生重复数据。
大偏移量性能差
SQL 中
OFFSET 10000 需要扫描并丢弃前 10000 条数据,性能随分页深度线性下降。总数查询开销
计算总页数需要
COUNT(*) 查询,在大表中代价昂贵。Relay Cursor Connection 规范
Schema 定义
# Connection 模式的通用结构
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String! # 不透明的分页游标(通常是 base64 编码的 ID)
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String # 当前页第一个元素的 cursor
endCursor: String # 当前页最后一个元素的 cursor
}
type Query {
posts(
first: Int # 向前分页:取前 N 条
after: String # 从指定 cursor 之后开始
last: Int # 向后分页:取后 N 条
before: String # 从指定 cursor 之前开始
): PostConnection!
}
客户端查询示例
# 第1页:获取前20条
query {
posts(first: 20) {
edges {
cursor
node { id title createdAt author { name } }
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
# 第2页:从 endCursor 之后获取下20条
query {
posts(first: 20, after: "Y3Vyc29yOjIw") {
edges {
cursor
node { id title }
}
pageInfo { hasNextPage endCursor }
}
}
服务器实现
function encodeCursor(id: string): string {
return Buffer.from(`cursor:${id}`).toString('base64');
}
function decodeCursor(cursor: string): string {
return Buffer.from(cursor, 'base64').toString().replace('cursor:', '');
}
const resolvers = {
Query: {
posts: async (_, { first = 20, after, last, before }, { db }) => {
const take = first ?? last ?? 20;
const cursor = after ? { id: decodeCursor(after) } : undefined;
const posts = await db.posts.findMany({
take: take + 1, // 多取1条用于判断 hasNextPage
cursor,
skip: cursor ? 1 : 0,
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > take;
const nodes = hasNextPage ? posts.slice(0, -1) : posts;
const totalCount = await db.posts.count();
return {
totalCount,
edges: nodes.map(post => ({
node: post,
cursor: encodeCursor(post.id),
})),
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
endCursor: nodes.at(-1) ? encodeCursor(nodes.at(-1)!.id) : null,
},
};
},
},
};
使用 @pothos/plugin-relay 简化
手写 Connection 类型很繁琐。推荐使用 Pothos GraphQL 或 Nexus 等 schema-first 构建工具,它们内置了 Relay Connection 的实现,几行代码即可生成完整的分页 Schema 和 Resolver。
Cursor 的编码原理
游标(Cursor)是一个不透明(Opaque)的字符串,客户端不应该尝试解析它的内容,只需原样传回给服务器。服务器内部决定游标的编码方式:
基于 ID 的游标
将记录的唯一 ID(如数据库主键)用 base64 编码,格式通常为
cursor:${id}。优点:简单高效;缺点:如果需要多字段排序,则无法仅靠 ID 确定位置。基于排序字段的游标
将排序字段值(如
createdAt + id)一起编码。解码后用 WHERE (createdAt, id) < (:cursor_time, :cursor_id) 查询,适合复合排序场景。不透明性保证
使用 base64 编码而非明文,是向客户端传达"请勿解析"的约定,而非安全手段。若需防篡改,可加 HMAC 签名校验游标的合法性。
前端无限滚动实现
// React 无限滚动示例(配合 Apollo Client)
import { useQuery } from '@apollo/client';
import { useRef, useCallback } from 'react';
const GET_POSTS = gql`
query GetPosts($first: Int!, $after: String) {
posts(first: $first, after: $after) {
edges {
cursor
node { id title createdAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function PostFeed() {
// fetchMore 是 Apollo 提供的追加数据方法
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 20 },
});
// IntersectionObserver 监听"加载更多"触发元素
const observer = useRef<IntersectionObserver>();
const lastPostRef = useCallback((node: Element | null) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && data?.posts.pageInfo.hasNextPage) {
// 用 endCursor 获取下一页
fetchMore({
variables: { after: data.posts.pageInfo.endCursor },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
posts: {
...fetchMoreResult.posts,
// 合并 edges 数组:旧数据 + 新数据
edges: [
...prev.posts.edges,
...fetchMoreResult.posts.edges,
],
},
};
},
});
}
});
if (node) observer.current.observe(node);
}, [loading, data?.posts.pageInfo]);
const posts = data?.posts.edges ?? [];
return (
<div>
{posts.map((edge, i) => (
// 最后一条加 ref,触发加载更多
<PostCard
key={edge.node.id}
post={edge.node}
ref={i === posts.length - 1 ? lastPostRef : null}
/>
))}
{loading && <Spinner />}
</div>
);
}
两种分页方案选择指南
选择 Offset 分页的场景
管理后台列表(需要跳到第 N 页)、数据基本静态(无并发写入)、需要展示总页数(如"共 50 页")、数据集较小(< 10 万条)。
选择 Cursor 分页的场景
社交动态/新闻流(无限滚动)、数据实时变化(频繁插入/删除)、大数据集(> 100 万条,OFFSET 性能不可接受)、移动端 App(用户不需要跳页)。
常见误区
Cursor 分页并非万能——它不支持"跳页"(无法直接跳到第50页),也不方便显示"当前在第几页"。很多产品同时提供两种接口:公开 API 用 Cursor(符合 Relay 规范),内部管理 API 用 Offset(便于运营操作)。
本章小结
本章核心要点
- Offset 分页的局限:并发写入导致数据漂移(重复/遗漏);大 OFFSET 需全表扫描,性能随深度线性下降;COUNT(*) 在大表代价高昂。
- Relay Cursor Connection 规范:标准结构为 Connection → edges[](每项含 node + cursor)+ pageInfo(hasNextPage/hasPreviousPage/startCursor/endCursor)+ totalCount;这是 GraphQL 分页的行业标准。
- 分页参数语义:
first + after向前翻页;last + before向后翻页;after 接收上一页的 endCursor,实现链式翻页。 - hasNextPage 判断技巧:查询时多取 1 条(take + 1),若结果数 > take 则 hasNextPage = true,同时截掉最后一条再返回。无需额外 COUNT 查询。
- Cursor 编码:通常将
cursor:${id}base64 编码,保持不透明性;客户端不应解析 cursor 内容,仅需原样传回。 - 选型建议:无限滚动/实时流/大数据集选 Cursor;需要跳页/总页数/小数据集选 Offset;大型产品可同时提供两种接口满足不同场景。