当系统从单体 GraphQL 服务演进为多个独立服务时,最先撞上的问题不是「怎么拆」,而是「拆完之后客户端该连谁」。如果让前端同时对接用户服务、订单服务、商品服务三个端点,那么跨服务的字段拼装、鉴权统一、错误归一化就全部下沉到了客户端——这恰恰是 GraphQL 想消灭的痛点。Schema Stitching(模式拼接) 就是在这个背景下出现的聚合方案:它在网关层把多个子 Schema 合并成一份统一 Schema,客户端只看到一个端点,网关负责把每个字段的解析委派给正确的下游服务。
Stitching 与 Apollo Federation 常被放在一起比较,但两者的设计哲学差别很大:Federation 要求子图(subgraph)遵循一套规范指令(@key、@external、@requires),由 Router 生成查询计划;而 Stitching 是网关侧的纯运行时拼接,子服务完全不需要知道自己被聚合,甚至可以是普通 REST 转成的 GraphQL。本文从 @graphql-tools/stitch 的实现机制讲起,覆盖类型合并、委派、Schema 变换、批量优化与生产调优,最后给出 Stitching 与 Federation 的选型矩阵。
一、Stitching 的两种形态
1.1 Schema 合并(Schema Merging)与运行时拼接
graphql-tools 历史上提供过两条路线,理解它们的差异是选型的前提:
| 形态 | 代表 API | 执行方式 | 是否支持跨服务类型合并 |
|---|---|---|---|
| Schema 合并 | mergeSchemas(旧)/ stitchSchemas | 启动时构建统一 Schema,resolver 直接委派 | 支持,但语义靠约定 |
| 运行时拼接 | @graphql-tools/stitch | 启动时构建 + 运行时按需委派 | 支持,且支持 computed fields |
早期的 mergeSchemas 会把多个 Schema 的 type 直接叠加,字段冲突时后者覆盖前者,这在同名类型语义不一致时会静默产生错误数据。现代 @graphql-tools/stitch 引入了类型合并(Type Merging) 概念:当多个子 Schema 定义同名 type(如 User)时,网关知道它们是「同一个实体的不同字段切片」,并按 selectionSet 声明的键把请求分发到各子服务,再合并结果。
import { stitchSchemas } from '@graphql-tools/stitch';
const gateway = stitchSchemas({
subschemas: [
{ schema: userSchema, batch: true },
{ schema: orderSchema, batch: true },
{ schema: productSchema, batch: true },
],
});
batch: true 让网关把同一轮请求中针对同一子 Schema 的委派合并成一次查询,这是 Stitching 性能的生命线。
1.2 子 Schema 的三种来源
Stitching 的一个显著优势是不挑下游协议:
- 远程 GraphQL 服务:通过
schemaFromExecutor包装一个 fetch executor,指向下游/graphql端点。 - 本地内存 Schema:直接传入
makeExecutableSchema构建的实例,适合单体拆分过渡期。 - 非 GraphQL 数据源:用
@graphql-tools/wrap或手写 executor,把 REST / gRPC / 数据库包装成 GraphQL Schema。
远程子 Schema 的标准写法:
import { schemaFromExecutor, wrapSchema } from '@graphql-tools/wrap';
import { print } from 'graphql';
const remoteExecutor = async ({ document, variables, context }) => {
const query = print(document);
const res = await fetch('https://user-service.internal/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: context.authorization, // 透传用户身份
'x-trace-id': context.traceId, // 透传链路 ID
},
body: JSON.stringify({ query, variables }),
});
return res.json();
};
const userSubschema = wrapSchema({
schema: await schemaFromExecutor(remoteExecutor),
executor: remoteExecutor,
});
这意味着你可以先用 Stitching 把「一个单体 Schema + 一个 REST 商品接口」聚合起来,验证聚合模型,再逐步把 REST 换成 GraphQL,而客户端全程无感知。
1.3 网关的自省与 Schema 快照
Stitching 网关在启动时会拉取所有子 Schema 做自省(introspection)。生产环境应当:
- 关闭网关对外的 introspection(见 持久化查询与生产安全 ),但保留对内的自省;
- 把每次启动生成的合并 Schema 落盘为快照,纳入 git,作为破坏性变更检测的基线;
- 启动失败时快速失败(fail-fast),而不是带着缺失的子 Schema 提供服务——半残的 Schema 比直接 503 更难排查。
二、类型合并的键与 selectionSet
2.1 @key 与 selectionSet
Stitching 靠 @key 指令(或 merge 配置项)识别「这个 type 在哪些子 Schema 中存在、用什么字段标识同一实体」:
# user-service
type User @key(selectionSet: "{ id }") {
id: ID!
email: String!
nickname: String!
}
# order-service
type User @key(selectionSet: "{ id }") {
id: ID!
orders(first: Int): OrderConnection!
}
当客户端查询 { user(id: "1") { nickname orders { edges { node { id } } } } } 时,网关会:
- 调用 user-service 拿到
nickname; - 把返回的
id作为键,向 order-service 发起{ _entities(representations: [{ __typename: "User", id: "1" }]) { orders { ... } } }(或按 stitching 的merge查询形态); - 按
id把两份数据合并成一个User对象返回。
2.2 键的选择原则
| 键形态 | 示例 | 适用场景 | 注意 |
|---|---|---|---|
| 单字段键 | { id } | 全局唯一 ID | 必须跨服务语义一致 |
| 复合键 | { tenantId id } | 多租户系统 | 少一个字段就串数据 |
| 业务键 | { sku } | 商品、SKU | 业务键可能变更,需谨慎 |
| 代理键 | { __typename id } | 多态实体 | 配合 interface/union 使用 |
复合键的坑最隐蔽:如果某个子服务的 selectionSet 漏了 tenantId,网关在合并时会用不完整的键匹配,导致跨租户数据串号。建议在 Schema 评审阶段就把键定义列为必查项,并在集成测试里专门覆盖「同 id 不同租户」的用例。
2.3 @merge 的三种合并策略
除了 @key 简写,merge 配置项还能显式控制合并行为:
const gateway = stitchSchemas({
subschemas: [{
schema: userSchema,
merge: {
User: {
selectionSet: '{ id }',
fieldName: 'userById', // 该子服务上按 id 取实体的根字段
args: (originalObject) => ({ id: originalObject.id }),
argsFromKeys: (ids) => ({ ids }), // 批量取数形态
valuesFromResults: (results, keys) =>
keys.map((id) => results.find((r) => r.id === id)),
},
},
}],
});
fieldName + argsFromKeys 决定了网关如何用一批键去下游取数。如果下游没有批量字段(如 usersByIds(ids: [ID!]!)),批量委派会退化为逐个查询,batch: true 也就失去了意义。因此为下游补充批量取数根字段是接入 Stitching 时的常见改造项。
2.4 字段冲突与 canonical 声明
当两个子服务都定义了 User.email 时,网关必须知道以谁为准。Stitching 的规则是「哪个子服务的 selectionSet 命中,就用哪个」,但若两者都能命中,需要显式声明权威来源(canonical):
type User @key(selectionSet: "{ id }") {
id: ID!
email: String! @canonical # 声明本子服务为该字段的权威来源
}
未声明 canonical 的重复字段,网关会按子 Schema 顺序取第一个非空值。这在字段语义漂移时会输出难以复现的数据,务必在 CI 里对合并 Schema 做重复字段检测。
2.5 @merge 与 computed fields
有些字段无法从被合并的对象直接得到,而是依赖其他子服务的字段计算得出。例如 User.fullName 需要 firstName + lastName,而这两个字段在 profile 服务里:
type User {
id: ID!
firstName: String! @external
lastName: String! @external
fullName: String! @computed(selectionSet: "{ firstName lastName }")
}
@computed 字段的解析顺序被网关自动编排:先取依赖字段,再调用计算字段所属的子服务。这比在客户端拼字符串优雅得多,但要注意计算字段会引入额外的委派跳数,深度嵌套时要评估查询计划复杂度。
三、委派(Delegation)机制
3.1 delegateToSchema 与上下文
网关的核心动作是「委派」:把某个字段的解析工作转交给下游 Schema。手工委派的写法:
import { delegateToSchema } from '@graphql-tools/delegate';
const resolvers = {
Query: {
async order(_, { id }, context, info) {
return delegateToSchema({
schema: orderSubschema,
operation: 'query',
fieldName: 'order',
args: { id },
context,
info,
});
},
},
};
关键参数:
operation:query/mutation/subscription,mutation 必须显式声明,否则网关可能误判为可并行的 query;context:向下游传递的请求上下文(用户身份、traceId、超时预算);info:当前字段的 GraphQL 解析信息,网关据此裁剪下游请求的字段集(只请求需要的字段,这是 Stitching 减少过取的关键);transformedSchema:可注入 Schema 变换(重命名、过滤),用于处理下游与网关的命名差异。
3.2 字段裁剪与 info
delegateToSchema 会读取 info.fieldNodes,把客户端实际请求的子字段拼成下游查询。这意味着下游请求的形状由客户端决定,网关不做无脑的全字段转发。如果某个 resolver 自己拼了一段固定查询字符串,就绕过了这个优化,容易在字段演进时产生不一致——这是 Stitching 代码评审的重点。
3.3 批量委派与查询合并
当同一轮请求中有 N 个字段需要委派到同一个子 Schema 时,逐个委派会产生 N 次网络往返(N+1 问题的网关版)。开启批量后,网关会把它们合并为一次查询:
const gateway = stitchSchemas({
subschemas: [
{ schema: userSchema, batch: true, merge: { /* ... */ } },
],
});
批量委派依赖两个前提:子 Schema 必须支持「按多个键一次查询」(通常通过 _entities 或 nodes(ids: [...]) 字段),且网关能在同一 tick 内收集到所有待委派字段。对于非 GraphQL 数据源,需要自己实现批量化 executor,否则批量开关形同虚设。
3.4 委派与 DataLoader 的组合
网关自身的 DataLoader 解决的是「同一子 Schema 内的重复取数」,而批量委派解决的是「跨子 Schema 的往返合并」。两者是互补的:
| 层 | 问题 | 手段 |
|---|---|---|
| 子服务内部 | 同一请求内重复查同一实体 | DataLoader(load(id) / loadMany) |
| 网关层 | 同一轮请求多次委派同一子服务 | batch: true + argsFromKeys |
| 客户端层 | 多次 operation 的重复请求 | 请求批处理 / APQ |
三层都做好,一个列表查询才可能稳定在个位数的下游请求数。任何一层缺失,压测时的请求放大都会指数级暴露。
四、Schema 变换(Transforms)
4.1 为什么需要变换
下游服务的 Schema 往往不能直接暴露给客户端:内部字段命名不规范、暴露了不该公开的字段、缺少网关层的统一封装。@graphql-tools/wrap 提供了声明式的变换:
import {
wrapSchema, RenameTypes, RenameRootFields,
FilterRootFields, FilterObjectFields,
TransformQuery, TransformCompositeFields,
} from '@graphql-tools/wrap';
const gatewaySchema = wrapSchema({
schema: legacySchema,
transforms: [
new RenameTypes((name) => `Legacy${name}`),
new RenameRootFields((op, name) => `legacy${name[0].toUpperCase()}${name.slice(1)}`),
new FilterRootFields((op, name) => !name.startsWith('_internal')),
new FilterObjectFields((type, field) => field !== 'passwordHash'),
],
});
4.2 常用变换一览
| 变换 | 作用 | 典型用途 |
|---|---|---|
RenameTypes | 重命名类型 | 避免与网关类型冲突 |
RenameRootFields | 重命名根字段 | 统一命名规范 |
FilterRootFields | 删除根字段 | 屏蔽内部查询 |
FilterObjectFields | 删除对象字段 | 屏蔽敏感字段 |
TransformQuery | 改写查询 AST | 注入默认参数 |
TransformCompositeFields | 重写字段 resolver | 全局字段级加工 |
ExtendSchema | 追加类型定义 | 增加网关专用字段 |
4.3 变换的顺序与副作用
变换是按数组顺序依次应用的,顺序错了结果就错。例如先 RenameRootFields 再 FilterRootFields,过滤条件必须用新名字;反之则用旧名字。建议把变换写成一个显式的、有注释的流水线,并在 CI 里对变换后的 Schema 做快照测试——变换是纯函数,快照测试成本极低,收益极高。
另一个副作用是变换会破坏字段裁剪的语义:如果某个变换把下游字段改名,而 info 里的字段名还是网关名,委派时需要 transformedSchema 参与才能正确映射。这是手写 resolver + 变换混用时最常见的 bug 来源。
五、错误传播与降级
5.1 部分失败的处理
Stitching 网关聚合多个服务,任何一个下游失败都可能影响整体响应。处理策略取决于字段的可空性:
type Query {
# 用户核心信息,失败即整体失败
me: User!
# 推荐模块,失败可降级为空列表
recommendations: [Product!]!
}
推荐做法是把非关键字段声明为 nullable,并在 resolver 里捕获下游错误后返回 null + 写入 extensions:
async recommendations(_, args, context, info) {
try {
return await delegateToSchema({ /* ... */ });
} catch (err) {
context.reportPartialFailure('recommendations', err);
return null;
}
}
GraphQL 的 errors[] 数组天然支持「部分数据 + 部分错误」,客户端可以基于 data.recommendations == null 渲染降级 UI,而不是整页白屏。
5.2 错误归一化
下游服务各自的错误码体系不同(用户服务返回 USER_NOT_FOUND,订单服务返回 ORDER_404),网关应当在委派层做一次归一化,统一映射为网关契约里的 extensions.code:
| 下游错误 | 网关归一化码 | 客户端处理 |
|---|---|---|
USER_NOT_FOUND | NOT_FOUND | 展示空态 |
INVALID_TOKEN | UNAUTHENTICATED | 跳登录 |
RATE_LIMITED | RATE_LIMITED | 退避重试 |
| 5xx / 超时 | DOWNSTREAM_UNAVAILABLE | 降级或提示稍后重试 |
归一化必须在网关做一次,否则每个客户端都要维护一张「下游错误码 → UI 行为」的映射表,成本随下游数量线性增长。
5.3 部分失败的观测
网关应当为每次委派打点:下游名、耗时、状态码、是否命中缓存、是否降级。这些指标汇总后可以回答「哪个下游在拖慢整体 P99」「降级触发的频率是否在上升」。指标埋点与链路追踪的落地细节参见 可观测性与链路追踪 。
六、缓存与实时数据
6.1 网关层缓存
Stitching 网关天然是缓存的好位置——它是所有下游请求的汇聚点,缓存命中率直接决定下游压力。可缓存的层次:
| 层次 | 缓存对象 | 失效策略 |
|---|---|---|
| 字段级 | 单个实体的字段值 | TTL + 主动失效 |
| 委派结果 | 一次下游查询的结果 | 按 operation + 变量哈希 |
| 响应级 | 整个 GraphQL 响应 | APQ 哈希 + CDN |
| 实体级 | 按 @key 索引的实体对象 | 事件驱动失效 |
实体级缓存最契合 Stitching 的模型:网关按 { __typename, id } 缓存实体对象,任何子服务的委派结果都可以回填进去,后续命中直接返回,无需再向下游取数。
6.2 订阅(Subscription)的聚合
Stitching 的订阅支持弱于 Federation。若下游通过 WebSocket 提供订阅,网关需要:
- 与每个下游建立独立的订阅连接;
- 把多个下游的事件流合并为单一
Subscription根字段; - 处理下游断线重连与事件去重。
const resolvers = {
Subscription: {
orderStatusChanged: {
subscribe: (_, args, context, info) =>
delegateToSchema({
schema: orderSubschema,
operation: 'subscription',
fieldName: 'orderStatusChanged',
args,
context,
info,
}),
},
},
};
实践中的坑在于订阅的连接数:每个客户端连接都要在网关与下游各占一条长连接,连接数会随在线用户线性增长。更稳妥的架构是让下游把事件推到消息队列(如 Redis Pub/Sub、Kafka),网关消费队列后广播给本地连接,把「N 个客户端 × M 个下游」的长连接降为「网关 × 下游」的固定连接数。
七、性能调优
7.1 委派开销的来源
Stitching 的性能开销集中在四处:
- 网络往返:每个子 Schema 至少一次 HTTP 请求;
- 序列化/反序列化:网关要解析下游 JSON、再按 GraphQL 形状重组;
- 类型合并的键匹配:大量实体的键比对与对象合并;
- 查询计划生成:每次请求都要根据
info计算委派路径。
7.2 优化清单
| 优化点 | 手段 | 预期收益 |
|---|---|---|
| 减少往返 | 开启 batch: true,实现批量 executor | 高 |
| 减少过取 | 依赖 info 自动字段裁剪,禁止硬编码查询 | 中 |
| 复用连接 | 下游 HTTP 客户端启用 keep-alive / 连接池 | 中 |
| 缓存 | 对幂等查询启用响应缓存或 DataLoader | 高 |
| 限制深度 | 网关层做查询深度与复杂度限制 | 防止雪崩 |
| 超时预算 | 每个委派设置独立超时,避免慢下游拖垮整体 | 高 |
超时预算是 Stitching 特有的问题:客户端一次请求可能触发 5 个下游委派,如果每个下游都等 30 秒,最坏情况就是 150 秒。正确做法是给整个请求分配一个总预算(如 2 秒),每个委派按剩余预算动态设置超时,超时的委派走降级路径。相关思路与 GraphQL Resolver 性能与 N+1 问题根治 中的数据加载策略可以组合使用。
7.3 查询计划的可观测性
为每次请求记录「委派图」:访问了哪些子 Schema、每个字段由谁解析、各段耗时。当 P99 抖动时,这份委派图能直接定位是哪一个下游的哪一段变慢,而不是对着聚合后的总耗时猜。可以把委派图序列化为 JSON 写入 trace 的 span 属性,与链路追踪系统联动。
八、Stitching 与 Federation 的选型
8.1 对比矩阵
| 维度 | Schema Stitching | Apollo Federation |
|---|---|---|
| 子服务改造 | 无需遵循规范,零侵入 | 需实现 _entities 与联邦指令 |
| 网关职责 | 重(类型合并、委派、计划) | 轻(Router 生成查询计划) |
| 跨服务类型合并 | 网关侧声明 @key/@merge | 子图侧声明 @key |
| 非 GraphQL 数据源 | 原生支持(包装 executor) | 需先转成子图 |
| 订阅与实时 | 需自行编排 | Router 原生支持 |
| 生态与工具 | graphql-tools 生态 | Apollo 全家桶(Studio/Router) |
| 迁移成本 | 低(可增量接入) | 高(需改造所有子服务) |
8.2 决策建议
- 已有大量 REST/gRPC 服务,想快速聚合:选 Stitching。它的 executor 抽象能直接包装非 GraphQL 源,是「先聚合、后统一」的务实路径。
- 从零构建、团队已用 Apollo:选 Federation。子图规范带来的可治理性(Schema Registry、查询计划可观测)在长期更重要,且 Router 的性能与订阅支持更成熟。
- 混合场景:Stitching 网关可以把一个 Federation 子图当作普通子 Schema 接入,实现「外部聚合 + 内部联邦」的两层结构。这种架构在大型组织中并不罕见,但要警惕两层委派叠加导致的延迟放大。
若已确定走 Federation 路线,网关侧的运维细节(查询计划解读、实体解析调优)可参考 联邦 Router 运维与查询计划 ;而无论哪条路线,Schema 的破坏性变更管控都不可省略,具体流程见 Schema 治理与 Registry 。
九、落地路径与常见陷阱
9.1 从单体到聚合的渐进路径
- 阶段一:单体 Schema 保持不变,Stitching 网关只做透传(单子 Schema),验证网关层鉴权、日志、限流。
- 阶段二:把低频、边界清晰的领域(如通知、商品)拆成独立服务,接入网关,观察委派延迟。
- 阶段三:按领域逐步拆分核心服务,同时引入
@key/@merge做类型合并。 - 阶段四:评估是否迁移到 Federation,或用 Stitching 长期维护聚合层。
每一步都要有可回滚点:网关配置以代码形式入库,切换子 Schema 来源只需改配置,不重新发版。
9.2 常见陷阱
- 键不唯一:多个子服务对同一
id返回不同实体,合并出脏数据。对策是集成测试覆盖键冲突用例。 - 循环委派:A 服务的字段依赖 B,B 的字段又依赖 A,网关陷入递归。对策是限制委派深度并做环检测。
- 上下文丢失:委派时忘记透传
context,下游拿不到用户身份,鉴权静默降级为匿名。对策是在网关统一封装delegate辅助函数。 - N+1 委派:忘记开
batch,列表查询放大成 N 次往返。对策是压测时专门构造列表场景验证请求数。 - 变换顺序错乱:多个 transform 叠加后命名与过滤条件错位。对策是快照测试 + 显式流水线注释。
FAQ
Q1:Stitching 能替代 Federation 吗?
不能简单替代。Stitching 的定位是「网关侧的聚合层」,适合异构数据源与非侵入式接入;Federation 的定位是「子图规范 + 查询计划」,适合从零构建、追求长期可治理的大型体系。两者可以共存:Stitching 做外部聚合,内部用 Federation 组织子图。
Q2:mergeSchemas 还能用吗?
技术上可用,但不建议在新项目中使用。它在同名类型字段冲突时是静默覆盖,缺乏现代 stitching 的类型合并与 canonical 语义,容易产出难以复现的脏数据。迁移到 stitchSchemas 的成本通常低于排查一个静默数据错误的成本。
Q3:网关会成为单点瓶颈吗?
会,因此网关必须无状态、可水平扩展。所有状态(缓存、订阅连接)要么放外部存储(Redis),要么接受「连接落在某个实例」并由负载均衡做粘性。网关实例数按 QPS 与下游往返预算估算,详见压测与容量规划的方法。
Q4:如何测试 Stitching 网关?
三层测试:单元测试覆盖每个手写 resolver 的委派参数;集成测试用内存子 Schema 验证类型合并与键匹配;契约测试验证合并后的 Schema 与客户端期望一致。契约层面的自动化验证可参考 契约测试与自动化验证 。
小结
Schema Stitching 的本质是在网关侧用运行时委派换取子服务的零改造:它用 @key/@merge/@computed 表达跨服务的实体拼装,用 delegateToSchema 完成字段级路由,用批量委派把 N+1 收敛为一次往返,用 Schema 变换屏蔽下游的内部细节。它的优势在于对异构数据源的宽容度和低迁移成本,代价是网关承担了更重的合并与计划职责。当团队需要快速聚合既有 REST/gRPC 服务时,Stitching 是务实之选;当从零构建且追求长期可治理性时,Federation 的子图规范更值得投入。无论选哪条路,键的语义一致性、错误归一化、委派超时预算、变换顺序这四件事都必须提前设计。
相关阅读
- GraphQL Federation:分布式 Schema 设计与服务编排
- 联邦 Router 运维与查询计划
- GraphQL Resolver 性能与 N+1 问题根治
- Schema 治理与 Registry
- TypeScript 全栈 GraphQL 服务端类型安全
- Protobuf 最佳实践:版本兼容与字段设计规范
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。