写点什么

构建生产就绪的 tRPC API:Apollo Federation 的 TypeScript 替代方案

作者:Dinesh Kumar Elumalai
  • 2026-04-27
    北京
  • 本文字数:5938 字

    阅读完需:约 19 分钟

我得跟你们说句实话。六个月前,我还是 GraphQL Federation 的布道者。我们花了六个月的时间,利用 Apollo 构建了一个联邦图,其中包含模式拼接、网关配置,以及一套复杂的 CI/CD 管道(每次提交都会重新生成类型)。纸上谈兵时,这套方案看起来很完美。但在生产环境中呢?每次部署都像是要发生一场灾难。

 

转折点发生在周五下午的一次例行部署中。我们的产品团队更新了某项服务中的一个字段类型,模式重新生成成功,测试通过,于是我们将其发布。三十分钟后,我们的移动应用开始崩溃,原因是 iOS 客户端仍在使用两小时前生成的旧类型。模式的版本已经更新,网关也已经升级,但客户端代码生成尚未运行,因为有人忘记触发它了。这正是 GraphQL 联邦架构中的一个典型痛点。

 

那时,我开始研究 tRPC。最吸引我的是,它不需要繁琐的模式定义就可以实现端到端的类型安全。不需要 SDL 文件,不需要代码生成步骤,也不需要联邦网关。从头到尾,只需 TypeScript 即可。自然,我还是心存疑虑。毕竟我们已经在 Apollo 上投入了大量的资源。但在看到一些大规模部署 tRPC 的公司所提供的生产环境指标后,我说服团队构建了一个概念验证。

 

下文记录了我们的完整迁移过程,包括我们犯过的错误、意想不到的性能提升,以及对当前生产环境架构的回顾——该架构每日能够处理 240 万次请求,并保持 99.97% 的可用性。这不是一份通过简单项目介绍 tRPC 的教程,而是将 tRPC 投入生产环境应用所需的实际经验。

图 1: tRPC 如何在没有模式定义的情况下实现端到端安全性

技术现实:tRPC 究竟能为你带来什么

没有模式开销的类型安全

关于 GraphQL Federation,有件事没人会告诉你:模式会成为单点故障。使用 tRPC 时,你的 TypeScript 类型就是契约。没有中间表示形式,无需维护 SDL,也不需要在不同的环境间保持模式注册表的同步。

 

在运行 Apollo Federation 时,典型的类型变更流程如下:更新 GraphQL 模式 → 运行代码生成 → 提交生成的文件 → 更新解析器实现 → 更新客户端查询 → 运行客户端代码生成 → 部署两项服务 → 祈祷一切正常。

 

使用 tRPC 呢?更新 TypeScript 接口 → 就这样。客户端会立即知道,因为它们共享相同的类型定义。

图 2:在每分钟 10000 次请求的持续负载下测得

真正重要的是性能

我们进行了生产环境负载测试,将旧版的 Apollo Federation 架构与新版的 tRPC 实现方案进行了对比。测试结果令人震惊。对于我们的无服务器函数而言至关重要的冷启动性能提升了 75% 。Apollo Federation 的网关开销在冷启动时会增加 180 毫秒,而 tRPC 仅需 45 毫秒。这还是在我们尚未进入实际业务逻辑之前。

 

在持续负载下,其平均响应时间从 38 毫秒降至 12 毫秒。但真正关键的是 P95 和 P99 延迟。使用 Apollo 时,我们的 P95 延迟为 85 毫秒,P99 延迟为 156 毫秒。迁移后,P95 延迟降至 28 毫秒,P99 延迟降至 42 毫秒。这些尾部延迟会严重影响用户体验,尤其是在移动网络上。

 

关于资源包大小的故事同样令人惊叹。我们基于 Federation 的 Apollo Client 设置在 gzip 压缩后为 142KB 。而采用 tRPC 搭配 React Query 的方案呢?仅需 28KB 。也就是说,大小减少了 80%。在网速较慢的情况下,这意味着页面初始加载速度可以提升 2 到 3 秒。真实用户立刻就会感受到这一差异。

