GraphQL 分页模式深度对比:基于游标、偏移量与 Relay 规范的 DApp 实践选择
2026/7/15 21:40:45 网站建设 项目流程

GraphQL 分页模式深度对比:基于游标、偏移量与 Relay 规范的 DApp 实践选择

一、分页之痛:当 DApp 的数据量越过简单的极限

DApp 的数据查询有一个明显的特点:数据是持续增长的。交易记录、NFT 转移历史、质押收益明细——这些列表随着用户交互次数和区块高度的推进不断膨胀。最初你可能只需要查询"最近 10 笔交易",简单的ORDER BY block_number DESC LIMIT 10就能解决。但当你需要做无限滚动(infinite scroll)时,问题来了:如何高效地获取"下一页"数据?

GraphQL 作为 DApp 后端(尤其是 The Graph 协议的索引层)的标准查询语言,提供了三种主流的分页模式:偏移量分页(Offset-based)、**游标分页(Cursor-based)**和Relay 规范分页(Relay Connection Spec)。这三种模式在实现复杂度、性能特征和适用场景上有本质差异,但很多团队在选择时仅凭"大家都用这个"的直觉——这是性能隐患的来源。

flowchart TB subgraph 偏移量分页 O1[请求: skip=0, first=10] --> R1[返回: 第1-10条] O2[请求: skip=10, first=10] --> R2[返回: 第11-20条] O3[请求: skip=20, first=10] --> R3[返回: 第21-30条] end subgraph 游标分页 C1[请求: first=10] --> CR1[返回: items + endCursor] C2[请求: first=10, after=endCursor] --> CR2[返回: items + endCursor] C3[请求: first=10, after=endCursor] --> CR3[返回: items + endCursor] end subgraph Relay规范分页 RL1[query{transactions first:10}] --> RR1[返回: edges + pageInfo] RR1 --> RL2[query{transactions first:10 after:pageInfo.endCursor}] end O1 -.- PROBLEM1[问题: 中间插入数据导致重复/遗漏] C1 -.- SOLVE1[解决: 基于稳定游标,插入无影响] RL1 -.- SOLVE2[标准化: 元数据pageInfo提供hasNextPage等]

二、三种分页模式的核心机制与性能差异

2.1 偏移量分页:简单但脆弱

偏移量分页是 SQL 原生支持的LIMIT/OFFSET模式。GraphQL 端通常暴露为skip+first参数。

工作原理:数据库扫描skip + first条记录,然后丢弃前skip条,返回剩余first条。

致命缺陷:当数据在两次查询之间插入或删除时,偏移量会"漂移"。例如你查询skip=0, first=10获得第 1-10 条记录,但在下一次查询skip=10, first=10之前,有人在列表头部插入了 3 条新记录——你的下一页将从原来的第 14 条开始,遗漏了第 11-13 条。这就是经典的"分页漂移"问题,在数据频繁写入的 DApp 场景中极为常见。

性能特征OFFSET 10000 LIMIT 10需要数据库扫描 10010 条记录然后丢弃 10000 条。偏移量越大,查询越慢——这是 O(n) 的退化。

2.2 游标分页:稳定但需要索引支持

游标分页不依赖行号,而是依赖一个唯一、有序的标识符(cursor)来标记边界。在使用 The Graph 子图时,游标通常基于(block_number, log_index)的组合。

工作原理:将cursor字段和索引字段传入WHERE条件,使用索引快速定位起点的下一条。

核心优势:游标是数据位置的"锚点",即使列表中间插入新数据,锚点之后的连续序列不会被打断。后端可以走索引扫描(WHERE cursor_value > $cursor ORDER BY cursor_value LIMIT $first),时间复杂度 O(log n)。

代价:游标分页不支持直接跳页("跳到第 5 页"),只能沿一个方向顺序遍历。对于需要显示"第 1/2/3 页"的分页 UI 不够友好。而且游标必须是有序且唯一的——在区块链数据中,同时刻产生多条事件时,需要额外使用log_indextransaction_index来确保唯一性。

2.3 Relay 规范分页:标准化但开销略高

Relay Connection Spec 是 Facebook 定义的 GraphQL 分页标准,它将分页数据规范化为edgespageInfo两层结构:

type TransactionConnection { edges: [TransactionEdge!]! pageInfo: PageInfo! totalCount: BigInt! } type TransactionEdge { node: Transaction! cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String! endCursor: String! }

Relay 规范的核心价值在于约定了一套统一的接口范式,使任何实现了该规范的 API 能被 Relay 客户端或 Apollo Client 的fetchMore无缝消费。代价是数据包装层多了一层edgesnode的间接访问,在数据量极大时轻微的序列化开销需要评估。

三、代码实践:The Graph DApp 的分页实现对比

