移动开发中的游标分页——原理与实现指南

作者: IT Sectr 发布日期: 2026-03-11 阅读时间: 9 分钟

Cursor Pagination(游标分页)是一种基于唯一游标在有序数据集中进行导航的分页加载方法。根据 GraphQL 规范(2025),游标分页是处理动态数据的 API 推荐标准。游标分页解决了 Offset 方式的主要缺陷:插入操作导致的不稳定性以及大偏移量下的性能下降。

核心要点

  • 游标分页——每条记录拥有唯一游标标识符用于导航的分页方法。
  • 游标——数据集中的唯一位置标记(通常为 ID、UUID、时间戳),插入操作不会改变它。
  • 稳定性——两次请求之间新增的记录不会影响游标位置,杜绝重复和遗漏。
  • 性能——WHERE id > cursor 查询高效利用索引,大数据量下性能不衰减。
  • 局限性——游标分页不支持按页码导航(无法直接跳转到第 5 页)。

什么是游标分页?

游标分页(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) 时间内找到游标后的第一条记录,保证稳定的响应时间。

sql
-- 获取游标 '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 分页对比

选择游标分页还是 Offset 分页是 API 设计中的关键架构问题之一。每种方法各有利弊,决定了其适用场景。游标分页在动态数据场景中胜出,Offset 则在任意导航场景中占优。

特性游标分页Offset 分页
插入稳定性高(无重复)低(页面偏移)
大数据集性能O(log n) — 稳定O(n) — 随数据量下降
按页码导航不支持支持(page=5)
实现复杂度中等
REST 支持cursor/before/afterpage/offset
GraphQL 支持Relay 标准不推荐

为什么 Offset 在大数据量下表现不佳

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 分页。

kotlin
@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
        )
    )
}

Android 客户端实现

在客户端,游标分页通过 Paging 3 中的 PagingSource 实现,其中键是游标(Long)。PagingSource.load 接收 LoadParams.key——上次加载记录的游标。LoadResult.Page 返回数据和 nextKey——下一页的游标。当 nextKey = null 时,分页结束。

kotlin
// 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)
    }
}

通过 Relay 实现 GraphQL

在 GraphQL 中,游标分页通过 Relay 的 Connection 模式实现。每个类型都有 Connection(包含 pageInfo 和 edges)和 Edge(node + cursor)。查询传递 first、after、last、before 参数。服务器返回带游标的 edges 数组和带 hasNextPage/hasPreviousPage 的 pageInfo。

何时使用游标分页

游标分页适用于处理动态数据的 API,其中记录频繁添加或删除。典型场景:社交网络新闻流、聊天消息、交易历史、文章评论。所有这些场景都需要一致性和无重复。

  • 聊天和即时通讯——每条新消息都添加到列表顶部。Offset 分页会在每条新消息时偏移。
  • 社交网络和信息流——帖子不断发布。游标分页确保用户不会错过任何帖子。
  • 订单和交易历史——数据变化较少,但一致性对财务报告至关重要。
  • 大数据量 API——数百万条记录。游标分页保持性能,而 Offset 开始变慢。
  • GraphQL API——Relay 标准要求使用基于游标的分页以符合规范。
  • 带无限滚动的移动应用——用户向下滚动,加载新批次。游标方式提供流畅的无重复用户体验。

何时游标分页不适用

有些场景下 Offset 分页更方便:管理后台需要按页码导航;带分页的搜索结果可能变化;报表和分析需要指向第 5 页的固定链接。在这些场景中,游标的优势不足以弥补实现复杂度。

游标分页不支持「跳转」到任意页面——用户无法点击「第 5 页」直接到达。这个限制是架构性的:要计算总页数需要单独的 count 查询,对于大表可能代价高昂。此时可采用混合方法:数据使用游标 + 分页使用 count。

常见问题

游标分页中的游标是什么?

游标是记录的唯一标识符,指向数据集中的位置。它可以很简单(记录的 ID)或复合(多个字段)。客户端获取页面最后一条记录的游标,在下次请求中传递以获取下一批数据。

游标分页比 Offset 好在哪里?

游标分页不受插入操作引起的偏移影响——每个元素精确落入一个页面。它还通过使用索引而非扫描前 n 行来保持大数据量下的速度。Offset 更简单,但对动态数据不稳定。

没有 GraphQL 也能实现游标分页吗?

可以。游标分页不依赖 GraphQL。在任何 REST API 中都可以实现,只需将游标作为查询参数 ?after=83&limit=20 传递。响应应包含带 endCursor 和 hasNextPage 的 pageInfo——这使客户端无需了解游标的内部结构即可管理加载。

应该使用哪种游标——ID、UUID 还是时间戳?

自增 ID 是最优选择:单调递增、不变、高效索引。UUID v7(按时间排序)也适用。时间戳在相同时间可能产生重复,因此应将其与 ID 组合:(created_at, id) 以保证游标唯一性。

游标分页如何知道总页数?

游标分页不提供总页数——这是它的局限性。如果需要 total 信息,请使用相同过滤条件执行单独的 COUNT 查询。对于大表,可使用 EXPLAIN 进行近似计算或使用缓存的分析 total。

总结

  • 游标分页——使用记录的唯一标识符而非偏移量进行导航的分页方法。
  • 游标保证插入操作下数据集的稳定性:新记录不会改变已加载页面的位置。
  • 性能——利用 B-tree 索引,大数据量下保持高性能(O(log n))。
  • 游标分页适用于动态数据:聊天、新闻流、交易、评论。
  • 主要限制——不支持按页码导航,无法跳转到任意页面。
  • 实现——使用 WHERE id 大于游标、after/before 参数和响应中的 pageInfo。
  • 标准——Relay Connection GraphQL,但带游标参数的 REST API 也广泛使用。

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读