生产架构:我们实际上是如何构建这个系统的

行之有效的单库设置

我们的生产环境是一个基于 pnpm 工作区的单库(monorepo),前端采用 Next.js 14 App Router,所有 API 通信均通过 tRPC 实现。我们有 12 个微服务,每个微服务都暴露自己的 tRPC 路由器,并由一个网关层将它们全部整合在一起。在实际应用中,其结构如下:

图 3:12 个微服务,每日 240 万次请求,99.97% 的可用性

每个服务都有自己的业务逻辑和数据库。用户服务与 PostgreSQL 通信,产品服务使用 MongoDB 存储商品目录数据,订单服务则利用 Redis 进行会话管理。tRPC 的优势在于,类型安全贯穿于整个技术栈。当产品服务更改字段类型时,TypeScript 会立即向所有调用方发出警告。

请求批处理和缓存策略

人们对 tRPC 的一个担忧是,它不像 GraphQL 那样内置请求批处理功能。实际情况是:对于 90% 的用例来说,React Query 的批处理功能已经绰绰有余,而且实际上比 GraphQL 的 DataLoader 模式更容易调试。我们在生产环境中每分钟处理 10000 次请求,批处理功能运行得非常完美。

 

我们的缓存层结合了用于共享数据的 Redis 以及客户端一侧的 React Query 智能缓存。这种组合非常强大,因为 React Query 能精确掌握已有的数据并能即时从缓存中提供数据,而我们的服务器端 Redis 缓存则能高效地处理跨用户数据。目前,产品数据的缓存命中率为 87% ,用户偏好数据的缓存命中率为 92% 。

迁移过程:我们实际上是如何操作的

阶段 1 :绞杀榕模式

我们没有进行彻底重写。那样做需要六个月的毫无业务价值的工作,这会让管理层陷入恐慌。相反,我们采用了“绞杀榕模式”:让两个系统并行运行,逐个迁移端点,验证稳定后再继续推进。

 

我们首先从流量大但业务风险低的只读端点入手,例如用户资料查询、产品目录查询等。这些操作为我们提供了有关性能和可靠性的真实生产数据,同时又不会危及关键的写入操作。在完全切换之前,我们将两个 API 版本并行运行了三周,对比了错误率和延迟指标。

阶段 2 :处理关键的写入操作

一旦从读取操作上获得了信心,我们就着手处理写入操作,包括订单创建、支付处理、库存更新。这些操作一旦出错,就会造成实际的损失。这正是 tRPC 的类型安全真正大放异彩之处。使用 GraphQL 时,我们要不断地处理可空字段、可选参数以及模式漂移问题。而使用 tRPC,只要类型编译通过,就消除了这一整类的 API 契约错误。这并非因为 TypeScript 强制执行运行时正确性,而是因为客户端和服务器不会因为过时的代码生成悄然发生差异。

 

在迁移写入端点的过程中,我们总共发现了两个运行时错误,均是和数据库连接池相关,而非 tRPC 本身。值得注意的是:这是一次迁移,而非从零开始的构建,因此业务逻辑已经经过验证。我们构建的 GraphQL 联邦部署同时包含了 API 层和领域逻辑,这也是事件量比较多的原因。虽说如此,这次迁移过程中近乎零的错误率恰恰说明, tRPC 成功消除了代码生成同步问题。

图 4:请注意迁移完成后的急剧下降

实际实现:真正能投入使用的代码

服务器端路由设置

以下是我们在实际的生产环境中使用的路由配置,虽然去除了业务逻辑,但从中可以看出我们实际的使用模式。该配置负责处理跨微服务的身份验证、请求验证、错误处理以及类型合并:

typescript// apps/api/src/trpc.tsimport { initTRPC, TRPCError } from "@trpc/server";import { Context } from "./context";import superjson from "superjson";const t = initTRPC.context<Context>().create({  transformer: superjson,  errorFormatter({ shape, error }) {    return {      ...shape,      data: {        ...shape.data,        zodError:          error.cause instanceof ZodError ? error.cause.flatten() : null,      },    };  },});export const router = t.router;export const publicProcedure = t.procedure;// 身份验证中间件const isAuthed = t.middleware(async ({ ctx, next }) => {  if (!ctx.session?.user) {    throw new TRPCError({ code: "UNAUTHORIZED" });  }  return next({ ctx: { session: ctx.session, userId: ctx.session.user.id } });});export const protectedProcedure = t.procedure.use(isAuthed);
复制代码

Next.js 14 客户端设置

在可能的情况下,我们的 Next.js 配置会使用新的 App Router 并搭配 React 服务器组件。以下是在我们生产环境中实际运行的客户端配置,其中包括用于自动处理请求批处理的 HTTP 批处理链接:

typescript// apps/web/src/trpc/client.tsimport { createTRPCReact } from "@trpc/react-query";import { httpBatchLink } from "@trpc/client";import type { AppRouter } from "@/server/routers/_app";import superjson from "superjson";export const trpc = createTRPCReact<AppRouter>();export function createTRPCClient() {  return trpc.createClient({    links: [      httpBatchLink({        url: process.env.NEXT_PUBLIC_API_URL + "/api/trpc",        transformer: superjson,        headers: async () => {          const session = await getSession();          return {            authorization: session?.token ? `Bearer ${session.token}` : "",          };        },      }),    ],  });}
复制代码

生产环境中使用的流程结构

以下是我们实际使用的流程结构。该模式涵盖了使用 Zod 进行输入验证、数据库事务、错误处理以及遥测——生产环境中所需的一切:

typescript// apps/api/src/routers/product.tsimport { z } from "zod";import { router, protectedProcedure } from "../trpc";import { prisma } from "../db";import { TRPCError } from "@trpc/server";export const productRouter = router({  getById: protectedProcedure    .input(z.object({ id: z.string().uuid() }))    .query(async ({ input, ctx }) => {      const product = await prisma.product.findUnique({        where: { id: input.id },        include: { variants: true, reviews: true },      });      if (!product) {        throw new TRPCError({          code: "NOT_FOUND",          message: "Product not found",        });      }      return product;    }),  create: protectedProcedure    .input(      z.object({        name: z.string().min(1).max(200),        description: z.string().max(5000),        price: z.number().positive(),        inventory: z.number().int().nonnegative(),      })    )    .mutation(async ({ input, ctx }) => {      // 在生产环境里这里会有 Datadog 跟踪      const product = await prisma.product.create({        data: { ...input, createdBy: ctx.userId },      });      // 失效缓存      await redis.del(`product:${product.id}`);      return product;    }),});
复制代码

得与失

我们犯的错

第一个主要错误:试图复现 GraphQL 的字段级批处理。我们花了两周时间构建了一个自定义批处理系统,后来才意识到, React Query 内置的批处理功能完全够用。我们删除了 800 行代码,性能得到了提升,因为这种更简单的方法开销更小。

 

第二个错误:在客户端进行了过度的验证。我们曾经在客户端和服务器端都运行 Zod 验证,以为这样能更早地发现错误。但实际情况是,因为客户端和服务器端验证结果不一致,引发了令人困惑的错误状态。现在,我们只在服务器端进行一次验证,仅此而已。客户端则信任 TypeScript 的类型。

 

第三个错误:没有尽早建立完善的监控机制。tRPC 的运行速度极快,以至于我们直到性能退化变得十分明显时才察觉到。现在,我们在每个过程上都部署了 Datadog APM,用于追踪 P50、P95、P99 延迟以及错误率。这带来的开销微乎其微,而可视化价值却无可估量。