/** * GraphQL 分页方案实现对比 * * 设计决策: * - 游标分页作为默认方案:DApp 数据场景以无限滚动为主 * - 偏移量分页仅在管理后台的"跳页"场景使用 * - Relay 规范用于需要与 Relay/Apollo Cache 深度集成的场景 * - 所有分页请求加入 request deduplication 避免重复请求 */ import { GraphQLClient, gql } from 'graphql-request'; // ==================== 偏移量分页实现 ==================== interface OffsetPageParams { skip: number; first: number; } interface OffsetPageResult<T> { items: T[]; total: number; hasMore: boolean; } class OffsetPagination { private client: GraphQLClient; constructor(endpoint: string) { this.client = new GraphQLClient(endpoint); } /** * 偏移量分页查询 * 使用场景:管理后台的表格分页(需要跳页功能) * 注意:每次请求都会触发全量计数查询,大数据量下需要慎用 */ async fetchPage<T>( query: string, params: OffsetPageParams ): Promise<OffsetPageResult<T>> { const document = gql` query($skip: Int!, $first: Int!) { transfers( skip: $skip first: $first orderBy: blockNumber orderDirection: desc ) { id from to value blockNumber } # 全量计数:The Graph 支持但大数据量下性能差 transfersCounter: transfers { id } } `; const data = await this.client.request<{ transfers: T[]; transfersCounter: T[]; }>(document, params); return { items: data.transfers, total: data.transfersCounter.length, hasMore: params.skip + params.first < data.transfersCounter.length }; } } // ==================== 游标分页实现 ==================== interface CursorPageParams { first: number; after?: string; // 上一页返回的游标 } interface CursorPageResult<T> { items: T[]; endCursor: string | null; // 下一页的起点游标 hasMore: boolean; } class CursorPagination { private client: GraphQLClient; private dedupTokens = new Map<string, string>(); // 请求去重 private pageCache = new Map<string, any>(); // 分页缓存 constructor(endpoint: string) { this.client = new GraphQLClient(endpoint); } /** * 游标分页查询 * 使用场景:DApp 前端的无限滚动列表(交易记录、NFT 列表) * * 设计决策: * - 使用 (blockNumber, logIndex) 双字段排序保证唯一性 * - endCursor 编码为 `${blockNumber}_${logIndex}` 方便解析 * - 多查询一个 item(first + 1)判断 hasMore,避免额外请求 */ async fetchNextPage<T>( after?: string ): Promise<CursorPageResult<T>> { const first = 20; const document = gql` query($first: Int!, $after: String) { deposits( first: $first # 游标解析:将 after 拆分为 blockNumber 和 logIndex where: { ${after ? this.buildCursorWhere(after) : ''} } orderBy: blockNumber orderDirection: desc ) { id user amount blockNumber logIndex } } `; const data = await this.client.request<{ deposits: Array<T & { blockNumber: string; logIndex: string }>; }>(document, { first: first + 1, after }); // +1 判断 hasMore const hasMore = data.deposits.length > first; const items = hasMore ? data.deposits.slice(0, first) : data.deposits; // 构造游标 const endCursor = items.length > 0 ? `${items[items.length - 1].blockNumber}_${items[items.length - 1].logIndex}` : null; return { items, endCursor, hasMore }; } /** * 将游标解析为 GraphQL where 条件 * 设计决策:使用双字段条件确保排序的严格单调 * WHERE (blockNumber < cursorBlock) * OR (blockNumber = cursorBlock AND logIndex < cursorLog) */ private buildCursorWhere(after: string): string { const [blockNum, logIdx] = after.split('_'); return ` or: [ { blockNumber_lt: "${blockNum}" } { blockNumber: "${blockNum}" logIndex_lt: "${logIdx}" } ] `; } /** * 请求去重:同一游标的并发请求合并 * 设计决策:在 React 的 StrictMode 或快速滚动场景下, * 同一页可能被多次请求,通过 token 机制避免重复 */ private deduplicate(key: string, fetcher: () => Promise<any>): Promise<any> { if (this.dedupTokens.has(key)) { return this.dedupTokens.get(key) as Promise<any>; } const promise = fetcher().finally(() => { this.dedupTokens.delete(key); }); this.dedupTokens.set(key, promise); return promise; } } // ==================== Relay 规范分页实现 ==================== interface RelayConnection<T> { edges: Array<{ node: T; cursor: string; }>; pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor: string; endCursor: string; }; } class RelayPagination { private client: GraphQLClient; constructor(endpoint: string) { this.client = new GraphQLClient(endpoint); } /** * Relay Connection 查询 * 使用场景:与 Apollo Client RelayStylePagination 缓存策略配合 * * 设计决策: * - 使用 @connection 指令为 Apollo Cache 提供稳定 key * - 游标编码使用 base64(timestamp_blockNumber_logIndex) 增强可读性 */ async fetchConnection<T>( first: number = 20, after?: string ): Promise<RelayConnection<T>> { const document = gql` query($first: Int!, $after: String) { swaps( first: $first after: $after orderBy: blockTimestamp orderDirection: desc ) @connection(key: "userSwaps") { edges { node { id tokenIn { symbol amount } tokenOut { symbol amount } blockTimestamp } cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } `; const data = await this.client.request<{ swaps: RelayConnection<T>; }>(document, { first, after }); return data.swaps; } } // ==================== React Hook 集成 ==================== import { useState, useCallback, useRef } from 'react'; /** * 通用游标分页 Hook * 设计决策: * - 使用 useRef 追踪游标避免闭包陷阱 * - 内置加载状态和错误处理 * - 支持手动刷新(重置游标) */ function useCursorPagination<T>( fetcher: (after?: string) => Promise<CursorPageResult<T>> ) { const [items, setItems] = useState<T[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [hasMore, setHasMore] = useState(true); // 游标追踪:使用 ref 避免在闭包中使用过时的 state const endCursorRef = useRef<string | null>(null); /** * 加载第一页(重置所有状态) */ const loadFirstPage = useCallback(async () => { setLoading(true); setError(null); endCursorRef.current = null; try { const result = await fetcher(); setItems(result.items); setHasMore(result.hasMore); endCursorRef.current = result.endCursor; } catch (err) { setError(err as Error); } finally { setLoading(false); } }, [fetcher]); /** * 加载下一页(追加到已有列表) */ const loadNextPage = useCallback(async () => { if (loading || !hasMore) return; setLoading(true); try { const result = await fetcher(endCursorRef.current ?? undefined); setItems((prev) => [...prev, ...result.items]); setHasMore(result.hasMore); endCursorRef.current = result.endCursor; } catch (err) { setError(err as Error); } finally { setLoading(false); } }, [fetcher, loading, hasMore]); return { items, loading, error, hasMore, loadFirstPage, loadNextPage }; }

