Cursor Pagination(游标分页)是一种基于唯一游标在有序数据集中进行导航的分页加载方法。根据 GraphQL 规范(2025),游标分页是处理动态数据的 API 推荐标准。游标分页解决了 Offset 方式的主要缺陷:插入操作导致的不稳定性以及大偏移量下的性能下降。
核心要点
游标分页(Cursor Pagination)是一种分页加载方法,服务器随数据一起返回一个特殊指针——游标。客户端在下次请求中使用该游标获取下一批记录。游标是当前页面最后一条记录的唯一标识符。
与 Offset 分页不同——客户端说「给我第 5 页,每页 20 条」——游标分页的工作方式是:「给我 ID 大于 83 之后的 20 条记录」。服务器执行 WHERE id > 83 且 LIMIT 20 的查询。这种方式确保无论是否有插入操作,每条记录都精确落入某一页面。
游标分页的概念因 Relay Connection(GraphQL)规范而广泛普及,该规范将基于游标的分页确立为现代 API 的标准。Relay 定义了响应格式:edges(带游标的记录数组)、pageInfo(hasNextPage、hasPreviousPage、startCursor、endCursor)。
游标分页并非新技术——早在 Web 出现之前,它就在数据库中被使用。在 SQL 中,这称为 keyset pagination 或 seek method。2015 年 Relay 规范发布后,该方法在 API 中流行起来,该规范将游标格式定义为 base64 编码字符串,以便通过 HTTP 统一传输。
游标分页的基本原理是使用 WHERE 条件在索引字段上定位,而不是使用偏移量。正方向查询使用 WHERE id > last_id,反方向使用 WHERE id < first_id。B-tree 索引能在 O(log n) 时间内找到游标后的第一条记录,保证稳定的响应时间。
-- 获取游标 '83' 之后的 20 条记录
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- 获取游标 '83' 之前的 20 条记录(向后)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
游标可以是简单的(ID 值)或复杂的(由多个字段组成)。简单游标是记录的主键,例如 自增 ID 或 UUID。复合游标用于按非唯一字段排序的情况,例如 (created_at, id),其中 id 在时间戳相同时保证唯一性。
典型的 API 格式是 base64 编码字符串形式的游标。服务器解码游标,提取值并构建 SQL 查询。Base64 编码隐藏了游标的内部结构,允许更改格式而不破坏向后兼容性。客户端在响应的 endCursor 字段中获取游标,并在下次请求中将其作为字符串传递。
游标分页支持双向导航。向前(next)使用当前页面最后一个元素的游标,向后(previous)使用第一个元素的游标。请求中的 after 和 before 参数决定方向:after 获取游标之后的记录,before 获取游标之前的记录。
选择游标分页还是 Offset 分页是 API 设计中的关键架构问题之一。每种方法各有利弊,决定了其适用场景。游标分页在动态数据场景中胜出,Offset 则在任意导航场景中占优。
| 特性 | 游标分页 | Offset 分页 |
|---|---|---|
| 插入稳定性 | 高(无重复) | 低(页面偏移) |
| 大数据集性能 | O(log n) — 稳定 | O(n) — 随数据量下降 |
| 按页码导航 | 不支持 | 支持(page=5) |
| 实现复杂度 | 中等 | 低 |
| REST 支持 | cursor/before/after | page/offset |
| GraphQL 支持 | Relay 标准 | 不推荐 |
Offset 分页会扫描整个表直到 OFFSET 位置。当 offset=100000 时,数据库读取并跳过 100000 行,即使 LIMIT 仅为 20。MySQL 和 PostgreSQL 无法优化 OFFSET——这是 SQL 中 LIMIT/OFFSET 的实现特性。游标分页使用 B-tree 索引,能在 O(log n) 时间内找到位置。
Offset 的另一个问题是向后翻页时「跳过」记录。如果用户已加载第 5 页,而此时新增了记录,请求第 6 页时会再次看到第 5 页的记录或遗漏新记录。游标分页完全排除了这种情况:游标指向数据集中的具体位置,插入操作不会改变该位置。
接下来介绍游标分页在后端(Kotlin + Spring)和客户端(Android + Retrofit)的实现。服务器接收 after、before、limit 参数,返回带游标和 pageInfo 的记录列表。典型响应包含 hasNextPage 和 hasPreviousPage,用于管理 UI 分页。
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
在客户端,游标分页通过 Paging 3 中的 PagingSource 实现,其中键是游标(Long)。PagingSource.load 接收 LoadParams.key——上次加载记录的游标。LoadResult.Page 返回数据和 nextKey——下一页的游标。当 nextKey = null 时,分页结束。
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// 带游标键的 PagingSource
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
在 GraphQL 中,游标分页通过 Relay 的 Connection 模式实现。每个类型都有 Connection(包含 pageInfo 和 edges)和 Edge(node + cursor)。查询传递 first、after、last、before 参数。服务器返回带游标的 edges 数组和带 hasNextPage/hasPreviousPage 的 pageInfo。
游标分页适用于处理动态数据的 API,其中记录频繁添加或删除。典型场景:社交网络新闻流、聊天消息、交易历史、文章评论。所有这些场景都需要一致性和无重复。
有些场景下 Offset 分页更方便:管理后台需要按页码导航;带分页的搜索结果可能变化;报表和分析需要指向第 5 页的固定链接。在这些场景中,游标的优势不足以弥补实现复杂度。
游标分页不支持「跳转」到任意页面——用户无法点击「第 5 页」直接到达。这个限制是架构性的:要计算总页数需要单独的 count 查询,对于大表可能代价高昂。此时可采用混合方法:数据使用游标 + 分页使用 count。
常见问题
游标是记录的唯一标识符,指向数据集中的位置。它可以很简单(记录的 ID)或复合(多个字段)。客户端获取页面最后一条记录的游标,在下次请求中传递以获取下一批数据。
游标分页不受插入操作引起的偏移影响——每个元素精确落入一个页面。它还通过使用索引而非扫描前 n 行来保持大数据量下的速度。Offset 更简单,但对动态数据不稳定。
可以。游标分页不依赖 GraphQL。在任何 REST API 中都可以实现,只需将游标作为查询参数 ?after=83&limit=20 传递。响应应包含带 endCursor 和 hasNextPage 的 pageInfo——这使客户端无需了解游标的内部结构即可管理加载。
自增 ID 是最优选择:单调递增、不变、高效索引。UUID v7(按时间排序)也适用。时间戳在相同时间可能产生重复,因此应将其与 ID 组合:(created_at, id) 以保证游标唯一性。
游标分页不提供总页数——这是它的局限性。如果需要 total 信息,请使用相同过滤条件执行单独的 COUNT 查询。对于大表,可使用 EXPLAIN 进行近似计算或使用缓存的分析 total。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。