API 组合架构:多个服务的数据该在哪里拼、谁拥有拼的代码、一个服务慢了客户端拿到什么

比较 Gateway Aggregation、Backend for Frontend、GraphQL Federation 与 CQRS Read View 四种组合拓扑:合并发生在哪、谁拥有这段代码因此改屏幕时谁被卡住、某个服务超时时客户端得到失败、部分答案还是过期答案、每个请求付一次合并还是视图提前付、状态归谁,以及一路超时或一个丢失的事件能波及多远、怎么收住。

一个订单页要显示订单、物流和客户资料,这三样活在三个服务、三个数据库里。所有组合架构都要回答四个问题:合并发生在哪(共享的网关、某种客户端专属的后端、按 schema 计划的路由,还是提前算好的读视图);谁拥有这段代码,因此改一个屏幕时谁被卡住;某个服务超时时客户端拿到的是失败、部分答案,还是过期答案;每个请求都要付一次合并的成本,还是视图提前付过了。这四个答案,而不是用的哪家产品,才定义了拓扑。

想亲手走一遍四种拓扑、注入故障再恢复,打开互动 Lab:/system-design-lab/api-composition-architectures

有约束的设计问题

一家公司先后要给四类场景选组合方案:手机在蜂窝网络上打开订单页,要一次往返拿到订单、物流和客户资料,某一块暂时取不到时页面仍要可用;Web、移动、合作方三种客户端要三种形状,三个团队各自发布,一个通用后端已经成了所有团队排队的瓶颈;十几个团队各自拥有类型,客户端要按屏幕自由选字段,某个团队的服务挂了不能拖垮整个响应;全站最热的组合查询,每次在内存里合并三个服务太大也太慢,页面能接受几秒延迟只要把新鲜度显示出来。每一种该怎么搭?

四种 topology signature

架构合并发生在哪谁拥有拼的代码服务慢了怎么办每个请求的成本State owner适合主要代价
Gateway Aggregation共享网关,每个请求平台团队策略:超时那一路放弃返回部分数据,或整个请求失败一次扇出加一次内存合并各服务自己的库;网关不存数据高延迟网络上的多话客户端,需要一次往返网关是共享的瓶颈和单点;合并逻辑诱使业务规则住进来;大结果集内存合并很贵
Backend for Frontend某种客户端专属的后端该客户端自己的团队那个客户端自己的策略,按它的屏幕定制每种客户端各一次扇出各服务自己的库;BFF 不存数据Web、移动、合作方需求冲突、由不同团队开发BFF 之间有意重复;每种客户端多一跳一个部署;业务逻辑必须挡在外面
GraphQL Federation路由,按 supergraph 生成的 query plan各 subgraph 团队拥有类型;路由是共享的失败那个 subgraph 的字段为 null,errors 里多一条,其余字段照常一次 plan,每个 subgraph 一次取数各 subgraph 自己的库;路由只存 schema很多团队拥有类型、客户端要自由选字段路由是共享依赖;plan 可能扇出很宽;实体 key 要跨团队稳定
CQRS Read View提前算好,在事件到达时拥有该视图的团队视图仍能答,只是数据截至最后一个已应用的事件一次行读;合并在事件时已付过各服务自己的库仍是真相;视图是派生副本很热的组合查询,内存合并太大太慢视图落后写入;每种新查询形状都要建一张新视图;更多活动部件

客户端自己逐个调用服务是这四种都要替代的基线;单个服务上的批量接口能省掉多次往返,但不跨服务组合;problem detailsapplication/problem+json)是给部分响应里缺的那一块写清楚原因的标准格式。它们在这里解释,不单独画。网关的认证、限流、路由这些横切职责见 API Gateway,REST/GraphQL/gRPC 的协议对比见 REST, GraphQL & gRPC,事件溯源作为写模型见 Event Sourcing

1. Gateway Aggregation:一个请求到网关,网关并行扇出到各服务、在内存里合并,超时的部分按策略返回部分结果

模式目录把问题和方案写清了:「如何在微服务架构里实现查询?」的答案是「定义一个 API Composer,调用拥有数据的服务并在内存里做一次 join」;这「是在微服务架构里查询数据的一种简单方式」,但「有些查询会导致大数据集低效的内存 join」;并且「一个 API Gateway 经常做 API composition」。网关本身:「实现一个 API 网关,作为所有客户端的单一入口」,缺点是「复杂度增加——API 网关是又一个必须开发、部署和维护的活动部件」和「多一跳网络往返带来的响应时间增加——不过对大多数应用来说,多一次往返的成本可以忽略不计」。