四、边界分析:每种分页方案的适用边界

游标分页的退化:双向遍历需求

游标分页天然支持单向(向前或向后)遍历,但不支持从中间位置向两个方向同时扩展。如果你的 DApp 需要展示一个可双向滚动的交易时间轴(用户可以向上翻看未来交易,向下翻看历史交易),就需要基于时间范围的"时间窗口分页":用WHERE timestamp BETWEEN $start AND $end替代游标条件。但这并非真正的分页,只是对时间范围的过滤。

偏移量分页的退化:subgraph 的 1000 条限制

The Graph 的子图(subgraph)默认对单次查询返回的记录数有上限(通常是 1000 条)。使用偏移量分页时,如果skip值尝试超过这个上限,查询会直接失败。这意味着偏移量分页在 subgraph 上无法处理超过 1000 条的数据集——这是一个硬限制,不是你通过优化能绕过的。对于超过 1000 条的场景,必须切换到游标分页。

Relay 规范的开销:无线滚动中的重复序列化

Relay 规范要求每条数据包装在{ cursor, node }结构中。对于列表有 10000 条数据的 DApp,用户滚动到底部时,所有 10000 条记录的 cursor 都会被序列化——即使 cursor 在客户端根本不被使用。在数据量大且 cursor 字段较长时,JSON 序列化开销会占网络传输的 10-15%。

游标分页的排序一致性要求

游标分页要求排序字段在两次查询之间保持一致性。在区块链数据场景中,block_number是增量递增的,天然适配。但如果你按amount(交易金额)排序,两笔等额交易之间的先后顺序可能因索引顺序而波动,导致分页的稳定性被破坏。对于非单调排序字段,需要二级排序字段(如idlog_index)保证确定性。

跨分页缓存失效

游标分页的缓存策略通常是"新数据追加到列表头部"。但如果中间数据被修改(如交易状态从 pending 变为 confirmed),现有的分页切片中可能包含过时数据。解决方案是使用 GraphQL subscriptions 监听特定 ID 的变更事件,在本地缓存中更新对应的条目。

五、总结

三种分页模式的取舍如下:

模式适用场景核心优势核心缺陷
偏移量分页管理后台、固定数据集实现简单、支持跳页漂移问题、O(n)性能退化
游标分页无限滚动、实时列表消除漂移、O(log n)不支持跳页、需有序索引
Relay规范Apollo Cache集成标准化、工具链生态序列化开销、包装层冗余

对于 DApp 的绝大多数场景(交易列表、持仓展示、事件流水),游标分页是默认最优选择。偏移量分页仅保留用于管理后台或确信数据集不会在分页过程中发生插入的场景。Relay 规范的选择取决于你的 GraphQL 客户端生态——如果已经重度使用 Apollo Client 且开启了RelayStylePagination缓存策略,那么接受 Relay 规范的包装开销是合理的;否则,直接使用游标分页的 API 更轻量。

核心原则:在数据持续写入的场景下,永远不要让偏移量决定你的分页边界。游标才是真正稳定的锚点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询