gRPC 与 GraphQL:Node 里的另外两种接口形态
你一定已经会写 REST 了。但工作两三年后,几乎必然会遇到这两个词:GraphQL(前端说"我要什么字段就给我什么,别给我一堆用不上的")和 gRPC(后端说"服务之间调来调去,JSON over HTTP 太费了,用二进制")。它们不是 REST 的替代品,而是解决不同问题的三种工具。这一章的目标:让你在技术选型会上能判断"这个场景该用哪个",并且能跑起来一个能用的最小实现。
先摆清楚:三种接口形态各管什么
| REST | GraphQL | gRPC | |
|---|---|---|---|
| 数据格式 | JSON(通常),结构由服务端定 | JSON,结构由客户端查询定 | Protobuf 二进制,结构与字段号由 .proto 定 |
| 传输 | HTTP/1.1 或 HTTP/2,方法 + 路径 | 通常是 HTTP POST,单一端点 /graphql |
强制 HTTP/2,支持双向流 |
| 核心优势 | 简单、通用、缓存与调试生态最成熟(浏览器直接打开就能调) | 一次请求取多资源,不多不少;schema 即文档,前端不用等后端加接口 | 快、强类型、支持流式;代码自动生成,接口契约编译期就能对 |
| 典型痛点 | 多页面聚合时要发多个请求(over-fetching / under-fetching) | N+1 查询、复杂查询可被滥用、HTTP 缓存不友好、错误码语义弱 | 浏览器不能直接调(要 grpc-web + 代理)、不可读难调试、生态偏重 |
| 用在哪 | 对外 API、开放平台、绝大多数 CRUD 业务 | 前端数据需求多变的产品(中台、BFF)、多端复用同一套接口 | 服务之间的内部调用、跨语言微服务、实时流、大数据量传输 |
论最常见的正确组合:对外 REST + 中间层 GraphQL + 内部 gRPC
对外(给第三方、给开放平台):用 REST。它最好理解、最好调试、最好做缓存和限流,第三方接入成本最低。
前端到 BFF:用 GraphQL。前端页面千变万化,让后端为每个页面写一个定制接口,沟通成本会爆炸;GraphQL 把"取哪些字段"的决定权交给前端,BFF 只负责聚合。
服务之间:用 gRPC。内部调用不需要给人类读,要的是低延迟、强类型、schema 演进安全(Protobuf 的字段号机制让加字段不会破坏老客户端)。
三者不是非此即彼:一个系统里可以三种并存,关键看"这条链路的对面是谁"。
GraphQL:让前端决定要哪些字段
GraphQL 的核心思想是:服务端只声明"有哪些数据、彼此什么关系"(Schema),具体取哪些字段由客户端写 Query 决定。它把"接口数量"从 N 个压成 1 个,代价是把复杂度转移到了服务端的解析器(resolver)上。
最小可用:Apollo Server 5 + 自定义数据加载
坑GraphQL 第一大坑:N+1 查询
假设前端查"20 篇文章及其作者"。朴素实现是:先查 1 次文章列表(1 次查询),然后为每一篇文章去查作者(20 次查询)——总共 21 次。数据一多就是灾难,而且 REST 里你至少能一眼看出来,GraphQL 里它藏在字段级 resolver 里,非常隐蔽。
解法是 DataLoader:它把同一轮事件循环内所有"查 author(id)"的调用合并成一次批量查询(WHERE id IN (...)),再把结果分发回每个调用者。21 次查询变成 2 次。同时它自带请求级缓存,同一个 id 在一次请求里只查一次。
用 DataLoader 干掉 N+1(这段代码值得背下来)
误用一:把 loader 定义成模块级单例。DataLoader 的缓存不区分用户,全局单例会导致 A 用户请求的数据被 B 用户命中——这是实打实的越权数据泄漏。正确做法是在每次请求的 context 里创建(context: () => ({ loaders: makeLoaders() }))。
误用二:批量函数返回顺序乱了。DataLoader 靠"返回数组的下标"对应"传入 id 的下标"。用 IN 查回来的行顺序和传入顺序无关,必须先建 Map 再按 ids 顺序映射回去,中间一个都不能少(缺失要显式给 null)。
误用三:以为 loader 能解决所有 N+1。如果 resolver 里做了条件分支、或者每一层都自己发查询,照样会 N+1。判断方法很朴素:打开 SQL 日志,看一次 GraphQL 请求到底发了多少条 SQL。
GraphQL 的安全:它能被一条查询打垮
REST 里每个接口的成本是"已知"的,天然好限流。GraphQL 只有一个端点,查询的复杂度完全由客户端决定——一条深度嵌套的查询可以让你的数据库跑到天亮。这是 GraphQL 必须做的四件事。
- 字段级授权必须写在 resolver 里,别只在入口做。GraphQL 很容易"从这个关系绕过去拿到不该看的数据"(比如查 order 时带出 user.email)。
- 错误信息别泄漏内部细节:默认的 GraphQL 错误会把堆栈和 SQL 片段带出去,生产环境要关掉 debug / 自定义 formatError。
- HTTP 缓存用不上怎么办:GraphQL 是 POST 请求,CDN 默认不缓存。解决办法是持久化查询后改用 GET + hash,或者用客户端缓存(Apollo/URQL 的 normalized cache)。
gRPC:服务之间用二进制说话
gRPC 是 Google 开源的 RPC 框架,一句话概括:用 .proto 文件定义接口和消息,自动生成客户端/服务端代码,走 HTTP/2 传 Protobuf 二进制。它比 JSON over HTTP 快,是因为三件事:二进制编码更紧凑、有 schema 不用猜字段名、HTTP/2 多路复用没有队头阻塞。
第一步:写 proto(这是唯一的"接口契约")
记Protobuf 用"字段号"而不是"字段名"通信
线路上传的是 1: "abc" 这样的"字段号 + 值",字段名只存在于代码里。所以字段名改了不影响兼容,但字段号绝不能复用——把删掉的 3 号字段重新分配给一个新字段,老客户端会把旧数据当新字段解析,产生静默的数据错乱。
正确做法:删字段时用 reserved 3; 把号段封存,永不复用。这是 Protobuf 演进最重要的一条纪律。
第二步:Node 侧跑一个 gRPC 服务(@grpc/grpc-js)
第三步:调用方(同一份 proto,生成类型安全的客户端)
坑一:浏览器不能直接调 gRPC。浏览器的 fetch/XHR 无法控制 HTTP/2 帧,说不了 gRPC 协议。前端要调必须用 grpc-web + 一个代理(Envoy / grpc-web 代理)。所以"把 gRPC 直接暴露给前端"这个方案不成立,通常是前端 → REST/GraphQL → gRPC 内部服务。
坑二:不设 deadline。gRPC 默认没有超时,下游卡住你的调用就一直挂着,最后引发雪崩。每个调用都要给 deadline,而且 deadline 要小于上游给它的超时预算(层层收敛)。
坑三:把 gRPC 状态码当 HTTP 码用。gRPC 有自己的码表:NOT_FOUND(5)、INVALID_ARGUMENT(3)、UNAVAILABLE(14)、DEADLINE_EXCEEDED(4)。判断"能不能重试"要看码:UNAVAILABLE 可以重试,INVALID_ARGUMENT 重试一万次也没用。
坑四:默认用不安全连接上线。createInsecure() 是明文,只在本地开发用。生产必须用 TLS 凭据,或者跑在服务网格(Istio / Linkerd)里由 sidecar 做 mTLS。
坑五:改了 .proto 忘了重新生成代码。Protobuf 的"向后兼容"是消息层面的,代码层面你必须重新跑 codegen(protoc / ts-proto / buf generate)。把它写进构建脚本,别手工生成。
到底选哪个:一张判断清单
- 对面是第三方开发者或浏览器?→ REST(或 GraphQL,如果对方愿意学)。别给外部用 gRPC。
- 前端页面对同一份数据的字段需求经常变?→ GraphQL。让前端自己写 query,省掉来回改接口。
- 服务之间 QPS 高、延迟敏感、要跨语言?→ gRPC。二进制 + HTTP/2 + 代码生成,是这类场景的标准答案。
- 要传大文件或实时双向通信?→ gRPC 的流式能力(服务端流/双向流)比 REST 的分块/SSE 更规整。
- 团队只有两三个人、要快速上线?→ 先 REST。GraphQL 和 gRPC 都有明确的学习与运维成本,别在没痛点时引入复杂度。
- 已经有大 REST 体系,只想优化某条链路?→ 局部引入。比如"前端 → BFF 用 GraphQL,BFF → 内部服务用 gRPC",存量接口不动。
① REST / GraphQL / gRPC 各管一段:对外 REST、前端到 BFF 用 GraphQL、服务之间用 gRPC,三者可以共存。
② GraphQL 把"取哪些字段"的决定权交给客户端,代价是服务端得管好 N+1(DataLoader)、深度与复杂度限制、字段级授权、生产关内省。
③ DataLoader 必须请求级创建(放 context),批量函数返回顺序必须与传入 id 一一对应。
④ gRPC 用 .proto 定义契约、Protobuf 二进制传输、HTTP/2 承载;支持一元 + 三种流。字段号只加不复用,删字段要 reserved。
⑤ gRPC 两条铁律:浏览器调不了(要 grpc-web + 代理),每次调用必设 deadline。
1.(排错题)线上 GraphQL 接口发现:查一页 20 篇文章及其作者,监控显示数据库 QPS 突然飙到原来的 10 倍以上。最可能是什么问题?怎么修?
查看答案
答案:N+1 查询。每篇文章的作者字段各触发了一次查询,20 篇文章就是 21 次。修法:① 用 DataLoader 把同一轮的 author 查询合并成一次 WHERE id IN (...),并且 loader 要在每次请求的 context 里创建;② 确认批量函数返回的顺序与传入 id 一一对应;③ 用 SQL 日志或 APM 验证一次 GraphQL 请求的 SQL 条数是否已经降下来。
2.(辨析题)同事说"GraphQL 只有一个端点,所以特别好做限流和缓存",这句话对吗?
查看答案
答案:不对,两件事都恰好相反。① 限流:单一端点意味着一句"按 URL 限流"毫无意义,因为一条深度为 10 的嵌套查询和一条查单字段的查询成本差几个数量级,必须按查询复杂度/深度限流;② 缓存:GraphQL 基本都用 POST,CDN 和 HTTP 缓存默认不缓存 POST,需要靠持久化查询 + GET 或客户端 normalized cache 来补。
3.(概念题)Protobuf 里为什么"删掉的字段号不能重新发给新字段"?如果违反会发生什么?
查看答案
答案:因为线上传输的是字段号而不是字段名。老版本客户端发过来的数据里,3 号字段是"旧的 A 字段",而新版本服务端已经把它定义成"新的 B 字段",就会把 A 的值按 B 的语义解析——类型可能对得上、不报错,但数据是错的,属于最难排查的静默 bug。正确做法是删除时写 reserved 3; 永久封存该号段。
4.(场景题)你们的内部服务调用超时严重,排查发现上游服务"卡住一段时间后自己好了",日志里只有零星报错。gRPC 的调用代码里最可能缺了什么?
查看答案
答案:缺 deadline(死线)。gRPC 调用默认没有超时,下游慢的时候调用方会一直挂着、连接和线程资源被占满,等下游恢复才集体返回——表现就是"卡一阵子然后自己好"。修法:每次调用传 { deadline: Date.now() + N },并且让 deadline 层层收敛(上游给的 2s 预算,调下游就用 1.5s),同时按 gRPC 状态码区分能否重试(UNAVAILABLE 可重试、INVALID_ARGUMENT 不可)。
5.(设计题)一个团队要把现有 REST 单体改造成"前端统一走 GraphQL、内部服务之间走 gRPC"的架构。请说出这个改造的分步顺序,并解释为什么不能一步到位。
查看答案
答案(参考分步):① 先建 BFF 层,GraphQL schema 直接包装现有 REST 接口(不动存量);② 前端逐个页面迁到 BFF,同时接入 DataLoader 与查询限制,把性能和安全基线立住;③ 再把后端按领域逐个抽出独立服务,服务间新建接口用 gRPC,BFF 内部改为调用 gRPC;④ 全部稳定后再考虑下线旧的 REST 内部接口。不能一步到位的原因:GraphQL 的 N+1/复杂度/授权问题、gRPC 的 TLS/超时/可观测性都需要逐项补齐,同时改三层会让故障根本定位不了;而且迁移期间必须保证新旧接口并存、可回滚。