Schema Stitching 与服务聚合

Schema Stitching 与服务聚合实战:对比 stitching 与 Federation 的取舍,讲解 @graphql-tools/stitch 的类型合并、@merge 与 computed fields、delegateToSchema 委派、Schema 变换、批量查询、错误传播、缓存与订阅、性能调优,以及聚合网关的落地与迁移路径。

当系统从单体 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 } } } } } 时,网关会:

  1. 调用 user-service 拿到 nickname;
  2. 把返回的 id 作为键,向 order-service 发起 { _entities(representations: [{ __typename: "User", id: "1" }]) { orders { ... } } }(或按 stitching 的 merge 查询形态);
  3. 按 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_FOUNDNOT_FOUND展示空态
INVALID_TOKENUNAUTHENTICATED跳登录
RATE_LIMITEDRATE_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 提供订阅,网关需要:

  1. 与每个下游建立独立的订阅连接;
  2. 把多个下游的事件流合并为单一 Subscription 根字段;
  3. 处理下游断线重连与事件去重。
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 的性能开销集中在四处:

  1. 网络往返:每个子 Schema 至少一次 HTTP 请求;
  2. 序列化/反序列化:网关要解析下游 JSON、再按 GraphQL 形状重组;
  3. 类型合并的键匹配:大量实体的键比对与对象合并;
  4. 查询计划生成:每次请求都要根据 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 StitchingApollo 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 从单体到聚合的渐进路径

  1. 阶段一:单体 Schema 保持不变,Stitching 网关只做透传(单子 Schema),验证网关层鉴权、日志、限流。
  2. 阶段二:把低频、边界清晰的领域(如通知、商品)拆成独立服务,接入网关,观察委派延迟。
  3. 阶段三:按领域逐步拆分核心服务,同时引入 @key/@merge 做类型合并。
  4. 阶段四:评估是否迁移到 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」更多文章

  1. Mock 与测试策略
  2. 标量类型与输入校验
  3. 自定义指令与模式扩展