GraphQL 请求批处理与增量交付:@defer、@stream 与 Batching

GraphQL 请求批处理与增量交付深度:HTTP 批处理、@defer/@stream 指令、增量加载与 Suspense 集成、实现方案与性能收益,帮助客户端减少首屏等待与网络往返。

GraphQL 的出现解决了 REST 的过度获取问题,但随之而来的是一个新瓶颈:网络往返。在移动弱网环境下,一次查询可能串行依赖多个数据源,而最慢的那个字段往往决定了整个请求的响应时间。请求批处理(Batching)与增量交付(Incremental Delivery)正是针对这一问题的两大工程手段:批处理把多个操作压缩进一次往返,增量交付则让客户端在部分数据就绪时立刻开始渲染。本文将深入 HTTP Batch、@defer、@stream 三种机制,并结合 Suspense 与 Apollo Client 的集成实践,帮助你把 GraphQL 客户端的首屏体验提升一个量级。

一、网络往返:GraphQL 客户端的隐藏成本

1.1 一次查询的完整生命周期

一个典型的 GraphQL 请求在服务端的生命周期可以拆解为四个阶段:解析(Parse)、校验(Validate)、执行(Execute)、序列化(Serialize)。前两个阶段处理查询字符串与类型系统,属于 CPU 密集的纯函数;真正的耗时往往集中在 Execute 阶段——它要遍历查询树,逐字段调用 resolver。

query HomeFeed {
  viewer {
    user { id name }
    notifications { id message }
  }
  feed(first: 20) {
    edges { node { id title } }
  }
  recommendedProducts { sku name price }
}

上面这条查询里有三个相互独立的根字段。如果服务端串行解析它们,响应时间就是三者的和;如果并行解析,则是三者中最大的那个。但无论并行还是串行,客户端都必须等待所有字段全部就绪才能收到响应——这就是「全量等待」问题。

1.2 慢字段拖垮整个请求

假设 recommendedProducts 依赖一个平均延迟 800ms 的推荐算法服务,而 viewer 只需要 50ms。没有增量交付时,整个请求的 P50 延迟就是 800ms 甚至更高。用户看到的空白页面时间,完全由最慢字段决定。

这种场景在真实业务中极其常见:首屏内容(用户信息、文章标题)很快,但边栏推荐、评论数、广告位等次要数据拖慢了整体。我们需要的不是「一次性拿全」,而是「先拿到快的,慢的随后到达」。

二、HTTP 批处理(Batching):一次连接承载多个操作

2.1 JSON Batch 协议

HTTP 批处理(Batch)指客户端在一次 HTTP 请求中携带多个 GraphQL 操作。业界最通用的协议是 Apollo 提出的 JSON Batch:请求体是一个 JSON 数组,每个元素是一个完整的 { query, variables, operationName } 结构。

[
  {
    "query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
    "variables": { "id": "42" }
  },
  {
    "query": "query GetOrders($userId: ID!) { ordersByUser(userId: $userId) { id total } }",
    "variables": { "userId": "42" }
  },
  {
    "query": "mutation TrackView($item: String!) { trackView(item: $item) { ok } }",
    "variables": { "item": "article/42" }
  }
]