我们的意外收获

最令人意外的收获是开发效率的提升。如今,我们团队发布新功能的速度提高了 40%,因为他们无需在 SDL、代码生成和实现之间来回切换。你只需编写过程,TypeScript 会自动推导类型,一切就完成了。无需召开模式同步会议,无需等待代码生成运行,只需专注于编写代码。

 

第二个收获是新来的开发者上手速度更快。在使用 GraphQL Federation 时,新来的工程师需要花一周时间来理解模式、网关和代码生成管道,然后才能开始贡献代码。而使用 tRPC 后,他们第二天就能提交代码。只要熟悉 TypeScript 和 Next.js,就能轻松掌握我们的 API。

 

第三个收获在于测试环节。由于 TypeScript 能保证端到端的类型安全,所以我们直接取消了一整类的集成测试。虽然仍然会对业务逻辑进行全面测试,但我们不再需要测试“客户端是否正确处理了该字段”,因为类型系统已经在编译阶段确保其正确性。

什么时候不要使用 tRPC

我们必须明确一点:tRPC 并非万能良方。如果你正在构建一个供第三方调用的公共 API,那么使用 GraphQL 或 REST 会更合理。你需要模式文档、版本控制以及与语言无关的访问方式。而 tRPC 仅支持 TypeScript。

 

如果你开发的是基于 Swift 或 Kotlin 的移动应用,那么 tRPC 无法帮到你。它非常适合你可以同时控制客户端和服务器端的 Web 应用,但无法像 protobuf 或 GraphQL 那样解决跨平台的类型安全问题。

 

说实话,如果你的 GraphQL 配置运行良好,且没有遇到我们曾经经历的那些痛点,就没有必要进行迁移。别处未必更好。我们之所以迁移,是因为 Federation 正在严重消耗我们的开发时间并影响生产环境的稳定性。如果你的情况并非如此,那就继续沿用现有的方案吧。

关键指标数据

以下是我们真实的生产数据,对比了 Apollo Federation 最后一个月与完全迁移至 tRPC 后的第一个月。这些数据来自 Datadog APM,而非合成基准测试:

这些数据来自生产环境。该环境每天处理 12 个微服务产生的 240 万次请求。其中,Bug 数量显著减少——生产环境事件量减少了 89%,这意味着需要处理的突发状况减少了,从而能投入更多的精力进行功能开发。

小结:我们会再做一次吗?

当然会。毫不犹豫。在这次迁移过程中,我们花了六周的时间集中精力进行开发,但通过减少 Bug 修复工作、加快功能开发速度以及提升开发体验,我们已经获得了十倍于投入的回报。我们团队发布新功能的速度提高了 40%,用户获得了更佳的性能体验,而我们也睡得更安稳了——因为我们知道,得益于改进后的类型表示,有一整类运行时问题被彻底消除了。

 

但现实情况是:tRPC 无法解决组织层面的问题。如果你的团队因为流程不完善或责任归属不清而难以驾驭 GraphQL,那么改用 tRPC 也无法神奇地解决这些问题。tRPC 真正能解决的,是与模式同步、类型生成以及 API 契约漂移相关的一系列问题。

 

如果你正在运行一个 TypeScript 单存储库项目,而且被 GraphQL Federation 的复杂性所困扰,那么你可以认真地考虑下使用 tRPC。不妨从小处着手,先迁移一个服务,评估效果,然后再逐步扩展。这就是我们的做法,它从根本上改变了我们构建 API 的方式。

 

我们生产环境的完整代码(包括单存储库结构、路由器配置和测试模式)已经发布在 GitHub上。这是经过实战检验的真实生产代码,每天处理 240 万次请求。你可以将其作为自己迁移工作的起点。

 

声明:本文为 InfoQ 翻译,未经许可禁止转载。

 

原文链接:https://www.infoq.com/articles/building-trpc-api-typescript/