Gateway Aggregation 这个模式把方案说得更具体:「网关接收客户端请求,把请求分发到各个后端系统,并在把结果发回客户端之前把它们聚合起来」;实施要点是「网关不应该在后端服务之间引入耦合」「网关应该靠近后端服务以尽量降低延迟」「网关服务可能引入单点故障(SPoF)」「网关可能引入瓶颈」「用舱壁、熔断、重试和超时等技术实现有弹性的设计」「如果一个或多个服务调用耗时过长,返回部分数据集也许是可以接受的,要想清楚应用怎么处理这种情况」「考虑把缓存数据作为故障时的兜底策略」「与其把聚合逻辑写进网关,不如考虑在网关后面放一个专门的聚合服务」;适用场景是「客户端可能使用延迟较高的网络,比如蜂窝网络」,不适用的场景是「客户端或应用靠近后端服务、延迟不是重要因素」。缺的那一块怎么说明:problem details 对象「以 application/problem+json 媒体类型标识」,其中 type 「是一个 JSON 字符串,包含标识问题类型的 URI 引用」,title 「是一个简短的、人类可读的问题类型摘要」,detail 「是一个针对这次具体问题的、人类可读的解释」,而 status 「只是提示性的……生成方必须在实际的 HTTP 响应里使用相同的状态码」。

网关不拥有任何数据,也不该拥有任何业务规则:它只做扇出、合并和策略。每个下游调用各自有超时,一路超时或出错时,网关要按策略明确决定是返回一个带缺口的部分响应(缺口用 problem details 说明)还是整个请求失败;数据不完整能不能接受,由这个接口自己决定,不能含糊。

典型事故:物流服务变慢,网关没有各自超时、只有一个整体超时,订单和客户资料早就回来了,网关却在等物流那一路,等到总超时才把整个请求报错——每个下游调用各自超时,超时的那一路按策略放弃并用 problem details 标出缺的是物流,对连续超时的服务熔断,用舱壁和异步 I/O 不让一路慢占满全部线程。业务规则一点点住进了网关,运费计算、优惠判断先写在了合并代码里,网关半年后成了最大的一个服务、每个团队改动都要过它的评审——把业务规则搬回拥有数据的服务,网关保持薄,需要重聚合的放进网关后面的聚合服务。

2. Backend for Frontend:每种客户端一个自己的组合后端,由该客户端团队拥有,只放客户端相关的逻辑

Sam Newman 把问题和方案定了名:「通用 API 后端承担多种职责的倾向,往往需要大量工作,常常导致专门为这个代码库成立一个团队」;解法是「与其有一个通用 API 后端,不如每种用户体验有一个后端——一个 Backend For Frontend(BFF)」;「BFF 和某个特定的用户体验紧密耦合,通常由维护该用户界面的同一个团队维护」;风险是「每个用户界面各有一个 BFF 的隐患之一,是 BFF 之间可能出现大量重复」;效率上「从效率角度看,尽可能让调用并行运行会聪明得多」;BFF 的定位是「BFF 紧密聚焦于单一 UI,只服务这一个 UI,这让它能够保持专注,因此也会更小」。

模式目录补上了组织和边界:「这个模式的一个变体是 Backends for frontends 模式。它为每种客户端各定义一个独立的 API 网关」;「引入一个新层,只处理该接口特有的需求」;「前端团队各自独立管理自己的 BFF 服务,这让他们能自主选择语言、发布节奏、工作优先级和功能集成」;「BFF 服务应该只处理和某个具体用户体验相关的客户端专属逻辑。监控、鉴权这类横切特性应当被抽象出去」;「代码重复是这个模式很可能带来的结果」;「因为客户端不再直接联系服务,新服务引入了一跳网络,延迟可能增加」;不适合的场景是「多个接口发出相同或相似的请求」或「只有一个接口在和后端交互」。

与其一个通用后端伺候所有客户端、被互相冲突的需求撕扯,不如每种用户体验一个自己的后端:移动 BFF 由移动团队拥有、裁掉手机不需要的字段、一页一页地取;Web BFF 由 Web 团队拥有、一次拉几页、拼更重的响应。两个团队各自决定语言、发布节奏,改屏幕不用互相协商。红线是 BFF 只放客户端相关的逻辑:总价、能否退货这类业务规则留在拥有数据的服务里,否则两个 BFF 迟早给出两个答案;认证、限流、监控放在前面的网关,不在每个 BFF 里重做。

