我到现在也说不准我们上 GraphQL 是不是对的。
2022 年 3 月那个项目立项,前端 3 个人、后端 2 个、移动端 1 个。后端负责人说上 GraphQL,理由是省得天天加接口。我没反对,动机挺功利——我不想每天在群里回「这个字段能不能再加一个」。
两年过去,接口确实少加了。但省下来的时间,基本又花在别的地方了。下面这些全是线上真实跑着的东西,不劝退也不安利。
一、真正搞死我们的不是复杂查询,是最蠢的 N+1
我一开始的假设是:GraphQL 的性能风险来自客户端乱拼查询,一个请求能拉一大堆数据回来,所以花了两周去调研查询复杂度限制方案,还挺得意。
结果第一年真正打崩我们的是 N+1,而且是特别朴素的那种。
订单列表页,一页 20 条。Order 类型下面挂了 customer、items、coupon 三个字段,每个字段的 resolver 各自去查一次库。一个请求算下来是 1 + 20 × 3 = 61 次 SQL。本地 SQLite 跑着完全无感,上了生产的 MySQL 8,P99 直接 1.2 秒。
解决方案没什么新鲜的,dataloader,我们锁的版本是 2.2.2。三个字段各写一个 batch 函数,按 id 批量查,SQL 次数从 61 降到 4,P99 掉到 180ms 左右。这个数字我印象很深,因为改完那天我盯着监控看了半小时。
坑在这儿:DataLoader 的实例缓存是默认开启的,而且跟着实例走。我们一开始图省事,在模块顶层 new 了一个全局复用,结果缓存跨请求了。测试同学报了个「订单详情偶尔显示别人的收货地址」,我查了整整一个下午才定位到。正确做法是每个请求 new 一批,塞进 context 里。DataLoader 的 README 其实写过这件事,但那句话藏得很深,措辞也委婉,我当时直接扫过去了。
二、两件比 N+1 更麻烦的事
第一件是缓存。GraphQL 默认全走 POST,CDN 天然不友好,这点在选型阶段完全没人提。
我们试过 APQ(Automatic Persisted Queries),把 query 预先算 sha256 存下来,请求时只发 hash 并改用 GET。理论上很美好,实操里 Apollo Client 的配置和 Cloudflare 的 Vary 头一配合就开始出玄学问题,灰度两天回滚。最后退到最土的办法:挑出三个带固定参数的查询(商品详情、类目树、配置开关),手写进 GET 白名单,其余一律 POST 不缓存。这三个接口的 CDN 命中率现在大概七成,够用了。
第二件是错误处理。GraphQL 有个反直觉的设计:查询报错照样返回 HTTP 200,错误塞在响应体的 errors 数组里。
这个设计本身有它的道理,但我们的监控体系扛不住。用的是 Prometheus + Grafana,HTTP 层的错误率面板永远是 0,我一度以为接口稳如磐石,直到翻业务日志才发现每天有几百条 resolver 抛异常。后来写了个 Apollo Server 插件,在 willSendResponse 钩子里扫 errors 数组,有就手动往 Counter 打点,按 error.extensions.code 分 label。插件大概 40 行代码,但它救了我的年终汇报。
三、Apollo Server 3 升 4 这件事,我劝你别跟风
我们 2023 年底升的,从 apollo-server-express 3.13 换到 @apollo/server 4.11。官方迁移文档写得挺清楚,但有几个点藏在细节里。
包名和依赖全换了。expressMiddleware 要自己装 body-parser 和 cors,这两个在 3.x 里是内置的,升级时忘了装,服务能起来,但 req.body 永远是 undefined。
context 的位置变了。3.x 写在 new ApolloServer({ context }) 里,4.x 挪到了 expressMiddleware 的第二个参数。我们的鉴权逻辑原来在 context 里读 JWT 解出用户,升级后那一段静默失效了——不报错,只是 ctx.user 永远是 null,所有接口都变成未登录状态。这种「不报错的失效」是最难查的。
CSRF 防护默认开启。Apollo Server 4 要求请求带 content-type: application/json,或者带一个 apollo-require-preflight: true 的请求头,否则直接拒绝。我们的 App 端用的是一个老版本网络库,默认发 text/plain,升完当天全端报错,排查了两个小时才发现是服务端的锅。
插件 API 重写了。requestDidStart 里那些钩子名字换了一批,我们原来用来做慢查询日志的插件完全不兼容,只能重写。
前后折腾一周,回滚两次。如果说有什么经验:留一个能随时切回旧版本的开关,别在业务高峰期升。
四、深度限制和复杂度阈值,数字到底怎么定
拍脑袋定 10 或者 20 是最常见的做法,我们也是先拍的 12。后来改成 8,依据是数据不是直觉。
具体做法:开一个中间件,把线上 30 天所有 query 的 AST 深度算出来打点,落到一张日志表。统计结果是 99.7% 的请求深度不超过 7,深度超过 10 的一共 400 多条,翻了一遍全是三个来源——爬虫、某个前端同学写的递归 fragment、以及我们自己的一个批量导出脚本。所以最终定 depthLimit(8),用的库是 graphql-depth-limit 1.1.0。
有个细节值得单独说:fragment 也计入深度,而 fragment 是可以循环引用的。有个经典绕过写法是 A 引用 B、B 又引用 A,某些深度计算实现会因此栈溢出或者直接算错。graphql-depth-limit 处理了这种情况,但如果你是自己写校验函数,记得加 visited 集合,不然就是白给。
复杂度我们用的 graphql-query-complexity,maximumComplexity 设 1500。默认每个标量字段 cost 是 1,列表字段要手动乘。我们的规则是:普通字段 1,列表字段 5,列表里再嵌列表 25,另外给三个聚合类字段单独配了 50。这套数字改过三次,每次都是拿线上被拒的请求去反推。
最后一句忠告:这些限制不能只放在 GraphQL 层。我们同时在数据库连接上设了 statement_timeout = 3s,接口层设了 5s 硬超时。因为总有人能写出通过复杂度检查但依然很慢的查询,比如一个不带 where 条件的 count 套在子查询里。
五、我的看法:GraphQL 转移的不是数据,是成本
说了这么多坑,说点更本质的。
REST 里加一个接口,决策权在后端。GraphQL 里前端可以自己拼出想要的形状,决策权跑到前端去了。这确实减少了很多沟通,我们那个「加字段」的群消息从每天十几条降到几乎没有。但成本没有消失,它只是被转移了。
一部分转移给了前端工程师。他们现在得理解字段之间的成本差异、得知道哪些查询会被拒、得学会看 Apollo 的缓存策略。我们后面前端招人,简历里写「熟悉 GraphQL」的不少,能说清 DataLoader 和请求级缓存为什么冲突的,一个没碰到。
另一部分转移给了运维。APQ、persisted query、CDN 缓存、深度限制、复杂度计费、字段级监控、introspection 防护——这些在 REST 世界里大部分不存在,或者有现成的中间件。GraphQL 这边你得自己拼。
所以我的结论有点反直觉:如果团队里前后端比例低于 1:1,或者干脆只有一个前端,别上 GraphQL。它的收益主要来自「减少沟通」,沟通成本本来就不高的团队,换来的是实打实的运维复杂度。我们 3 个前端 2 个后端,勉强算踩在盈亏平衡点上。
至于那两个老生常谈的卖点——类型系统、单端点、按需取数——它们确实是真的,但不是免费的。类型系统要靠 codegen 才能发挥价值,我们用的 @graphql-codegen/cli 5.x,每次改 schema 跑一遍生成 TS 类型,这一步不能省,省了就退化成手写字符串。单端点意味着所有请求打一个入口,限流和熔断都得重新设计。
我不是劝你别用。只是如果有人拿「少发几个请求」来推销 GraphQL,你可以问问他,那些请求省下来之后,你打算用什么填。