GraphQL 分页模式深度对比:基于游标、偏移量与 Relay 规范的 DApp 实践选择 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[请求: skip0, first10] -- R1[返回: 第1-10条] O2[请求: skip10, first10] -- R2[返回: 第11-20条] O3[请求: skip20, first10] -- R3[返回: 第21-30条] end subgraph 游标分页 C1[请求: first10] -- CR1[返回: items endCursor] C2[请求: first10, afterendCursor] -- CR2[返回: items endCursor] C3[请求: first10, afterendCursor] -- 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 端通常暴露为skipfirst参数。工作原理数据库扫描skip first条记录然后丢弃前skip条返回剩余first条。致命缺陷当数据在两次查询之间插入或删除时偏移量会漂移。例如你查询skip0, first10获得第 1-10 条记录但在下一次查询skip10, first10之前有人在列表头部插入了 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_index或transaction_index来确保唯一性。2.3 Relay 规范分页标准化但开销略高Relay Connection Spec 是 Facebook 定义的 GraphQL 分页标准它将分页数据规范化为edges和pageInfo两层结构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无缝消费。代价是数据包装层多了一层edges→node的间接访问在数据量极大时轻微的序列化开销需要评估。三、代码实践The Graph DApp 的分页实现对比/** * GraphQL 分页方案实现对比 * * 设计决策 * - 游标分页作为默认方案DApp 数据场景以无限滚动为主 * - 偏移量分页仅在管理后台的跳页场景使用 * - Relay 规范用于需要与 Relay/Apollo Cache 深度集成的场景 * - 所有分页请求加入 request deduplication 避免重复请求 */ import { GraphQLClient, gql } from graphql-request; // 偏移量分页实现 interface OffsetPageParams { skip: number; first: number; } interface OffsetPageResultT { items: T[]; total: number; hasMore: boolean; } class OffsetPagination { private client: GraphQLClient; constructor(endpoint: string) { this.client new GraphQLClient(endpoint); } /** * 偏移量分页查询 * 使用场景管理后台的表格分页需要跳页功能 * 注意每次请求都会触发全量计数查询大数据量下需要慎用 */ async fetchPageT( query: string, params: OffsetPageParams ): PromiseOffsetPageResultT { 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 CursorPageResultT { items: T[]; endCursor: string | null; // 下一页的起点游标 hasMore: boolean; } class CursorPagination { private client: GraphQLClient; private dedupTokens new Mapstring, string(); // 请求去重 private pageCache new Mapstring, any(); // 分页缓存 constructor(endpoint: string) { this.client new GraphQLClient(endpoint); } /** * 游标分页查询 * 使用场景DApp 前端的无限滚动列表交易记录、NFT 列表 * * 设计决策 * - 使用 (blockNumber, logIndex) 双字段排序保证唯一性 * - endCursor 编码为 ${blockNumber}_${logIndex} 方便解析 * - 多查询一个 itemfirst 1判断 hasMore避免额外请求 */ async fetchNextPageT( after?: string ): PromiseCursorPageResultT { 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: ArrayT { 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: () Promiseany): Promiseany { if (this.dedupTokens.has(key)) { return this.dedupTokens.get(key) as Promiseany; } const promise fetcher().finally(() { this.dedupTokens.delete(key); }); this.dedupTokens.set(key, promise); return promise; } } // Relay 规范分页实现 interface RelayConnectionT { 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 fetchConnectionT( first: number 20, after?: string ): PromiseRelayConnectionT { 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: RelayConnectionT; }(document, { first, after }); return data.swaps; } } // React Hook 集成 import { useState, useCallback, useRef } from react; /** * 通用游标分页 Hook * 设计决策 * - 使用 useRef 追踪游标避免闭包陷阱 * - 内置加载状态和错误处理 * - 支持手动刷新重置游标 */ function useCursorPaginationT( fetcher: (after?: string) PromiseCursorPageResultT ) { const [items, setItems] useStateT[]([]); const [loading, setLoading] useState(false); const [error, setError] useStateError | null(null); const [hasMore, setHasMore] useState(true); // 游标追踪使用 ref 避免在闭包中使用过时的 state const endCursorRef useRefstring | 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交易金额排序两笔等额交易之间的先后顺序可能因索引顺序而波动导致分页的稳定性被破坏。对于非单调排序字段需要二级排序字段如id或log_index保证确定性。跨分页缓存失效游标分页的缓存策略通常是新数据追加到列表头部。但如果中间数据被修改如交易状态从 pending 变为 confirmed现有的分页切片中可能包含过时数据。解决方案是使用 GraphQL subscriptions 监听特定 ID 的变更事件在本地缓存中更新对应的条目。五、总结三种分页模式的取舍如下模式适用场景核心优势核心缺陷偏移量分页管理后台、固定数据集实现简单、支持跳页漂移问题、O(n)性能退化游标分页无限滚动、实时列表消除漂移、O(log n)不支持跳页、需有序索引Relay规范Apollo Cache集成标准化、工具链生态序列化开销、包装层冗余对于 DApp 的绝大多数场景交易列表、持仓展示、事件流水游标分页是默认最优选择。偏移量分页仅保留用于管理后台或确信数据集不会在分页过程中发生插入的场景。Relay 规范的选择取决于你的 GraphQL 客户端生态——如果已经重度使用 Apollo Client 且开启了RelayStylePagination缓存策略那么接受 Relay 规范的包装开销是合理的否则直接使用游标分页的 API 更轻量。核心原则在数据持续写入的场景下永远不要让偏移量决定你的分页边界。游标才是真正稳定的锚点。