典型事故:移动团队在移动 BFF 里加了满额免运费的规则、Web 团队不知道,同一订单在手机上显示 98 元、网页上显示 108 元——总价这类业务规则搬回订单服务由它算好返回,BFF 只裁剪和重排字段,加一个「同一订单两个客户端显示一致」的对比测试。合作方客户端为了省一个部署被接到了 Web BFF 上,它要的分页和字段和网页不一样,BFF 里长出一堆「如果是合作方就」的分支——合作方要么有自己的 BFF 要么走公开 API,只有需求完全相同的客户端才共用一个 BFF。

3. GraphQL Federation:客户端一条查询点名字段,路由按 supergraph 生成 query plan,各 subgraph 按 @key 拼成实体,错误随部分数据一起返回

Apollo 的文档把联邦图的结构定了名:「这个单一的、联邦化的图叫做 supergraph。在一个 supergraph 里,构成它的各个 API 叫 subgraph」;「客户端向联邦 GraphQL API 的单一入口——router——发出请求。router 会智能地在你的各个 API 之间编排和分发请求,并返回一个统一的响应」;「每个团队都能独立工作,不需要维护多套 API 层」。实体是跨 subgraph 拼接的单位:「实体是可以用一个或多个唯一 key 字段获取的对象」;「@key 指令定义一个实体的唯一 key,由该类型的一个或多个字段组成」;「每个 subgraph 可以为它定义的实体贡献不同的字段,并负责解析这些字段」;「key 字段的唯一性让你的 router 能把来自不同 subgraph 的字段关联到同一个实体实例上」;「reference resolver 的第一个参数是被解析实体的一个 representation」。

BFF 目录也提到了两者的重叠:「有了 GraphQL,这种查询机制让客户端可以按需请求数据而不依赖预定义的端点,这样就不再需要单独的 BFF 层」;「如果你的组织已经在用带前端专属 resolver 的 GraphQL,BFF 服务可能不会给你的应用带来额外价值」。

让客户端说出它要的形状,让路由去计划。各团队各自拥有自己的 subgraph:订单团队定义 Order 类型并声明 @key(id),物流团队给同一个 Order 实体贡献 shipment 字段;这些 schema 被组合成一个 supergraph。客户端只向路由发一条查询、点名要哪些字段;路由按 supergraph 生成 query plan,先从订单 subgraph 取字段和 key,再带着 key 去物流 subgraph 取它贡献的字段,按 key 拼成同一个实体。失败按字段报告:物流 subgraph 挂了,shipment 字段为 null、errors 里多一条,订单字段照常返回,客户端必须读 errors 而不是只看 data。

典型事故:物流 subgraph 的连接池耗尽,reference resolver 对每个 key 都报错,客户端只读 data 把「暂时取不到」显示成「没有物流」——客户端把 errors 和 data 一起读,对应字段显示「暂时取不到」,路由对连续失败的 subgraph 熔断,实体 key 在团队之间约定为稳定 id 并做契约测试。一个列表查询要 50 个订单的 shipment,reference resolver 没有批量实现,路由逐个取,一页变成几十次请求——reference resolver 接收一批 representations 一次解析,路由限制查询深度和复杂度,按 plan 监控每次查询的取数次数。

4. CQRS Read View:各服务发领域事件,视图更新器提前把组合查询算成一张只读视图,查询路径只读一行

模式目录的定义:「定义一个视图数据库,它是专门为支持某个查询或一组相关查询设计的只读『副本』」;「应用通过订阅数据拥有方服务发布的领域事件,让这个数据库保持更新」;好处是「支持多个可伸缩、高性能的反规范化视图」和「关注点分离更清晰=命令和查询模型都更简单」;代价是「复杂度增加」「潜在的代码重复」和「复制延迟/最终一致的视图」;从组合的角度看,「CQRS 模式是(内存 join 的)一种替代方案」。

把合并从请求时搬到事件时。订单、物流、客户三个服务照常写自己的库,并把变化作为领域事件发到总线;一个视图更新器订阅这些事件,按实体版本顺序把它们应用到一张为订单摘要页专门反规范化的只读视图上:一行里已经有订单、物流状态和客户名。查询 API 只读这一行,不扇出、不合并,热查询再多也只是一次读。代价是延迟:视图落后于写入,用户刚改完地址立刻刷新可能看到旧的;事件丢了或乱序,视图里那一行就一直错到重建为止;每一种新的屏幕形状都是一张新视图,要从事件历史重建。视图永远是派生的副本,只有拥有数据的服务写真相。

