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 附近)显著改善:
- 弱网下用批处理合并多个并行操作,减少 TLS 握手与认证开销。
- 主查询用 @defer 把非关键字段移出初始响应。
- 列表用 @stream 让第一条数据尽快可渲染。
- 每个延迟字段包一层 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 文件上传与流式传输:multipart、分片与 @defer/@stream 增量交付
- GraphQL 客户端状态管理与缓存策略
- GraphQL Resolver 性能与 N+1 优化
- API 缓存与性能优化
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。