
GraphQL 缓存策略深度对比Normalized Cache、Response Cache 在 DApp 中的取舍一、DApp 的缓存困境当链上状态通过 GraphQL 流入前端区块链数据查询有三条路径直接读 RPC 节点、通过 The Graph 子图索引、使用中心化索引 API。其中子图方案已成为主流——它用 GraphQL 接口暴露链上事件经过处理后的结构化数据。一个典型的 DApp 首页可能需要查询用户持仓、池子流动性、质押奖励和历史交易记录5-8 个并发 GraphQL 查询是常态。问题在于这些查询结果在客户端如何被缓存考虑一个场景用户在 Swap 页面修改滑点容忍度整个页面的持仓列表随之重新请求——即使链上持仓数据并没有变化。没有合理的缓存策略子图的查询量会指数级增长同时用户体验因为重复加载而下降。与 REST API 不同GraphQL 的缓存问题更复杂同一实体可能以不同字段组合出现在多个查询中缓存的归一化和一致性维护是核心挑战。还有一个容易被忽略的维度——链上数据虽然最终一致但不同索引节点之间的数据新鲜度存在差异。一个用户通过子图 A 查询到持仓数据另一个用户通过子图 B 查询到同一池子的数据两者的延迟窗口可能导致数据不一致。Normalized Cache 在解决跨查询实体一致性上优势明显但其写后失效cache invalidation策略需要与链上确认深度对齐confirmed级别的事件可能在 1-2 个区块后回滚finalized级别的事件确认时间较长。设计缓存 TTL 时最小值不应低于链上最终确认的时间窗口。二、三种缓存策略的架构与数据流flowchart TD subgraph 客户端 Q[GraphQL 查询] end subgraph 缓存策略层 NC[Normalized Cache] RC[Response Cache] DC[Document Cache] end subgraph 存储层 NCS[(Normalized Storebr/实体-关系映射)] RCS[(Response Cache Storebr/查询key-响应映射)] DCS[(Document Cache Storebr/queryHash-结果映射)] end subgraph 子图 SG[The Graph Indexer] end Q --|需要跨查询一致性| NC Q --|查询模式固定| RC Q --|查询极少变化| DC NC --|实体读写| NCS NC --|缓存未命中| SG RC --|查询key匹配| RCS RC --|缓存未命中| SG DC --|queryHash匹配| DCS DC --|缓存未命中| SG NC --|实体变更自动更新| Q RC --|仅当key匹配返回| QResponse Cache是最简单的策略以完整查询和变量作为 key缓存整个 response 对象。当同一个查询完全相同字段、完全相同参数再次请求时直接返回缓存。优势是实现简单缓存命中时的读取速度最高。劣势是极度脆弱——同一实体只要查询字段略有不同就是完全不同的缓存条目。Document Cache是 Response Cache 的变体将 query document 哈希作为 key不管变量如何变化同一查询结构共享缓存。这在 DApp 场景中用处有限因为 DApp 查询的变量用户地址、池子 ID频繁变化。Normalized Cache是复杂但强大的方案将每个实体User、Pool、Token提取为独立记录查询结果通过实体引用来组装。当某个实体被更新时所有引用该实体的查询自动反映最新数据。这在数据有明确实体边界、存在交叉引用的 DApp 场景中优势明显。三、Normalized Cache 在 DApp 中的实现使用 URQL 的 Normalized Cache 配合 The Graph 子图查询// lib/urql-client.ts import { createClient, cacheExchange, fetchExchange } from urql; import { normalizedExchange } from urql/exchange-graphcache; /** * 子图缓存 Schema 定义 * 设计决策 * 1. 每个实体定义 keys 函数告诉 cache 如何生成唯一标识 * 2. resolvers 定义实体间的引用关系使关联查询可以复用缓存 * 3. updates 处理 mutation 后对缓存的手动更新 */ const cache cacheExchange({ keys: { // 自定义实体主键 User: (data: any) data.id ?? null, Pool: (data: any) data.id ?? null, Token: (data: any) data.id ?? null, LiquidityPosition: (data: any) ${data.user?.id}_${data.pool?.id} ?? null, Swap: (data: any) ${data.transaction?.id}_${data.logIndex} ?? null, }, resolvers: { Query: { // 通过 pool.id 查询单个池子时从已缓存的 pools 列表中提取避免重复请求 pool(_parent, args, cache) { const pool cache.resolve( { __typename: Pool, id: args.id }, id ); // 如果实体已在缓存中直接返回引用 return pool ? cache.resolve(Pool, args.id as string) : undefined; }, }, Pool: { // 池子关联的代币通过 resolve 建立引用关系 token0(parent, _args, cache) { return cache.resolve(Token, parent.token0?.id); }, token1(parent, _args, cache) { return cache.resolve(Token, parent.token1?.id); }, }, LiquidityPosition: { user(parent, _args, cache) { return cache.resolve(User, parent.user?.id); }, pool(parent, _args, cache) { return cache.resolve(Pool, parent.pool?.id); }, }, }, updates: { Mutation: { // swap 完成后更新相关池子的流动性和用户余额 swap(result, _args, cache) { if (!result.swap) return; const swap result.swap; // 更新池子储备量 cache.updateQuery({ query: PoolQuery }, (data) { if (!data?.pool) return data; return { pool: { ...data.pool, reserve0: swap.pool.reserve0, reserve1: swap.pool.reserve1, totalValueLockedUSD: swap.pool.totalValueLockedUSD, }, }; }); }, }, }, }); export const subgraphClient createClient({ url: process.env.NEXT_PUBLIC_SUBGRAPH_URL!, exchanges: [cache, fetchExchange], });// hooks/useUserPortfolio.ts import { useQuery } from urql; import { graphql } from /gql; /** * 用户持仓查询 * Normalized Cache 的作用 * - User 实体被归一化存储跨查询共享 * - LiquidityPosition 的 pool 引用会自动解析到已缓存的 Pool 实体 * - 当通过 swap mutation 更新某个 Pool 后引用该 Pool 的持仓数据自动刷新 */ const UserPortfolioDocument graphql( query UserPortfolio($userId: ID!) { user(id: $userId) { id liquidityPositions(first: 50) { id liquidityTokenBalance pool { id token0 { id symbol name } token1 { id symbol name } totalValueLockedUSD volumeUSD } } } pools(first: 100, orderBy: totalValueLockedUSD, orderDirection: desc) { id token0 { id symbol } token1 { id symbol } totalValueLockedUSD feesUSD } } ); export function useUserPortfolio(userId?: string) { const [result] useQuery({ query: UserPortfolioDocument, variables: { userId: userId || }, pause: !userId, // 每 15 秒重新查询但在 Normalized Cache 下仍可利用实体缓存减少数据传输 requestPolicy: cache-and-network, }); return result; }对于不适用 Normalized Cache 的查询如聚合统计、排行榜使用 Response Cache 策略// lib/response-cache.ts /** * 简单的 Response Cache 实现 * 设计决策使用 LRU TTL 策略适合聚合类查询 * 排行榜数据不需要归一化全量替换即可 */ class ResponseCache { private cache new Mapstring, { data: any; timestamp: number }(); private maxSize 200; private ttl 30_000; // 30 秒 TTL get(key: string): any | null { const entry this.cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp this.ttl) { this.cache.delete(key); return null; } // LRU: 将访问的 key 移到末尾 this.cache.delete(key); this.cache.set(key, entry); return entry.data; } set(key: string, data: any): void { // 容量超限时移除最早的条目 if (this.cache.size this.maxSize) { const firstKey this.cache.keys().next().value; if (firstKey ! undefined) { this.cache.delete(firstKey); } } this.cache.set(key, { data, timestamp: Date.now() }); } invalidate(pattern?: RegExp): void { if (!pattern) { this.cache.clear(); return; } for (const key of this.cache.keys()) { if (pattern.test(key)) this.cache.delete(key); } } } export const responseCache new ResponseCache();四、两种策略的适用边界Normalized Cache 适用场景实体类型明确且有关联关系User/Pool/Token同一实体被多个查询以不同字段组合使用需要 mutation 后自动更新相关查询数据更新频繁但单实体变更范围小不适用的场景聚合查询如过去 24 小时交易量排行——数据本质上是瞬时的归一化无意义分页列表——页码与偏移量参数破坏了实体的唯一性时序数据——每个时间点都是新实体归一化缓存会无限膨胀Response Cache 适用场景查询模式固定、变量不频繁变化的场景聚合统计、排行榜等无实体边界的数据短 TTL 的实时数据价格、Gas 费实践中两种策略应该混合使用Normalized Cache 作为默认缓存层Response Cache 通过context参数在特定查询上覆盖缓存策略。URQL 支持通过cache-only、network-only等策略在任何查询级覆盖缓存行为。混合策略的边界需要明确约定哪些查询走 Normalized Cache实体类的 CRUD哪些走 Response Cache聚合统计哪些跳过缓存实时 Gas 价格、代币汇率。这个约定应该写成团队规范文档而非隐式散布在代码中——否则新加入的开发者会在不知情的情况下为排行榜查询打开 Normalized Cache导致实体膨胀。五、总结在 DApp 的 GraphQL 缓存选择上没有放之四海皆准的方案。Normalized Cache 在处理有明确实体边界、跨查询引用的数据时提供了最佳的一致性和缓存利用率Response Cache 在聚合查询和实时数据场景中更简洁有效。关键决策因素不是缓存成熟度而是数据模型的特征——如果你的数据有清晰的实体关系和交叉引用投入 Normalized Cache 的实现成本会在应用复杂度增长时快速回收。如果数据主要是聚合统计和实时数值Response Cache 短 TTL 是务实选择。