典型事故:客户服务提交了新地址并发出事件,更新器还在积压队列后面没应用,用户立刻刷新看到旧地址、以为没保存又改了一次——响应带上视图的 as-of 时间并在界面显示,用户自己刚写的数据在短时间内从拥有它的服务读(读己之写),监控事件到应用的延迟并告警。一次网络重试让「已发货」事件投在了「已签收」之后,更新器按到达顺序应用没看版本,订单摘要行永久显示「已发货」——每个事件带实体版本,更新器只应用比视图行更新的版本、旧的丢弃,发现冲突从拥有服务重建那一行,保留从事件历史全量重建的能力。

四种拓扑共同的底线

  • 说清合并发生在哪。 共享网关、某种客户端专属的后端、按 schema 计划的路由,还是提前算好的读视图;四种里数据的真相永远在拥有它的服务自己的库里。
  • 说清谁拥有拼的代码。 网关归平台团队、BFF 归客户端自己的团队、subgraph 归各自的团队、视图归拥有它的团队;改一个屏幕时被卡住的就是这段代码的拥有者。
  • 说清一个服务慢了给什么。 失败、带 problem details 的部分响应、按字段返回的 null 加 errors,还是带 as-of 时间的过期视图;四种答案都要在设计时明确写下来,不能含糊到运行时才发现。
  • 不要让业务规则搬错地方。 总价、能否退货这类事实永远由拥有数据的服务计算;网关、BFF、路由、视图都只负责组合和形状,不负责计算事实。
  • 给每一路加超时和熔断。 网关和 BFF 的每个下游调用各自超时;路由限制 plan 的深度和扇出;视图更新器按版本顺序应用并能从事件重建。

故障与恢复

架构故障用户看到什么恢复
Gateway Aggregation物流服务慢,网关没有各自超时整页失败,尽管两块数据是好的每路各自超时;超时的一路返回部分响应并用 problem details 标出
Gateway Aggregation业务规则住进了网关每个团队改动都要排它的队规则搬回拥有数据的服务;网关保持薄
Backend for Frontend两个 BFF 各自算总价同一订单两个客户端显示不同金额总价由订单服务算好返回;BFF 只裁剪重排
Backend for Frontend一个 BFF 被两种客户端共用BFF 里长出一堆特殊分支合作方要么有自己的 BFF 要么走公开 API
GraphQL Federationsubgraph 解析不了实体引用「暂时取不到」被当成「没有」显示客户端读 errors;路由对失败的 subgraph 熔断
GraphQL Federationplan 对列表逐个实体取数一页变成几十次请求,物流服务被压垮reference resolver 批量解析;限制深度和扇出
CQRS Read View用户刚写完就刷新视图还没应用事件,看到旧数据响应带 as-of 时间;短时间内读己之写
CQRS Read View事件乱序到达视图行永久停在旧状态按实体版本应用;冲突时从拥有服务重建

怎么选

  • 高延迟网络上的多话客户端,需要一次往返:Gateway Aggregation,并行扇出、各自超时、超时的一路按策略返回部分响应并用 problem details 标出,网关保持薄。
  • Web、移动、合作方需求冲突、由不同团队开发:Backend for Frontend,每种客户端一个 BFF 由该团队拥有,只放裁剪和重排,业务规则留在服务里。
  • 很多团队拥有类型、客户端要自由选字段:GraphQL Federation,各团队的 subgraph 组合成 supergraph,路由按 plan 取数、按 @key 拼实体,失败按字段以 errors 返回。
  • 很热的组合查询,内存合并太大太慢:CQRS Read View,各服务发领域事件,视图更新器提前算成反规范化的只读视图,查询只读一行并带 as-of 时间。
  • 无论哪种:写下合并发生在哪、谁拥有这段代码、一个服务慢了客户端拿到什么。

回到开头的四类场景:手机订单页走 Gateway Aggregation;三种客户端三个团队走 Backend for Frontend;很多团队拥有类型走 GraphQL Federation;最热的组合查询走 CQRS Read View。

面试时这样回答

  1. 先复述约束:客户端网络延迟高不高、有几种客户端几个团队、有多少团队拥有类型、这个查询有多热。
  2. 说组合路径:点名图上的边,例如「网关并行扇出、各路各自超时、内存合并、超时的一路返回部分数据」「移动 BFF 自己扇出只裁形状,Web BFF 另拉一份」「路由按 supergraph 生成 plan,先取订单字段再带 @key 取物流字段」「服务发事件,更新器应用到视图,查询只读一行」。
  3. 说数据的真相归谁:四种拓扑里都是拥有数据的服务自己的库;网关、BFF、路由和视图都不是真相。
  4. 说代价与一个故障:例如物流服务超时而网关没有各自超时时,整页跟着最慢的一路失败,修法是给每个下游调用各自超时、超时的一路按策略放弃并用 problem details 标出缺口、对连续超时的服务熔断。

一手证据