
GraphQL 批处理请求的传输优化Multipart Response 与增量交付在 DApp 的应用一、GraphQL 在 DApp 中的过度索取困境以一个链上资产仪表盘为例页面需要展示的数据包括用户的所有 ERC-20 代币余额、各协议的 LP 仓位状态、最近的 20 笔交易记录、每条链的当前 Gas 价格、NFT 持仓和对应的地板价。在 REST 架构下这意味着至少 5-8 个独立的 API 调用。GraphQL 通过合并所有数据需求为单次请求显著减少了往返次数。但 GraphQL 也引入了自身的性能问题。一个包含多个独立数据源的 Query 在执行时是串行的——GraphQL 引擎按解析器顺序逐一执行直到所有字段解析完毕才会返回响应。如果Gas 价格查询需要 1 秒完成而其他字段只需要 200ms整个请求的响应时间仍然是 1 秒。用户的仪表盘在 1 秒内一片空白然后在某刻全部出现。这个问题的本质是GraphQL 的响应模型与渐进式渲染的用户体验需求之间存在断层。解决方向有两个批处理传输优化将多个独立请求合并传输和增量交付先返回已就绪字段后续字段流式追加。二、Multipart Response 与增量交付的协议细节2.1 Multipart Response 的流式语义GraphQL 的 Multipart Response 规范通过defer和stream指令允许服务端分多个部分返回响应。每个部分是一个独立的 JSON 对象通过 HTTP 的分块传输编码Chunked Transfer Encoding或 WebSocket 逐帧发送。一个典型的 Multipart Response 像这样--graphql Content-Type: application/json {data:{account:{address:0x...},balances:null},hasNext:true} --graphql Content-Type: application/json {data:{balances:[{token:ETH,amount:1.5}],gasPrice:25},hasNext:false} --graphql--客户端解析这个流时每接收一个 Part 就立即渲染其对应的 UI 组件不需要等待完整响应。这种模式与 React 的 Streaming SSR 思想一致——界面逐步上线而非整体加载。2.2defer与stream的适用性区别defer适用于独立字段延迟加载但不改变字段的结构。例如查询中gasPrice defer表示这个字段可以稍后返回但最终值仍然是一个标量。stream适用于列表字段逐步流式返回列表中的元素。例如transactions(first: 20) stream(initialCount: 5)表示先返回前 5 条记录剩余 15 条逐批到达。在 DApp 场景中defer适用于查询慢但值不大的字段如 Gas 价格查询、协议 TVL 聚合stream适用于数据量大但可分批的字段如交易历史、资产列表。2.3 批处理请求的传输优化GraphQL 标准定义了application/graphql-responsejson作为标准 Content-Type但不支持批处理。可以将多个独立 Query 打包为一个 array在服务端支持Content-Type: application/json的数组格式[ {query: query { account(id: \0x...\) { balance } }}, {query: query { gasPrice }}, {query: query { transactions(first: 10) { hash } }} ]服务端以 Multipart 数组返回每个 Part 包含一个独立 Query 的结果。客户端的优势在于三个请求共享一个 HTTP 连接避免了多次 DNS 解析和 TCP/TLS 握手。在 DApp 的高频场景页面加载时通常同时发出 3-8 个独立查询这可以减少 40-60% 的连接建立开销。三、代码实践GraphQL 网关与增量渲染客户端/** * GraphQL 批处理 增量交付网关 * * 设计决策 * 1. 批处理路由 /graphql-batch 接收 Query 数组 * 以 Multipart Response 返回部分结果 * 每个已完成的 Query 立即 flush 到客户端。 * 2. Query 级别的超时控制30 秒未完成的子查询被丢弃 * 避免单个慢查询阻塞整个批次响应。 * 3. DApp 特化指令 ethCall、chainData * 服务端自动注入链上数据解析器 * 减少前端编写重复 GraphQL Schema 的负担。 * * 技术栈graphql-yoga Node.js 流式响应 */ import { createYoga, createSchema } from graphql-yoga; import { createServer } from node:http; import { Readable } from node:stream; // ── Schema 定义 ── const typeDefs directive defer(if: Boolean, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT type Account { address: String! balance: String! # defer: 该字段可能延迟到达在 Multipart Response 中作为独立 Part 返回 gasPrice: String defer } type Transaction { hash: String! from: String! to: String! value: String! timestamp: Int! } type Query { account(id: String!): Account # stream: 列表数据逐步返回 transactions(first: Int!): [Transaction!]! gasPrice: String } ; // 模拟延迟数据源 async function fetchGasPrice(): Promisestring { // 实际调用 eth_gasPrice网络延迟 800ms return new Promise((resolve) setTimeout(() resolve((Math.random() * 50 20).toFixed(1)), 800) ); } async function fetchTransactions(first: number): Promiseany[] { // 模拟批量交易查询 await new Promise((resolve) setTimeout(resolve, 500)); return Array.from({ length: first }, (_, i) ({ hash: 0x${i.toString(16).padStart(64, 0)}, from: 0xabcdef, to: 0x123456, value: 1000000000000000000, timestamp: Date.now() - i * 15000, })); } // 解析器 const resolvers { Query: { account: async (_: any, { id }: { id: string }) ({ address: id, balance: 5.23, }), transactions: async (_: any, { first }: { first: number }) { return fetchTransactions(first); }, gasPrice: () fetchGasPrice(), }, Account: { gasPrice: () fetchGasPrice(), }, }; const yoga createYoga({ schema: createSchema({ typeDefs, resolvers }), }); // ── 批处理端点 ── // POST /graphql-batch // Body: [{ query: ..., variables: {} }, ...] // Response: multipart/mixed; boundarygraphql async function handleBatch(req: Request): PromiseResponse { const queries await req.json() as Array{ query: string; variables?: Recordstring, unknown; }; const BOUNDARY graphql; const stream new Readable({ read() { // 手动控制读取 }, }); // 并行执行所有 Query const results queries.map(async (q, index) { // 每个子请求 30 秒超时 const controller new AbortController(); const timeout setTimeout(() controller.abort(), 30_000); try { // 模拟 GraphQL 执行 // 生产环境应调用 graphql-js 的 execute 函数 const response await yoga.fetch( new Request(http://internal/graphql, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(q), signal: controller.signal, }) ); const data await response.json(); // 每个完成的 Query 作为一个 Part 推送到流 stream.push(--${BOUNDARY}\r\n); stream.push(Content-Type: application/json\r\n); stream.push(X-Query-Index: ${index}\r\n\r\n); stream.push(JSON.stringify({ index, data: data.data, errors: data.errors })); stream.push(\r\n); } catch (err: unknown) { const message err instanceof Error ? err.message : String(err); // 超时或错误返回错误 Part stream.push(--${BOUNDARY}\r\n); stream.push(Content-Type: application/json\r\n); stream.push(X-Query-Index: ${index}\r\n\r\n); stream.push(JSON.stringify({ index, data: null, errors: [{ message }], })); stream.push(\r\n); } finally { clearTimeout(timeout); } }); // 所有 Query 完成后发送结束标记 await Promise.allSettled(results); stream.push(--${BOUNDARY}--\r\n); stream.push(null); return new Response( Readable.toWeb(stream) as ReadableStream, { status: 200, headers: { Content-Type: multipart/mixed; boundary${BOUNDARY}, Cache-Control: no-store, X-Batch-Size: String(queries.length), }, } ); } // ── 客户端增量渲染 Hook ── import { useEffect, useState, useCallback } from react; interface BatchQueryResult { index: number; data: unknown; errors?: Array{ message: string }; } function useBatchGraphQL(queries: Array{ query: string; variables?: Recordstring, unknown; }) { const [results, setResults] useStateMapnumber, BatchQueryResult( new Map() ); const [isStreaming, setIsStreaming] useState(true); useEffect(() { const controller new AbortController(); const resultMap new Mapnumber, BatchQueryResult(); (async () { try { const response await fetch(/graphql-batch, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(queries), signal: controller.signal, }); if (!response.body) return; const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const parts buffer.split(\r\n--graphql\r\n); // 最后一个不完整 Part 保留在 buffer 中 buffer parts.pop() || ; for (const part of parts) { const parsed parseMultipartPart(part); if (parsed) { resultMap.set(parsed.index, parsed); // 增量更新每个 Part 到达时立即触发重渲染 setResults(new Map(resultMap)); } } } // 处理最后一个 Part if (buffer.includes(--graphql--)) { const lastPart buffer.replace(--graphql--, ).trim(); if (lastPart) { const parsed parseMultipartPart(lastPart); if (parsed) resultMap.set(parsed.index, parsed); } } setResults(new Map(resultMap)); setIsStreaming(false); } catch (err) { if ((err as Error).name ! AbortError) { console.error(Batch query failed:, err); } setIsStreaming(false); } })(); return () controller.abort(); }, [JSON.stringify(queries)]); return { results, isStreaming }; } function parseMultipartPart(raw: string): BatchQueryResult | null { const bodyStart raw.indexOf(\r\n\r\n); if (bodyStart -1) return null; const headers raw.slice(0, bodyStart); const match headers.match(/X-Query-Index: (\d)/); if (!match) return null; const body raw.slice(bodyStart 4); try { const json JSON.parse(body); return { index: parseInt(match[1]), ...json }; } catch { return null; } } // ── 使用示例 ── function Dashboard() { const { results, isStreaming } useBatchGraphQL([ { query: query { account(id: 0x123) { address balance } } }, { query: query { gasPrice } }, { query: query { transactions(first: 10) { hash value } } }, ]); return ( div {/* 账户信息通常最快到达 */} AccountCard data{results.get(0)?.data} loading{!results.has(0) isStreaming} / {/* Gas 价格通常最慢 */} GasPriceCard data{results.get(1)?.data} loading{!results.has(1) isStreaming} / {/* 交易列表流式逐步填充 */} TransactionList data{results.get(2)?.data} loading{!results.has(2) isStreaming} / /div ); }设计决策的三个关键点Query 级别的超时在批处理场景中如果某个 Query 因为后端数据源延迟而挂起不能让它阻塞整个流。30 秒超时后返回错误 Part其他 Query 的结果照常送达。边界计数器X-Query-Index头标记每个 Part 对应的原始 Query 索引。客户端通过这个索引将流片段映射到正确的 UI 组件不会因为到达顺序变化而产生错位。增量的不可变性每次setResults(new Map(resultMap))创建新的 Map 实例确保 React 能检测到状态变化并触发所需组件的重渲染。未变化组件的results.get(N)返回相同的引用。四、边界分析Multipart Response 的代理兼容性部分 HTTP 代理、CDN 或 Service Worker 可能不支持或缓冲了 Multipart Response导致客户端收不到逐 Part 推送的效果。在 CDN 层面需要关闭响应缓冲或使用 WebSocket 替代。测试时优先用直连验证再逐层排查中间件。慢 Query 的 UI 体验如果gasPrice字段延迟 3 秒到达UI 组件会在 3 秒内显示 loading 状态。用户可能认为应用卡住了而刷新页面。需要为每个 Part 的超时设置不同的 UX 策略——如 1 秒后显示骨架屏3 秒后显示数据来源延迟提示。Query 依赖关系破坏批处理独立性批处理的前提是 Query 之间相互独立。如果一个 Query 的结果需要作为另一个 Query 的变量批处理就无法直接工作。解决方案是将有依赖关系的 Query 在客户端分两轮发送第一轮获取前置数据第二轮用结果构建后续查询。内存压力在高并发场景下服务端同时持有大量未完成的 Query Promise 和缓存中的响应 Part。需要限制并发批处理请求数如 50 个同时进行并在流关闭时清理所有关联资源。与链上 RPC 的二次耦合GraphQL 解析器内部如果直接调用eth_getLogs或eth_callGraphQL 层的优化效果会被 RPC 层的瓶颈抵消。建议在 GraphQL 和 RPC 之间引入缓存层Redis将高频查询结果 TTL 设置为一个区块时间12 秒。五、总结GraphQL 在 DApp 中的批处理和增量交付优化解决的其实是同一个问题如何让界面在数据尚未完全就绪时就已经开始为用户提供价值。Multipart Response 让快数据先到先渲染defer让慢数据不拖累整体体验批处理消除了多次连接的协商开销。这些优化叠加后的效果是用户打开仪表盘的第 200ms 看到账户余额第 500ms 看到交易列表的前 5 条第 1200ms 看到 Gas 建议价格。每个时间节点界面都在更完整的过程中而非等待最后一笔数据到达后才整体出现。对于 DApp 这种天然存在大量独立数据源的应用场景增量交付不是锦上添花而是用户体验的底层要求。用户在中心化金融应用如 Robinhood中已经习惯了秒级数据就绪去中心化应用需要在传输协议层面弥补链上查询的固有时延差距。