服务端识别 Content-Type: application/json 且请求体以 [ 开头时,将数组中的每个操作独立执行,并按相同顺序返回结果数组。Apollo Server 2.x 与 graphql-request 客户端都原生支持这种协议。

2.2 客户端批量提交

客户端将多个独立操作合并成一次请求的关键在于「合并时机」。以 React 应用为例,组件挂载阶段常会同时发起多个查询,此时可以用微任务(microtask)窗口收集:

import { GraphQLClient } from 'graphql-request';

const client = new GraphQLClient('/graphql', {
  // 开启批处理:同一 tick 内的多个请求合并为一次
  batch: {
    enabled: true,
    batchMaxWait: 30,   // 收集窗口:30ms
    batchMaxSize: 10,   // 最多合并 10 个操作
  },
});

// 三个组件同时挂载,三次 fetch 被合并为一次 HTTP 往返
async function Home() {
  const [viewer, feed, products] = await Promise.all([
    client.request(VIEWER_QUERY),
    client.request(FEED_QUERY),
    client.request(PRODUCTS_QUERY),
  ]);
  // ...
}

合并后,三次请求共享 TCP 连接、TLS 握手与认证头,弱网下的收益尤为明显。Apollo Client 的 HttpLink 也支持通过 batch 配置开启类似行为。

2.3 批处理的边界与注意点

批处理并非免费午餐,有三个边界需要工程师特别注意。

第一,错误隔离。批处理中某个操作失败不会影响其他操作,但响应数组中对应元素会是错误对象,客户端必须按索引正确分发结果与错误,不能因一个失败就丢弃整批。

第二,超时语义。批内各操作耗时不同,整体响应时间由最慢者决定。因此批处理适合「互相独立且量级相近」的操作,不适合把「即时写操作」与「慢查询」混在一起。

第三,认证与审计。批处理让一条请求携带多个不同权限级别的操作,服务端必须基于「每个操作各自校验」而非「整批统一校验」的模型实现授权,日志中也应记录每个操作的单独标识。

三、@defer 指令:字段级延迟执行

3.1 语法与语义

@defer 指令用于标记查询中的某些字段或片段为「可延迟交付」。服务端先返回不含这些字段的初始响应,随后通过增量(incremental)块把延迟字段补上。

query HomePage {
  viewer {
    user { id name }          # 初始响应就返回
    notifications {
      id message              # @defer:随后增量返回
    }
  }
  feed(first: 20) {
    edges { node { id title } }
  }
  recommendedProducts {
    sku name price
  } @defer(label: "recommended")
}

@defer 可以直接标注在字段选择集上,也可以通过内联片段标注:

query ArticlePage($id: ID!) {
  article(id: $id) {
    id
    title
    body
    ...Comments @defer(label: "comments")   # 评论区最慢,最后交付
  }
}

3.2 服务端多部分响应

当查询包含 @defer 字段时,服务端不再返回单个 JSON,而是返回 multipart/mixed 流:第一部分是初始 data,随后是若干个 incremental 块,每个块携带 path 与 data 字段。

{
  "data": {
    "viewer": { "user": { "id": "42", "name": "Plume" } },
    "feed": { "edges": [] }
  },
  "hasNext": true
}

流中的增量块:

{
  "incremental": [
    {
      "data": { "notifications": [{ "id": "n1", "message": "你有新消息" }] },
      "path": ["viewer"],
      "label": "notifications"
    },
    {
      "data": { "recommendedProducts": [{ "sku": "S-1", "name": "咖啡豆", "price": 99 }] },
      "path": [],
      "label": "recommended"
    }
  ],
  "hasNext": false
}

客户端协议(Apollo Client 的 IncrementalDeliveryLink、graphql-ws 增量响应)负责把这些块按 path 合并进最终结果。@defer 要求服务端执行器支持增量执行——Apollo Server 4、GraphQL Yoga 以及 Rust 的 async-graphql 均已提供稳定支持。

3.3 @defer 的适用场景

@defer 最适合「初始体验依赖的数据就绪快、补充数据就绪慢」的查询:

  • 文章详情页:正文快,评论、推荐慢。
  • 电商商品页:商品主信息快,库存/价格/评价慢。
  • 列表页:第一屏条目快,分页计数与排序选项慢。

注意 @defer 并不是万能银弹——它要求后端 resolver 本身可拆分执行,且会增加连接存活时间与流式序列化开销。对延迟全部字段无差别标注,反而可能让简单查询退化得更慢。

四、@stream 指令:列表的增量交付

4.1 语法

@stream 指令用于把列表字段按元素增量交付。客户端先收到空数组与初始数据,随后数组元素逐个或分批追加。

query Search($q: String!) {
  search(query: $q) {
    total
    items @stream(initialCount: 3, label: "search-items") {
      id
      title
      snippet
    }
  }
}

initialCount 指定初始响应中包含的元素数量,剩余元素按增量块逐个(或按服务端实现批量)到达。

4.2 增量流式响应

流的第一部分:

{
  "data": {
    "search": {
      "total": 120,
      "items": [{ "id": "1", "title": "石墨", "snippet": "..." }]
    }
  },
  "hasNext": true
}

随后每个新元素以增量块形式追加:

{
  "incremental": [
    {
      "data": [{ "id": "2", "title": "金刚石", "snippet": "..." }],
      "path": ["search", "items"],
      "label": "search-items"
    }
  ],
  "hasNext": true
}

客户端按 path 将元素 append 到数组中。对用户而言,搜索结果第一条出现到全部出现之间的等待被「抹平」为渐进式填充。

4.3 @stream 与分页的关系

@stream 与游标分页是互补而非替代关系。分页解决的是「一次取多少」的问题,@stream 解决的是「取来的数据何时渲染」的问题。生产实践中常把两者组合:查询先用 first: 20 做分页,再对 items 标注 @stream(initialCount: 5),让首屏只等 5 条、其余 15 条流式填充。

query FeedWithStream($cursor: String) {
  feed(first: 20, after: $cursor) {
    edges {
      node { id title author { name } }
    } @stream(initialCount: 5)
    pageInfo { hasNextPage endCursor }
  }
}

需要留意的是,@stream 会显著增加服务端连接持续时间与内存占用,且要求传输层支持长连接(HTTP/2 或 SSE)。对超大列表(如数万条)应优先考虑分页,而非一次性流式交付。

五、客户端增量加载与 Suspense 集成

5.1 Apollo Client 的 incremental 支持

Apollo Client 3.8+ 通过 IncrementalDeliveryLink 支持 @defer/@stream 响应的自动合并。该 link 拦截 multipart 流,将初始响应与增量块按 path 合成为最终结果,业务代码几乎无感知。

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { incrementalDeliveryLink } from '@apollo/client/link/incremental';

const client = new ApolloClient({
  link: incrementalDeliveryLink(new HttpLink({ uri: '/graphql' })),
  cache: new InMemoryCache(),
});

启用后,即便查询里带了 @defer,组件也可以像普通查询一样消费最终结果;区别只在于渲染时机。

5.2 React Suspense 集成

增量交付与 React Suspense 是天作之合:@defer 字段可以用独立的 Suspense 边界包裹,让初始数据先渲染,延迟数据到达后再「补挂载」对应子树。

import { useSuspenseQuery } from '@apollo/client';

function HomePage() {
  const { data } = useSuspenseQuery(HOME_QUERY);

  return (
    <div>
      <UserCard user={data.viewer.user} />
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsBoundary />  {/* 依赖 @defer 字段的子树 */}
      </Suspense>
    </div>
  );
}

Suspense 边界确保「初始字段」不再等待「延迟字段」:UserCard 在初始响应返回后立即渲染,评论区则等 comments 增量块到达后才挂载,期间显示骨架屏。

5.3 首屏体验优化

将三者组合后,首屏时间(TTFB + FCP 附近)显著改善:

  1. 弱网下用批处理合并多个并行操作,减少 TLS 握手与认证开销。
  2. 主查询用 @defer 把非关键字段移出初始响应。
  3. 列表用 @stream 让第一条数据尽快可渲染。
  4. 每个延迟字段包一层 Suspense,让首屏永远不等慢数据。

这套组合在移动端收益最大,因为它把「一次全量等待」转化为「最小首屏 + 渐进填充」的体验模型。

六、批处理与增量的取舍

6.1 批处理的代价

维度说明
错误隔离单操作失败不应连坐整批,客户端需按索引分发
慢操作拖尾整批延迟取最慢者,混入写操作会放大延迟
缓存失效批中操作各有缓存键,POST 缓存难命中
限流粒度网关按请求计数时,一次批处理只算一次

6.2 增量的代价

维度说明
后端支持要求执行器支持 incremental 执行与 multipart 流
长连接连接存活时间变长,占用网关/负载均衡并发
日志与追踪增量块无独立 HTTP 请求,trace 需按流维度聚合
CDN 缓存流式响应不可整体缓存,只能缓存初始块或放弃缓存

取舍建议:批处理适合「请求数多、单请求小、弱网」的客户端;增量交付适合「查询大、慢字段明确、需要首屏体验」的场景。二者可以共存,但都要在网关层做好配套(超时、限流、日志、指标)。

七、生产实践与工具链

7.1 Apollo Server / Yoga 配置

Apollo Server 4 与 GraphQL Yoga 默认支持 @defer/@stream,只需开启对应选项:

// Apollo Server 4
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';

const server = new ApolloServer({
  schema,
  plugins: [ApolloServerPluginLandingPageLocalDefault()],
});

Yoga 对 @defer 的支持通过 graphql-yoga 的增量响应能力自动启用,配合 useServer 或 createYoga 的 fetchAPI 即可:

import { createYoga } from 'graphql-yoga';
import { createServer } from 'node:http';

const yoga = createYoga({ schema });
const server = createServer(yoga);
server.listen(4000, () => console.log('Yoga ready at /graphql'));

7.2 网关与 CDN 兼容性

  • Apollo Router:preview_defer_support: true 开启后即可透传/规划带 @defer 的查询,并支持对 subgraph 的增量响应做合并。
  • CDN 缓存:multipart 流式响应不应直接整响应缓存;可对「不含 @defer 的查询」维持常规 POST/GET 缓存,对增量查询走动态直通。

7.3 性能收益度量

引入增量交付后,务必建立可量化的观测指标:

  • p50/p95 TTFB:初始响应到达时间(首屏体验的关键指标)。
  • full_delivery_time:全部增量块完成的时间(总数据完成时间)。
  • payload_saved:初始响应体积占全量响应的比例。
  • 弱网模拟(DevTools 3G/4G throttling)下对比改造前后的 LCP。

用「初始响应时间」而非「全量完成时间」作为增量交付的核心 KPI,才能真实反映用户感知的提速。

八、一句话总结

批处理把「多个请求」压进「一次往返」,增量交付把「全量等待」拆成「最小首屏 + 渐进填充」——二者配合 @defer、@stream 与 Suspense,是弱网与移动场景下 GraphQL 客户端体验优化的核心手段。

FAQ

Q1: 批处理(Batching)和 @defer 有什么区别?

A: 批处理针对的是「多个操作」——把若干独立 GraphQL 操作合并为一次 HTTP 请求,共享连接与认证。@defer 针对的是「单个操作内部」——把查询里某些字段推迟到初始响应之后交付。二者可以组合使用:批内每个操作自身也可以带 @defer。

Q2: 什么情况下不应该使用 @defer?

A: 当整个查询的所有字段延迟都差不多、或服务端不支持增量执行时,@defer 只会增加流式开销。另外,对需要 CDN 整体缓存的高频读接口,流式响应无法被整块缓存,应避免滥用。

Q3: 服务端如何识别并处理 JSON Batch 请求?

A: 请求体是 JSON 数组,每个元素是 { query, variables, operationName }。服务端逐个执行后按相同顺序返回结果数组,数组元素可以是 data 或 errors。Apollo Server 2.x 的 batch 支持、以及 graphql-request 客户端的 batch.enabled 均基于该协议。

Q4: @stream 与游标分页是替代关系吗?

A: 不是。分页决定「取多少、从哪取」,@stream 决定「取到的数据何时渲染」。实践中通常组合使用:分页参数控制总量,@stream 控制首屏只需等待前 N 条。

Q5: 增量交付对日志与链路追踪有什么影响?

A: 初始响应与增量块属于同一条查询、同一个 traceId,但增量块没有独立 HTTP 请求。建议在服务端按「查询 ID + 流序列号」聚合日志,并分别记录初始执行时间与增量执行时间,避免把多次交付误记为多次查询。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. GraphQL 限流与成本控制:查询成本分析、复杂度限制与按量计费
  3. GraphQL 边缘缓存与 CDN:POST 缓存、边缘执行与缓存键设计