REST 为每个资源定一个端点,前端常常"要一个用户却拉回一堆用不上的字段",或"一次页面要调五六个接口"。GraphQL 用一套端点 + 客户端声明要什么解决这两类痛点。本页讲清它和 REST 的差异、Schema/Resolver 模型、查询/变更/订阅的写法、客户端缓存、多语言服务端落地,以及 N+1 与性能安全这套生产必做的功课。它补上了 tech-springai / tech-nodejs 里"API 设计只讲 REST/gRPC"的那块。基线:GraphQL spec 2021、Apollo Client 3.x、graphql-java 22、gqlgen v0.17。
为什么需要 GraphQL:与 REST 的对比
REST 的痛点是过度获取(返回一堆不要的字段)和获取不足(一个页面要多次往返)。GraphQL 让客户端用一次请求、精确声明要的字段树,服务端按图返回。这一章先把"为什么"讲透,再谈什么时候不该用它。
REST 的两个老毛病:过度与不足取数
过度获取(Over-fetching):GET /users/1 返回 30 个字段,页面只要名字和头像,其余 28 个白传。获取不足(Under-fetching):一个页面要用户 + 他的文章 + 文章评论,得调 3~N 个接口串行往返,慢且复杂。后端越通用,前端越难受——这就是 GraphQL 的出发点。
更糟的是"后端为了让前端少调接口,往一个端点塞越来越多字段",结果出现各种 ?include=posts,comments 的半吊子方案——既失去 REST 的简洁,又没换来 GraphQL 的灵活。真到了这步,与其打补丁,不如直接上 GraphQL 一次性解决。
单一端点 + 查询语言:客户端声明要什么
GraphQL 只有一个端点(通常 /graphql),客户端发一段查询文本描述"我要哪些字段、什么结构"。服务端按这份声明执行 Resolver 并原样返回。一次请求就能把整页数据树拉齐。
论本质:把"取数逻辑"交给客户端声明
REST 是"服务端决定返回什么",GraphQL 是"客户端决定返回什么"。后端只暴露一个类型化的能力图(Schema),前端自己组合查询,前后端耦合度骤降。移动端、Web 端、第三方可以各取所需,服务端一套 Schema 通吃。
与 REST / gRPC 三方对比
三者不是你死我活,而是各有主场。REST 简单、HTTP 缓存天然好,适合公开 CRUD;GraphQL 客户端定结构,适合复杂多变的前端;gRPC 强类型二进制、低延迟,适合内部服务间高性能调用。
| 维度 | REST | GraphQL | gRPC |
|---|---|---|---|
| 适用 | 简单 CRUD、公开 API | 复杂前端、多端复用 | 内部服务间高性能调用 |
| 取数结构 | 服务端定 | 客户端定 | 强类型、proto 定 |
| 传输 | JSON over HTTP | JSON over HTTP | Protobuf over HTTP/2 |
| 缓存 | HTTP 缓存天然好 | 需自建(按字段) | 通常不用 HTTP 缓存 |
什么时候不该用 GraphQL
它带来灵活性,也带来复杂度(Schema 管理、N+1、限流、缓存都要自己搭)。简单场景硬上反而亏。
① 接口极简单:就两三个资源,REST 一把梭更省事。② 高度依赖 CDN/HTTP 缓存的公开 API:GraphQL 单端点 + 动态查询天然难用 HTTP 缓存,得自建。③ 内部高性能服务调用:gRPC 更合适。GraphQL 的强项在"多端、复杂、变化快的前端取数"。
心智模型:把"取数能力"画成一张图
别把它当"另一个 REST"。正确的心智模型是:后端画一张能力图(Schema),前端在图上做查询。这张图就是前后端唯一的契约,比一堆散落的 REST 端点好维护得多。后面所有章节都是围绕"这张图怎么定义、怎么执行、怎么保护"展开。
论从 REST 迁移的正确姿势
别一上来重写所有接口。先在最"多端、取数复杂"的那一两个 BFF 场景试点(比如聚合多个微服务给前端),把收益跑出来,再逐步铺开。GraphQL 最该坐的位置是 BFF(前后端之间的聚合层),不是替代你所有内部 RPC。
BFF 聚合层:GraphQL 该坐的位置
GraphQL 最该坐的地方是 BFF(Backend For Frontend,服务于前端的后端):它坐在前端和一堆后端微服务之间,前端只跟 GraphQL 对话,GraphQL 再去调各 REST/gRPC 服务并拼装成前端要的树。这正好衔接 tech-systemdesign 的"网关/聚合"思路。
论别用 GraphQL 替代内部 RPC
内部服务间调用要的是低延迟、强类型、高吞吐,gRPC/protobuf 更合适。GraphQL 的强项是"面向多变前端做聚合取数",放在边界(BFF)收益最大;塞进服务内部反而增加复杂度还没收益。边界用 GraphQL,内部用 RPC,各司其职。
GraphQL 的代价:你失去了什么
灵活性不是免费的。选 GraphQL 前要清楚你放弃了什么:HTTP 缓存不再天然可用(单端点 + 动态查询,CDN 难按 URL 缓存);服务端复杂度上移(Schema 管理、N+1、限流、复杂度都要自建);学习曲线对团队是实打实的成本。别把它当银弹。
| REST 白送的 | GraphQL 要自己补 |
|---|---|
| URL 级 HTTP 缓存 / CDN | 规范化缓存 + 持久化查询 |
| 按端点限流 | 解析层复杂度/深度限流 |
| 浏览器直接调 | 需网关/服务端防滥用 |
论一个决策清单:该不该上 GraphQL
① 前端多变、多端复用、取数复杂 → 上。② 接口极简、公开 CRUD、强依赖 CDN 缓存 → REST 更省。③ 内部服务间高性能调用 → gRPC 更合适。一句话:痛点落在"过度/不足取数 + 前后端耦合"上,才值得引入 GraphQL 的复杂度,否则是给自己加担子。
如果你的接口就三五个、前端就一个、变化不快,REST 一把梭省下的精力远高于 GraphQL 的取数灵活性。技术选型看"痛点是否被命中",而不是看社区热度。等真的被过度/不足取数反复折磨,再上不迟。
Schema 与类型:前后端共享的契约
Schema(用 SDL 定义)是前后端共享的契约:有哪些类型、字段、关系。每个字段背后有一个 Resolver——负责真正去数据库/API 取这字段的数据。这一章把"这张图"画清楚。
SDL:用声明式语言定义契约
SDL(Schema Definition Language)是 GraphQL 自己的类型定义语法,和语言无关。你描述"有什么类型、字段是什么类型、是否必填(!)",工具据此生成文档、校验查询、甚至生成代码。
| SDL 关键字 | 含义 |
|---|---|
| type / interface / union | 对象 / 接口 / 联合类型 |
| enum / scalar | 枚举 / 自定义标量 |
| ! | 非空修饰符 |
| @deprecated | 标记字段弃用 |
标量、枚举与自定义标量
GraphQL 内建标量:Int、Float、String、Boolean、ID。业务常有"日期、金额、JSON"这类,内建没有——用自定义标量扩展(在 Schema 声明 + 在 resolver 里写序列化/反序列化)。枚举(enum)限定取值集合,比裸 String 更安全、可文档化。
论为什么用 enum 而不是 String
String 状态值拼错("SHIPED")只有运行时才炸;enum 在查询校验阶段就拒绝非法值,前端还能从 introspection 拿到所有可能值自动生成下拉。把"合法取值"固化进契约,是 GraphQL 强类型红利的一部分。
接口与联合:多态查询
当返回可能是多种类型时:接口(interface)是"有共同字段的抽象"(如 SearchResult 都有 title);联合(union)是"几种不相关类型的集合"(如 FeedItem = TextPost | ImagePost | VideoPost)。客户端用 ... on 类型 内联片段分别取各类型的字段。
接口和联合特别适合"搜索 / 信息流"这类返回异构结果的场景:一次查询就能把文本、图片、视频卡片各取所需,前端用 __typename 分流渲染。但若返回类型高度同质,硬上 union 只会增加复杂度,普通 type + 可空字段往往更简单直接。
Resolver:每个字段背后的取数函数
Schema 只说"有什么",Resolver 说"怎么来"。每个字段可挂一个 resolver 函数,输入是(父对象、参数、context、info),输出是该字段的值。没写的字段默认用父对象上的同名属性,所以只有"需要算/需要查"的字段才写 resolver。
论Resolver 是"按需执行"的
客户端没要 posts 字段,对应的 resolver 就不会跑——这正是 GraphQL "不多取"的根源。但也意味着嵌套字段越多,触发的 resolver 越多,性能全看你在这些函数里怎么取数(这就引出第 5 章的 N+1 与 DataLoader)。
Schema 即契约:变更要谨慎
Schema 是公开承诺。删字段、改类型会直接破坏线上客户端查询。生产靠版本化 + 废弃标记(@deprecated)+ 宽限期平滑过渡,而不是硬砍。
论版本化策略:隐式演进 vs 显式版本
隐式演进(推荐):不换端点,靠 @deprecated 平滑过渡,客户端无感升级,是 GraphQL 社区主流。显式版本(/graphql/v1、/v2):干净但老版本要长期维护、客户端迁移成本高。除非是被强合规约束的对外开放 API,否则默认走隐式演进更省心。
① 直接删字段:还在用它的客户端当场 400。② 把可空改非空:老数据/老查询全崩。③ 改字段类型(String→Int)同样破坏性。安全做法:加新字段、旧字段标 @deprecated、监控其调用量归零后再删。配合Schema Registry 做"破坏性变更"卡点。
Schema 优先 vs 代码优先:两条落地路径
定义 Schema 有两种流派:SDL 优先(schema-first)先写 .graphql 文件,再生成代码骨架,前后端先对契约;代码优先(code-first)先写 resolver 类,再从代码导出 SDL。小团队用 code-first 起步快,跨团队协作用 schema-first 更稳(契约先行、并行开发)。
论选哪条都行,但别混
两条路都能成,关键是一个项目只走一条。混用会导致"代码和 SDL 谁为准"的扯皮,最后两边对不上。schema-first 要配代码生成保证实现一致;code-first 要定期导出 SDL 给前端、并接入 Registry 防破坏性变更。
Schema 文档与自省:让契约被人也由工具读懂
GraphQL 的一大爽点是 introspection(自省):客户端能"反过来问"服务端"你有哪些类型、字段、参数、默认值",于是文档、IDE 自动补全、代码生成都能自动生成。配合 GraphiQL / Apollo Studio 这类工具,前端不用等后端写文档就能探索 API,前后端对接效率大增。
论自省是双刃剑,生产要关
introspection 让开发体验起飞,但也把完整 Schema(含内部类型、敏感字段名)暴露给任何能访问端点的人。所以开发环境开、生产环境关是标准做法;生产若需文档,用持久化查询白名单 + 受控文档站替代裸 introspection,既安全又不丢可探索性。
查询 / 变更 / 订阅:三种操作写什么
GraphQL 有三类根操作:Query 读、Mutation 写、Subscription 订阅实时推送。它们共用同一套 Schema 与类型系统,只是语义不同。这一章把三种写法、变量、指令、片段讲全。
Query:声明式读取
Query 就是"我要的字段树",可以用参数控制筛选/分页(如 posts(first: 3, after: "..."))。返回结构严格镜像你写的查询,不多不少——这是它和 REST 最大的体感差异。
顺带一提:GraphQL 的响应结构严格镜像请求结构,前端要什么就只返回什么,天然杜绝了 REST 的过度获取;但反过来,前端若漏写一个字段,后端也不会"好心补上",所以契约要对齐清楚,别指望服务端兜底。
变量与指令:让查询可复用、可条件
变量($)把查询参数化,前端传 JSON 变量,查询文本可缓存复用。指令(directive)控制执行:@include(if:) 条件包含、@skip(if:) 条件跳过——同一份查询根据场景决定取哪些字段,避免写 N 份变体。
论@skip/@include 不是"动态改字段"的银弹
它解决"同一查询根据开关取不同字段",但若分支很多、组合爆炸,不如拆成几个具名查询 + Fragment 组合更清晰。指令适合少量条件开关;复杂多变场景仍建议多查询 + 共用 fragment,可读性与缓存命中率都更好,也避免一个巨型查询谁都不敢改。
Mutation:写操作与原子性
Mutation 改数据(增删改)。规则:一次 mutation 里的多个字段按顺序串行执行(保证可预期),且返回更新后的对象让前端直接拿到新状态。设计原则:让 mutation 返回"被改的对象",客户端拿到就能更新缓存,不必再发一次 query。
多个字段在一个 mutation 内是顺序执行的,但跨 mutation 不要假设先后顺序。另外常见坑:mutation 返回的对象漏了某个字段,导致前端缓存里该字段被"清空"(GraphQL 缓存按字段合并)。返回对象时把前端可能要的字段尽量带上。
Subscription:基于事件的实时推送
Subscription 是"长连接的查询":客户端订阅一个事件流,服务端在有数据时主动推送(底层常用 WebSocket)。适合聊天、实时大盘、协作光标。
论订阅不是"轮询的替代品"那么简单
订阅需要服务端维持连接 + 有发布机制(如 Redis pub/sub、Kafka)。别拿它做"高频行情推送"还每个 client 单独查库——应在服务端聚合后统一 publish。订阅的连接数也要限流,否则 WebSocket 把网关打爆。
片段(Fragment):复用字段集合
Fragment是"一组字段的命名块",多处复用,避免复制粘贴、也保证 UI 组件要的字段集合一致。配合代码生成(第 4 章),fragment 还能直接变成类型安全的组件 props。
错误与空值:errors 数组与 nullable 设计
GraphQL 的错误模型很特别:部分成功。即使某个字段报错,其他字段照常返回,错误进顶层 errors 数组并带 path 指明哪个字段挂了。这和 REST"整条 500"不同,前端要分别处理 data 和 errors。
把本该非空的字段设成可空,前端不报错却拿到 null,UI 崩在运行时。设计原则:能确定有值就标 !(如 id)。另外错误别用异常绕过 data——GraphQL 里业务错误应进 errors 数组并带上 path,前端才好定位,而不是整个查询失败。
内联片段与元字段 __typename:多态查询的钥匙
当返回的是 union/interface(多种类型混在一起),客户端用 ... 内联片段按具体类型取不同字段。此时 元字段 __typename 极其关键:它返回对象的类型名,前端据此决定渲染哪个组件,也是 Apollo 规范化缓存主键的组成部分。
| 写法 | 作用 |
|---|---|
| ... on ImagePost { url } | 类型匹配时才取该字段 |
| __typename | 拿类型名,驱动渲染/缓存 |
| fragment X on Type | 命名可复用字段块 |
论__typename 是缓存正确性的命脉
Apollo 缓存主键是 __typename + id。如果类型没稳定 id(如匿名事件),至少 __typename 区分了类型,避免不同类型对象被当成同一份。缺失 __typename 的查询结果,规范化缓存会退化甚至串数据。所以查询里常隐式带着它,别随手删。
客户端:缓存、代码生成与状态管理
GraphQL 把"取什么"交给客户端,客户端工具链因此很重。Apollo Client 最流行,自带规范化缓存;Relay 与 React 深度整合、强约束;代码生成把 Schema 变成类型安全代码。这一章讲清它们怎么帮你少写样板、不写错。
Apollo Client:查询即状态
Apollo Client 让你用 useQuery 声明"我要什么",它负责发请求、管缓存、暴露 loading/error/data。你几乎不用手写 fetch 和状态管理——查询本身就是组件的数据源。
注意:useQuery 默认把请求和缓存绑定,组件卸载时 Apollo 不会自动清缓存(便于跨组件复用同一份数据)。需要"只活在当前组件、不污染全局"的数据,用 fetchPolicy 控制(如 cache-and-network),并留意缓存体积别无限增长。
规范化缓存:靠 __typename + id 去重
Apollo 默认把返回"拍平"成一张规范化表,键是 类型名 + id(如 User:u1)。同一对象在不同查询里出现,只存一份——A 页改了名字,B 页自动更新。这是 GraphQL 客户端缓存比 REST 聪明的根本。
论没有 id 的实体会破坏规范化
缓存靠 __typename + id 当主键。如果你的类型没有稳定 id(比如一个列表项只有下标),规范化会退化、同对象多处出现无法联动更新。给每个可缓存类型一个稳定唯一键(用 dataIdFromObject 自定义也行),否则缓存一致性会出诡异 bug。
Relay:游标分页与编译期约束
Relay(Facebook 出品)和 React 绑定深,强制用游标分页(Connection/Edge/Node)规范,且用编译期 GraphQL(graphql-compiler)把查询预编译、自动生成带类型的组件。约束多、学习陡,但大型应用里一致性极佳、性能可控。
Relay 的代价是强约束:它要求你用它的 Connection 规范写分页、要跑编译步骤、组件和查询强绑定。小项目会觉得束手束脚;但当前端规模上百个组件、多人协作时,这种"约束"反而成了防止混乱的护栏,长期维护成本更低。
代码生成:把 Schema 变成类型安全的代码
手写 query 字符串容易拼错字段、且 TS 拿不到类型。代码生成(如 graphql-codegen)读 Schema + 你的 .graphql 文件,自动产出 TS 类型、React hooks、甚至后端 resolver 类型——字段改名,编译期就报错。
不用代码生成,前端 query 和 Schema 的契约靠人肉对齐,Schema 一改,前端静默拿到 undefined,运行时才炸。上了 codegen,Schema 变更 → 重新生成 → 类型不匹配编译报错,把前后端契约错误前移到编译期。中大型项目强烈建议默认开。
乐观更新:先改 UI 再等服务端确认
写操作(如点赞)如果等服务端返回再刷新,会有明显卡顿。乐观更新:本地立刻把 UI 改成"预期结果",服务端确认后对齐,失败则回滚。Apollo 的 update 回调直接改缓存,实现"无感"交互。
论乐观更新要能回滚
乐观即"先假设成功"。若服务端返回错误,必须把缓存改回去并提示用户,否则 UI 显示成功实际失败,比卡顿更糟。Apollo 在 mutation 报错时会自动回滚乐观写入,但你自定义 update 时要留意这个契约。
若返回对象的 id 不唯一(如列表项用数组下标当 key),Apollo 规范化会错乱:A 页的数据覆盖 B 页,UI 出现"改了这里、那里也变"的诡异现象。务必保证每个可缓存类型有稳定唯一键;必要时用 dataIdFromObject 自定义主键,否则这类 bug 极难排查。
分页模式:offset 与 cursor 的深坑
分页两种:offset/limit(按页码)简单但数据插入时会漏/重(翻页中途新增一条,整页错位);cursor(游标)基于"上一页最后一条的指针"取下一页,结果稳定,是 GraphQL 推荐做法(Relay Connection 规范)。
实时写入的列表用 offset 翻页,第 1 页拉走 10 条,第 2 页时新插入 1 条,原来第 11 条变成第 12 条,你要么重复要么漏。游标分页基于"上一条的位置",不受新增影响。面向可能变动的数据,一律游标。
errorPolicy:局部错误时 UI 怎么办
GraphQL 支持"部分成功"——部分字段报错其余照常返回。Apollo 的 errorPolicy 控制怎么对待这种错误:默认 none(有错就当整体失败、data 置空);ignore(忽略错误、保留能拿到的 data);all(两者都要)。选错策略,要么白屏、要么 silently 丢数据。
论别用 ignore 掩盖真问题
ignore 适合"非关键字段挂了也不影响主流程"(如推荐位加载失败,正文照常)。但把全局查询都设 ignore,会 silent 丢掉错误、问题藏到生产才暴露。原则是:关键数据用 none/all 暴露错误,装饰性数据才用 ignore 保体验。错误可见性优先于表面流畅。
服务端实现:多语言落地与 N+1 根治
GraphQL 是语言无关的规范。这一章给三种主流服务端栈的最小落地(Node Yoga / Java graphql-java / Go gqlgen),并聚焦生产第一要务:用 DataLoader 批处理根治 N+1。
Node.js:用 GraphQL Yoga 起一个服务
GraphQL Yoga 是现代 Node 服务首选:零配置、内置批处理/订阅/文件上传,支持任何 HTTP 框架(甚至 Serverless)。下面是最简骨架。
Java:graphql-java 与 Spring for GraphQL
Java 侧有底层库 graphql-java,更常用的是 Spring for GraphQL——它和 Spring Boot 一体,Schema 用 SDL 写,resolver 用 @Controller + @QueryMapping 写,依赖注入、安全、DataLoader 都接 Spring 生态。呼应 tech-springai。
Go:用 gqlgen 做类型安全服务
gqlgen 是 Go 生态主流:根据 SDL + 一个 models 配置,生成 resolver 接口和绑定代码,你只填业务逻辑。好处是编译期保证 resolver 签名和 Schema 一致,没有运行时才发现字段对不上。
DataLoader:批处理根治 N+1
这是生产必做项。嵌套查询里每个 posts resolver 各查一次库,10 个用户会变成"1 次查用户 + 10 次查文章"的 N+1 风暴。DataLoader 把同一 tick 内的子查询合并成一次批量请求并加缓存,把 N 次变 1 次。
论为什么和 ORM 的 N+1 是同一类病
本质都是"在循环里发查询"。REST 也有,但 GraphQL 因为嵌套查询天然易触发,所以 DataLoader/批处理是上生产前的必做项,否则优点全被性能坑抵消。注意 DataLoader 是按请求作用域创建的(每个请求一个实例),不能做成全局单例,否则串数据。
补充一个直觉:N+1 的本质不是"嵌套了几层 resolver",而是"每个 resolver 各发一次独立查询"。哪怕只有两层,只要子字段对每条记录都单独查库,N 条父记录就触发 N 次查询。DataLoader 正是在这一层把 N 次合成 1 次,与嵌套深度无关——所以治它要从"数据访问"下手,而非减少字段。
Context 与依赖注入:把公共能力塞进上下文
每个 resolver 第三个参数 context 是"本次请求的共享背包":放当前登录用户、DataLoader 实例、数据库连接、追踪 ID。鉴权、多租户、限流都从 context 取,不该每个 resolver 自己查。
DataLoader 内部有 per-request 缓存,若做成全局单例,用户 A 的批量结果会被用户 B 读到——严重的数据串号漏洞。必须在每个请求开始时 new 一个,请求结束随 context 一起释放。
Node 是单线程事件循环,resolver 里同步读大文件、密集计算、没 await 的 CPU 重活会阻塞所有并发请求,整个服务卡死。耗时操作必须异步(await)、或下沉到专门的后端服务 / worker。所以 resolver 通常只做"编排 + 调下游",重活在别处。
Apollo Server 插件与统一错误处理
服务端横切逻辑(日志、错误格式化、耗时统计)用 plugin 统一做,而不是散在每个 resolver。比如把内部异常转换成对前端友好的错误、记录每个操作的耗时、在错误里带上 traceId 方便排查。
论错误别把内部细节漏给前端
默认 GraphQL 错误可能带堆栈/内部字段名,对攻击者是大礼包。生产应在 plugin 层把错误"脱敏":对外只给安全 message + 错误码,详情写日志。配合第 6 章的鉴权与复杂度限制,形成完整的安全闭环。
测试 GraphQL:从解析器单测到端到端
GraphQL 的测试分三层:解析器单测(mock 掉数据源,验证每个 resolver 逻辑)、查询测试(发真实 query 文本,验证返回结构)、Schema 契约测试(对比生产 Schema 防破坏性变更)。最该先写的是 resolver 单测——它最贴近业务逻辑、跑得最快、性价比最高。
论把 DataLoader 也 mock 进测试
测 resolver 时,DataLoader 的批处理行为容易掩盖 N+1。测试里要么用真实(但 mock 底层)的 DataLoader,要么断言"同一批 key 被合并成一次 load 调用",才能确保生产不会因为忘了批处理而退化成 N+1 风暴。测试是守护性能护栏的一道闸,别只测正确性、漏测性能假设。
性能与安全:N+1、限流、复杂度与鉴权
GraphQL 的灵活性是双刃剑:客户端能写任意深、任意复杂的查询,恶意或手滑就能打垮服务端。这一章讲清生产必须加的护栏——N+1 已讲,这里聚焦限流、复杂度限制、鉴权与持久化查询。
复杂度 / 深度限制:挡住"查询炸弹"
客户端可以写 user { friends { friends { friends ... } } } 这种指数爆炸的嵌套。深度限制卡嵌套层数;复杂度限制给每种字段算"代价分",总和超阈值就拒。两者结合,防止一个查询拖垮全站。
论复杂度上限靠观测来定
复杂度阈值不是拍脑袋:先收集线上真实查询的复杂度分布,把 p99 作为基准再留余量。太松挡不住攻击,太紧会误伤正常查询。配合按用户配额度,比全局一刀切更稳,也更不容易误伤真实业务高峰。
单纯限深度挡不住"浅但宽"的查询:users(first: 100000) { posts(first: 100000) } 层数很浅,但笛卡尔乘积是百亿级。必须深度 + 复杂度(含列表倍数)双管齐下,并对列表参数 first 设硬上限(如 ≤100)。
限流:GraphQL 单端点的特殊难题
REST 可以对每个 URL 限流,GraphQL 只有一个端点——所以要在解析层限流:按用户/IP + 操作类型限流,或按"查询复杂度"限流(见上)。别指望在 HTTP 层仅靠 IP 限速就能护住。
落地上,限流常和 网关 配合:在 GraphQL 网关层(如 Apollo Gateway、graphql-go 中间件)统一做复杂度校验 + 配额,而不是散落在每个 resolver。这样一处设防、全接口受益,也方便按用户 / 租户维度配不同额度。注意限流本身要幂等——被限流的请求不该产生部分副作用。
鉴权与授权:在 context 层与 resolver 层做
认证(你是谁)在入口(中间件/context)做,把 user 放进 context;授权(你能看什么)在 resolver 里按字段精细控制(如普通用户看不到 salary 字段)。GraphQL 没有"字段级 HTTP 权限",得自己实现。
论别在 Schema 里"藏"敏感字段
有人图省事:把 salary 留在 Schema 里、只在 resolver 拦。但 GraphQL introspection 会把字段暴露给任何人(包括攻击者枚举)。生产应关掉生产环境 introspection,并按角色决定字段是否出现在 Schema(或用 @auth 指令在网关层统一拦截),而不是只靠 resolver 软拦。
持久化查询(Persisted Queries):防滥用 + 省带宽
持久化查询:客户端不直接发查询文本,而是发一个"查询的哈希/ID",服务端维护"ID → 查询"的映射。好处:① 客户端不用传巨大查询体(只传 ID,省带宽,利好移动端);② 服务端只允许白名单里的查询,彻底挡住任意复杂查询注入;③ 配合 CDN 缓存。
可观测:追踪每个 resolver 的耗时
GraphQL 单端点让传统"按 URL 看 QPS/延迟"失效。要接入字段级追踪(tracing):记录每个 resolver 的耗时,配合 Apollo Studio / OpenTelemetry,才知道"这个慢查询到底慢在哪个字段"。
正因为单端点,传统的"按 URL 看 QPS / 延迟 / 错误率"在 GraphQL 失效——所有请求都打同一个 /graphql。所以要换思路:按 操作名(operation name) 和 字段路径 做统计,才能知道"哪个查询慢、哪个字段是热点",排障才有抓手。
论没有字段级可观测 = 盲飞
REST 慢了看哪个接口就行;GraphQL 一个端点,慢查询可能是任一嵌套字段的 resolver 慢(往往又是 N+1 没治)。没有按字段/按操作的 tracing,你只能猜。上生产前把 tracing + 查询复杂度日志接好,是排障的眼睛。
Schema 演进与弃用流程:破坏性变更怎么走
Schema 会长大,但破坏性变更(删字段/改类型/收窄非空)绝不能硬来。正规流程:① 加新字段并文档化;② 旧字段标 @deprecated 并说明替代;③ 监控其调用量,归零后下个版本删除。配合 Schema Registry 在 CI 卡住破坏性 PR。
① 删除仍在被查询的字段;② 把可空改非空(旧客户端直接崩);③ 改字段类型(String→Int);④ 改参数是否必填。这些在 PR 里靠 schema:check 拦;上线前还要确认没有客户端还在用旧字段(用用量监控),否则"悄咪咪"破坏线上。
论把"演进纪律"写进流水线
靠人记"别删字段"不靠谱。把 schema:check(对比生产历史 Schema) 接进 PR 检查,破坏性变更直接红。这是 GraphQL 能长期健康演进、不被历史包袱压垮的工程保障——和第 2 章"Schema 即契约"首尾呼应。
GraphQL 用"单端点 + 客户端声明字段树"解决 REST 的过度/不足获取,与 REST(简单公开 CRUD)、gRPC(内部高性能)各有主场,强项在复杂多变前端;核心是 SDL 定义的 Schema(前后端契约,含标量/枚举/接口/联合)+ Resolver(按字段取数、按需执行);三种操作 Query/Mutation/Subscription 共用类型系统,配合变量、@include/@skip 指令、Fragment 复用;客户端靠 Apollo 的 __typename+id 规范化缓存实现对象联动更新,Relay 强约束游标分页,代码生成把契约错误前移到编译期;服务端可落 Node Yoga / Spring for GraphQL / Go gqlgen;生产必做 DataLoader 批处理根治 N+1(且须 per-request 实例防串号);安全护栏靠深度+复杂度限制、解析层限流、context 层认证+字段级授权、持久化查询白名单与关闭生产 introspection,并以字段级 tracing 保障可观测。
1.GraphQL 相比 REST 最主要解决了哪两个痛点?什么场景反而不适合它?
查看答案
过度获取(返回一堆用不上的字段)和获取不足(一个页面要多次接口往返)。反而不适合:接口极简单、或高度依赖 CDN/HTTP 缓存的公开 API(REST 更省事);以及纯内部高性能服务调用(gRPC 更合适)。
2.什么是 GraphQL 的 N+1 问题?怎么根治?根治时有什么必须注意的?
查看答案
嵌套查询中每个子字段 resolver 各自查库,导致 1 次主查询 + N 次子查询风暴。用 DataLoader 把同批子查询合并成一次批量请求并加缓存即可根治。注意 DataLoader 必须每个请求新建实例(per-request),绝不能是全局单例,否则会串数据。
3.为什么只限制查询"深度"还不够?应该怎么配合?
查看答案
只限深度挡不住"浅但宽"的查询,如 users(first:100000){posts(first:100000)} 层数浅但笛卡尔乘积百亿级。应深度 + 复杂度(含列表倍数)双管齐下,并对列表参数 first 设硬上限(如 ≤100)。
4.Apollo 的规范化缓存为什么能让"A 页改了名字、B 页自动更新"?
查看答案
缓存以 __typename+id 为键把对象拍平存成一份;同一对象在不同查询里出现只存一次,多处引用同一份。任一查询更新了该对象,所有引用它的 UI 自动联动刷新。前提是每个可缓存类型有稳定唯一键。
5.生产环境为什么要关掉 GraphQL introspection?
查看答案
introspection 会把完整 Schema(含敏感字段名)暴露给任何人,攻击者可据此枚举并构造查询。生产应关闭它,并按角色决定字段是否出现在 Schema(或用 @auth 指令在网关统一拦截),而不是只靠 resolver 软拦。
6.持久化查询(Persisted Queries)带来哪三个好处?
查看答案
① 客户端只发查询哈希/ID,省去传输巨大查询体(利好移动端);② 服务端只允许白名单内的查询,彻底挡住任意复杂查询注入;③ 配合 CDN 做缓存。自动持久化查询(APQ)还支持首查带文本、后续只带 hash。
下一步往哪走
路学完 GraphQL 之后
① 串 API 设计:回 tech-java / tech-nodejs 看 REST/gRPC,三者对比选型,别二选一。
② 串架构:本页的 BFF 聚合层呼应 tech-systemdesign 的网关与聚合;N+1/限流呼应高可用。
③ 动手:用 Apollo Server + Apollo Client 写一个"博客"最小 GraphQL 服务,故意触发 N+1 再用 DataLoader 修,并加上复杂度限制与持久化查询验证护栏。