前端只发了一句话——"把这 20 个订单,连同它们的收货人、商品明细、以及每个商品的库存一起给我"——服务端老老实实翻译成了 140 多次数据库访问。请求语法完全合法,参数没有越界,鉴权也过了,日志里连一条告警都没有。它跟恶意请求的区别只有一个:恶意请求是从外面来的,这个是从自己的前端来的。
这就是 GraphQL 最容易被低估的一面。把查询形状的自由度交给客户端的同时,把复杂度从客户端搬到了服务端——REST 时代"一个接口返回什么"是后端定死的,GraphQL 时代这件事由客户端那句 query 决定,而服务端是照着字面执行的。中间少了一层"这个请求我到底能不能扛得住"的判断,放大就发生了。
放大不用等到被攻击才会出现。它最常见的触发方式是业务正常迭代:前端为了少发一次请求,把两屏数据塞进一句 query;某个组件复用了父组件的数据结构,多带了三个字段;下拉刷新改成一次拉 100 条。每一步都合理,合起来就把单次请求的成本推高了一个量级。
危险的是这种请求在测试环境几乎看不见。测试库里订单只有几条,N+1 查出来是 3 次而不是 60 次,接口照样秒回。上线之后数据量一上来,同一个 query 在几百次数据源访问里爬行,连接池先告警,然后才是接口超时。
它会横向拖累其他请求。一个请求占着几十条数据库连接几十秒,其他请求的等待时间跟着上去了。表现很典型:整体流量没涨,全站 P99 突然变差,而监控上 CPU 和带宽都很平静——资源被消耗在等待里,不是被消耗在计算里。
服务端能做而且必须做的事,是提前设闸。不是等慢查询出现再去优化,而是在查询被执行之前就判断它的成本,超了就拒绝并给出明确错误。三道闸分别是深度上限、复杂度评分、分页条数上限,缺一道都能被绕过去。
设闸之后,DataLoader 和缓存才有意义。前者把本来就必要的访问合并掉,后者把重复的访问省掉。顺序不能反过来——没设上限就先上缓存,等于在给一个漏水的池子加水。
要说清放大,得先看 GraphQL 的执行模型。服务端拿到的不是一条 SQL,而是一棵查询树,执行器按深度优先遍历这棵树,每遇到一个字段就调用一次这个字段的 resolver,拿返回值,再往下走一层。关键就在这里:resolver 的调用单位不是"字段",而是"某个对象上的某个字段"。
列表让这个规则变得可怕。根字段 orders 的 resolver 执行一次,返回 20 个订单对象;接下来 buyer 这个字段不属于"订单列表",而属于"列表里的每一个订单",所以它会被执行 20 次。执行器不会自作主张把 20 次合并成一次,它不知道这 20 次内部能不能合并——那是 resolver 自己的事。
于是就有了经典的 N+1:根字段一次查询拿到 N 条记录(那 1),然后 N 条记录各自的子字段触发 N 次查询(那 N)。N 是 20 的时候还行,N 是 200 的时候就是 201 次。
这里有个必须说清的关系:resolver 调用次数是数据库查询次数的上界,不是等式。字段底下的数据可能有三种情况。第一种是默认 resolver——GraphQL 在没有自定义 resolver 时直接从父对象的同名属性取值,这是内存操作,不碰数据库,这种字段被调用一万次也不产生一次查询。第二种是自定义 resolver 但数据在父对象已经带出来了,同样不查库。第三种才真的要访问数据源。
所以排查 N+1 的第一步不是猜,是数。给每个 resolver 加一层计数埋点,记录"调用次数"和"实际发起数据源访问次数"两个值,两者的差值告诉你有多少是白跑的循环,后者才是要治理的对象。很多团队改完之后发现 resolver 调用次数没变,数据库查询从 140 次降到 4 次——因为大部分字段其实不必各自访问数据源,是被写法逼成了那样。
ORM 的懒加载会把这件事隐藏得很深。开发时写 order.buyer.name,看起来是一行属性访问,实际在访问 buyer 的那一刻触发了一次 SQL。在 GraphQL 里这种写法尤其致命,因为触发点被埋在 resolver 里,没有人会意识到"取个名字"是一次数据库往返。显式声明加载策略、或者干脆禁用懒加载,是治理的第一步。
还有一点容易被忽略:现代 GraphQL 执行器会把同一层的 resolver 并行调度。这当然是好事,但它让 N 次查询几乎在同一毫秒发出,直接压到连接池上限。串行执行时 100 次查询摊开或许是 200 毫秒,并行执行时就是同一瞬间要 100 条连接——连接池被打满的表现是后续请求全部卡在获取连接上,而不是数据库本身慢。这也解释了为什么 N+1 的症状经常是"全站变慢"而不是"这一个接口慢"。
单层 N+1 是加法:1 + N。麻烦的是 GraphQL 的查询树是嵌套的,每往下一层,上一层的每个元素都会各自往下展开一遍。层数一多,总调用次数是各层扇出的乘积,不是求和。
算例(明确说明:这是按执行模型推导的算例,不是任何环境下的实测数据)。查询长这样:
orders(first:20) { items(first:3) { detail { name } stock { qty } } }
假设每个订单平均 3 个商品,没有批量加载层,每个列表字段和每个需要外部数据的对象字段各自访问一次数据源:
第一层:orders 一次查询拿到 20 个订单 → 1 次。
第二层:每个订单解析一次 items,20 个订单就是 20 次 → 拿到 60 个商品。
第三层:60 个商品各解析一次 detail,60 次;各解析一次 stock,再 60 次。
合计:20 + 60 + 60 = 140 次数据源访问,加上根的那 1 次,141 次。
客户端那句话只有一行。多米诺倒在第三层。把它写成公式:第 n 层的 resolver 调用次数约等于它上面所有层扇出的乘积。第一层 20,第二层 20×3=60,第三层 60×字段数。只要有一层的扇出不受控——比如中间某个列表没有分页约束,一次返回 500 个元素——第三层的数字立刻从 60 变成 1500。
这也是无限递归的危险所在。只要 schema 里存在自引用关系(用户的好友的好友的好友、目录的父目录的父目录),一句语法完全合法的查询就能让执行器一直往下展开。每层扇出 5、嵌套 8 层,总量在万级;嵌套 12 层,量级直接到百万。这不是理论恐吓,任何有自引用关系的 schema 都天然具备这个条件,缺的只是一个发这条查询的人。
深度上限是最早该配、也最好理解的一道:数一下查询树嵌套了几层,超过阈值直接在校验阶段拒绝,根本不进执行阶段。它的实现很轻量——解析成 AST 之后数一下最深路径就行,对各语言都有成熟中间件。
配的时候有几个细节容易出错。一是计数要排除 introspection 元字段,__typename 这类字段会被前端工具自动插入,把它们算进深度会让正常查询莫名其妙超限。二是阈值要按自己业务最深的合法查询往上留,而不是拍一个数:把自己 App 里实际发出的 query 全收集起来,找出最深的那条,加 2 到 3 层作为余量。多数业务落在 7 到 12 这个区间,但这只是常见取值区间,不是推荐值。
它的价值在防御递归类型的查询——自引用展开这类攻击,一层深度限制就废掉了,成本极低。这也是为什么它必须存在。
但它的致命短板是:它只管"深",完全不管"宽"。看这两个查询:
A:a { b { c { d { e { f { g { h } } } } } } }——8 层,每层一个字段,总共 8 个 resolver 调用。构不成威胁,但会被拦。
B:u { f1 f2 f3 … f200 }——2 层,200 个字段,每个字段背后都是一次跨服务调用。构不成任何深度问题,会让后端跑 200 次请求。
深度上限会把 A 干掉,放行 B。而 B 才是生产环境里真正的常见事故。
还有两个绕法。别名重复:同一个字段用不同别名请求多次(x: items{…} y: items{…}),执行器认为这是两次独立解析,resolver 调用次数翻倍,深度一点没变。列表不设限:orders { items { … } } 里 items 一次返回 5000 条,深度只有 3 层,实际工作量却是 5000 倍量级。这两条深度都拦不住。
结论很清楚:深度上限必要,但它只能当兜底,不是主力防线。主力是下面这道。
复杂度评分的思路是把"成本"这件事显式化:给 schema 里每个字段配一个权重,查询进来时先静态遍历整棵树,把所有字段的权重按基点加总,超过预算就在校验阶段拒绝。整个过程在执行之前完成,被拒绝的请求连一个 resolver 都不会跑到。
核心公式是这样:一个字段的成本 = 它自身的权重 + 它的子字段成本之和 × 乘数(multiplier)。乘数只对列表字段生效,取的就是这次请求里 first/last 的实际取值——没有传的话取默认值。这一点非常关键,因为它意味着复杂度算得准不准,取决于分页参数有没有被 clamp 住,这正是第三道闸必须存在的原因之一。
权重怎么定,给出一个可以落地的起点:
标量字段:0 或 1。纯属性读取不碰数据源,给 0 或者象征性的 1,取决于你想不想让"字段数量"也计入成本。建议标量给 0,把预算留给真正贵的字段,这样评分结果的含义更干净。
需要一次本地数据访问的字段:5 到 10。比如一次主键查询、一次缓存命中之后的组装。
列表字段:自身权重 1,乘数取分页参数。列表字段自身不贵,贵的是它把下面的东西复制了多少份。
需要跨服务、跨库、或复杂聚合的字段:20 到 50。这类字段一次调用的成本顶得上几十次主键查询,权重必须反映这个差距,否则评分会失真。
涉及外部 API、全文检索、或不可缓存计算的字段:给到 50 以上,并且额外考虑单独限流。这些字段通常有外部配额或者有不可控延迟,不适合混在一棵树里统一算。
这些数字是起点不是标准答案,每个业务的真实成本结构都不一样。定完先别急着开拒绝。
上线路径应该是"先观测,后拦截"。第一阶段只算分、只打日志、不拒绝,跑一到两周收集线上真实分布,看 P50、P99、最大值各是多少。第二阶段把预算设在 P99 的 1.5 到 2 倍,拒绝超限请求。第三阶段按客户端身份分级:内部服务、合作方、匿名用户的预算不同,移动端首屏那种"大而全"的查询可以单独核准一个配额。这样基本不会误杀。
复杂度评分比深度靠谱,是因为它把三个维度都收进了一个数字:深度通过树的嵌套体现,宽度通过同层字段数累加体现,扇出通过列表乘数体现。上面那个 200 字段的宽查询,每个字段哪怕只给 5 分也是 1000 分,预算里立刻暴露。
实现上有三个值得注意的点。一是要处理变量:客户端常常把 first 通过 variables 传进来,静态分析必须结合变量取值,否则算出来的都是默认值,形同虚设。二是分析结果要缓存:遍历整棵树本身要 CPU,按 query 的哈希缓存骨干 AST 和算出的分数,重复请求不必重算——这一步在网关层是明显的 CPU 优化。三是错误信息要明确:返回"查询复杂度 3200,超过该凭证的上限 2000",配合报错时给出建议(减少分页条数、拆分请求),而不是含糊的"请求过大"。前端同学拿到才知道改哪里。
GraphQL 规范本身对分页条数没有任何约束。orders(first: 100000) 是一句完全合法的查询,而很多服务端实现会忠实执行——分配一个对象数组、往里塞 10 万条、再逐个往下解析。
这道闸之所以最容易被漏,有两个原因。一是它要逐个列表字段去配,不是配一次就全局生效;新增一个返回列表的字段时忘了加约束,漏洞当场产生。二是很多人默认"复杂度评分已经管了",但前面说过,复杂度算得准的前提是参数可信——如果 first 能取到任意值,复杂度就是个可以被拉爆的数字,而不是一道限制。
所以落地的正确顺序是:先把分页条数 clamp 住,再让复杂度基于 clamp 之后的值计算。
具体要管住三个量:
单字段分页上限。每个返回列表的字段都要有自己的上限。列表性质的字段(订单、商品、评论)常见起点是 50 到 100,聚合或字典类的小列表可以给 200 到 500。超过就直接拒绝并返回明确错误,而不是悄悄截断——悄悄截断会让前端以为数据到底了。
嵌套列表的总节点上限。这是单字段上限管不到、但危害最大的地方。外层 100 条、中层 100 条、内层 100 条,每一个单看都合法,三层乘起来是 100 万个节点。需要在执行过程中(或至少在静态分析里)累计总节点数,超了就中断。
翻页深度上限。基于 offset 的分页在 offset 很大时会让数据库扫描并丢弃大量行,即使最终只返回 50 条。游标分页能规避大部分问题,但如果业务必须用 offset,就得给 offset 本身设上限,或者强制走游标。
实现方式有几种。最直接的是 schema 层的参数校验:在 first/last 的定义处加校验规则,或者在解析 resolver args 的统一入口里 clamp。稍微优雅一点的是自定义指令(比如 @listSize(max: 100)),把它挂在列表字段上,由统一的中间件读取——好处是新增字段时不容易忘,因为代码评审会看到这个指令在不在。还有一种是从数据源侧兜底:数据库查询本身带 limit,即便上层忘了限制也不会真的返回 10 万行,但这种兜底的问题是错误反馈太晚,资源已经消耗了。
建议至少做两层:schema 层 clamp + 数据源 limit。前者负责快失败和明确报错,后者负责兜命。
| 查询形态 | 嵌套层数 | resolver 调用次数 | 数据库查询次数 | 未设上限时的结果 | 哪道闸能拦住 |
|---|---|---|---|---|---|
浅层单列表orders(first:20){ id amount status } |
2 层 | 约 61 次(根 1 次 + 20 × 3 个字段) | 1 次(标量由默认 resolver 从对象读取) | 基本无害,延迟主要来自单次查询本身 | 不必拦;但分页上限要兜住 first 被改成大值的情形 |
两层嵌套orders(first:20){ buyer{ name phone } } |
3 层 | 约 61 次(根 1 + 20 个 buyer + 40 个标量) | 约 21 次(根 1 + 每个订单一次 buyer) | 典型 N+1;first 放大 10 倍,查询数同步涨到 200 余次 | 分页上限(把 first 压住)为主,复杂度评分按 buyer 字段权重 5–10 累加兜底 |
三层嵌套orders(first:20){ items(first:3){ detail{…} stock{…} } } |
5 层 | 约 141 次(根 1 + 20 + 60 + 60) | 约 141 次(同上,每个字段各一次数据源访问) | 单次请求上百次数据源访问,占满连接池,同机其他接口一起变慢 | 复杂度评分(乘数 20 × 3 暴露真实成本)+ 分页上限;深度上限在此深度通常还没触发 |
宽而浅u { f1 … f200 } 一次要 200 个字段 |
2 层 | 约 201 次 | 取决于字段实现,跨服务字段可达 200 次 | CPU 与 JSON 序列化吃紧、响应体膨胀,延迟随字段数线性上升 | 深度上限完全失效;只能靠复杂度评分累加权重,配合字段级白名单收敛 |
深层递归(恶意但合法)friends{ friends{ friends{ … } } } |
10 层以上 | 按每层扇出 5 计,第 10 层已到数万次量级 | 与 resolver 次数同量级,数万次 | 单个请求执行数十秒直至超时,持续吃满一个核,内存随结果树线性膨胀 | 深度上限一把拦住,成本最低;复杂度评分在数万分以上同样会拒 |
以上为放大关系的算例示意,非实测数据。清单口径:无批量加载层,列表字段每个元素触发一次子字段解析,每个自定义 resolver 计一次数据源访问,friends 自引用每层扇出按 5 计。
三道闸解决"单个请求不能太大",还有几件事它们解决不了:同一个请求被反复发、一次发很多个中等请求、以及请求被用来探测服务内部结构。这几件事要单独堵。
持久化查询(白名单)。这是最彻底的一招:构建前端时把所有 query 提取出来存到一个映射表里(key 是 query 内容的哈希),线上只允许执行表里存在的哈希,客户端只发哈希不发明文。安全性上它直接消灭了"任意构造查询"这个前提——连三道闸理论上都可以不设了(实际还是建议保留,防御配置失误导致的漏网)。性能上它顺带减小了请求体,也让服务端不必反复解析和校验同样的 AST。代价是流程变重:新增或修改一个 query 要走一次发布,第三方对接方不喜欢这个约束。折中做法是内部客户端强制白名单,第三方开放窗口但受复杂度预算约束。
超时。需要三层:请求级 deadline——整个 GraphQL 请求的总执行时间上限,比如 5 秒或 10 秒,到了就中断并返回超时。resolver 级超时——单个数据源调用的等待上限,防止一个慢子查询拖死整棵树。数据源语句级超时——数据库侧的 statement timeout,这是兜底的一层,保证即使应用层的取消信号没传递到位,数据库自己也会断开。
这里有个常见坑:应用层超时了,但已经发出去的 SQL 还在数据库里跑。中断信号必须真的传到驱动(比如 JDBC 的 cancel、Node 侧的 AbortSignal 一直传到连接池),否则超时只是让客户端少等了几秒,负载一点没减。还要注意运行时差异:单线程事件循环的语言里,一个 CPU 密集的 resolver(比如同步的大 JSON 解析、列举的大循环)是没法被"超时"打断的,它占着线程别人什么都做不了,这类操作只能靠限制输入规模来规避。
并发限制。控制同时在执行的请求数。设一个全局 in-flight 上限,超出的排队或直接拒绝,让系统保持在可控负载下运行而不是越积越多。同时对单个连接设并发查询数上限(多路复用场景),避免一个连接塞进几十个子查询。
限流,而且必须按客户端身份。按 IP 限流在 GraphQL 网关上基本没用:移动端用户在 NAT 后面共用一个出口 IP,一个正常热点出口就能封掉一片;反过来攻击者换 IP 成本极低。正确的粒度是 API Key、App ID、或者 JWT 里的用户/租户标识。更好的做法是复杂度感知的限流——不按"每分钟多少个请求"扣配额,而是按"每分钟多少复杂度分"扣。这样一个体积庞大的查询和一个简单查询消耗的配额自然不同,不会出现"把所有配额都用来发一个大请求"的情况。
关掉生产环境的 introspection 与调试堆栈。introspection 让任何人都能拿到完整的 schema,等于把攻击面图谱直接交出去;关掉它成本极低,收益明确。调试用的 GraphiQL / Playground 也不要在生产暴露。错误信息里的堆栈同样要屏蔽,否则表名、字段名、ORM 版本都泄漏了。开发环境保留完整信息,生产只保留结构化错误码,这条要在部署流水线里强制,不能靠人记。
顺带提一句请求入口:限制请求体大小(比如 100 KB 这个量级),避免超大 query 直接怼进来;只接受 application/json 的 POST,并在有 Cookie 鉴权时做 CSRF 防护——JSON POST 本身有限制,但简单查询走 GET 的路径要单独处理。
三道闸只是让请求"不至于太大",它不会让本来就必要的那 20 次 buyer 查询变少。真正消灭 N+1 的是批量加载层,思路来自 DataLoader,而且这个思路跟语言无关,各生态都有对应实现。
机制不复杂:在同一个执行轮次里,把所有需要加载的 key 收集起来(通常是一个事件循环 tick,或者一批并行 resolver 全部入队之后),用整个 key 数组发起一次批量查询,拿到结果后按 key 的顺序回填到各自的 Promise 里。同一轮里重复出现的 key 会被去重,100 次 buyer(id) 变成 1 次 SELECT … WHERE id IN (…)。上面那个 141 次的算例,引入批量层之后大致会收敛到 4 次:订单列表、商品清单、商品详情、库存,各一次。
用的时候有四条硬约束,踩错任何一条都会出诡异问题:
批量函数必须返回与入参 keys 等长、同序的结果数组,缺一个元素就错位,整个批次的解析结果全错。
必须自己处理部分失败。批量请求里有的 key 查不到是正常的,要返回 null 或者 Error 对象交由各自 resolver 处理,整个 Promise 不能 reject,否则同一个批次里全部的请求一起失败——这叫请求头部阻塞。
每个请求一个 loader 实例。DataLoader 自带一层内存缓存,如果跨请求共享实例,用户 A 的数据可能被返回给用户 B,同时数据更新永远看不到。正确生命周期是 per-request,请求结束跟着销毁。
批次要分片。一次 IN 里放 5000 个 id,SQL 语句会大到让数据库解析都很吃力,还可能撞上数据包大小上限。给 batch size 设上限(常见取值在 100 到 1000 这个量级),超了自动拆成多个批次。
代价有两笔账,必须提前算清。
第一笔是延迟。攒批本身要等——loader 要把同一轮的 key 收集齐才发请求,通常是一个事件循环 tick,量级在毫秒以内,但它是真实存在的额外等待,而且它把原本并行的 N 次查询变成了一次串行等待。批次越大,单个批次的执行时间越长。批大小要权衡:太小合并收益不足,太大单次查询变慢、锁竞争加剧。前面说的 100 到 1000 这个量级就是从这里来的,具体值要按自己的数据量压。
第二笔是内存。这是最容易被忽略的:每个请求都要维护自己的 loader 实例、未完成 Promise、以及批内的 key 集合。一个请求自身可能同时挂几十个 loader,如果列表扇出很大,pending 的 key 数量是乘出来的。再乘以并发请求数,就是常驻堆内存。用 GC 语言写的网关在这里尤其敏感——批次对象很快变成垃圾,堆压力大时 GC 停顿会直接影响所有在跑的请求。一句话:批量层是用内存换往返次数,这笔内存必须在容量规划里显式算进去。
还要纠正一个常见误解:DataLoader 的缓存不是 Redis 的替代品。它是请求内去重,作用域只有一个请求,请求结束就消失。想跨请求复用数据,那是下一节的事。
REST 时代缓存有一整套现成设施:GET 请求、URL 就是天然缓存键、CDN 和反向代理直接按 URL 缓存。GraphQL 这套几乎全废了——客户端统一往 /graphql 发 POST,查询内容在请求体里。URL 永远是同一个,请求体 CDN 默认不解析也不缓存。这是 GraphQL 引入的一个结构性代价,很多团队上线后才发现"之前 CDN 挡掉的那部分流量现在全打回来了"。
要让 CDN 重新起作用,唯一可行的路径是持久化查询 + GET:客户端只发 query 哈希,服务端支持 GET /graphql?hash=xxx&variables=…,这时 URL 重新变成有效的缓存键,CDN 就能接住了。这条路和上文的白名单是同一套基建,做一次拿两个收益。需要注意鉴权——走这条路径的请求必须是公开数据,带用户身份的请求要么不缓存,要么用 Vary 配合私有缓存策略,绝不要把用户级响应缓存进公共节点。
但绝大部分 GraphQL 服务的缓存还是得自己做,分三档:
数据源查询结果缓存。最贴近数据库的一层,缓存的是"这批 id 对应的行"。它跟 GraphQL 无关,但收益最直接:批量层发出来的 IN 查询在这里大量命中。粒度按实体主键组织,失效按实体变更走。这一层是所有 GraphQL 服务都应该先做的。
字段级缓存。给 schema 里的字段标注缓存策略(过期时间、作用域),由统一的中间件在 resolver 外层做命中判断。它的好处是粒度细、能部分命中——一个查询里没变的那几个字段直接走缓存,变了的那几个重新算。这在 GraphQL 里特别契合,因为 query 形状千变万化,整响应缓存的命中率通常很低。@cacheControl 这类指令做的就是这件事:给每个字段标 maxAge 和 scope,整棵树的响应取所有字段里最保守的那个值。
整查询响应缓存。用 query 哈希 + variables + 用户维度作为键,缓存最终的响应体。命中就整棵树都不用跑,收益最高,但条件也最苛刻:数据是公开的、更新频率低、且 query 形状稳定。它的失效也是最难做的——一个实体变更可能影响到无数种 query 形状,精确失效几乎不可能,实践中多是靠短 TTL 加版本号。
缓存 key 有一条铁律:必须包含用户或租户维度,并且权限校验必须在读缓存之前完成。GraphQL 里最容易出的越权事故就是把带 Authorization 的响应按 query 哈希缓存了,下一个用户取到上一个用户的数据。宁可命中率低一点,也别在这上面省事。
还有一件要单独说的:压缩。GraphQL 的响应体通常比同功能的 REST 更大——客户端倾向于一次把能想到的字段都取回来,JSON 嵌套又深。JSON 的压缩比很高(gzip 常见能压到原来的十分之一上下,br 更好一些,具体看数据重复度),这笔带宽省得非常划算。要在网关层就开 gzip/br,按响应体大小设阈值(太小的响应压了反而浪费 CPU),并且只对 application/json 生效。
网关层的资源画像跟通用 Web 服务器不一样,优先级顺序是:CPU 核数排第一,内存排第二,出网带宽排第三,磁盘排在末位。这个顺序是有原因的,逐条讲。
CPU 排第一,核数比主频重要。GraphQL 网关的 CPU 消耗集中在几件高度并行的事上:解析查询文本成 AST、执行静态校验、跑一遍深度与复杂度分析、执行器遍历整棵结果树、以及把结果序列化成 JSON。这里面除了序列化基本都是可以并行展开的——多核就能摊开,单核主频再高也只能让一个请求快一点。更重要的是要保证负载是分布在多核上的:单线程运行时的网关要用多进程模式(cluster / 容器多副本)跑满核数,而不是让一个进程占一个核、其余核闲置;JVM 系的网关则要注意线程池大小跟核数匹配,并把 GC 停顿算进去。
序列化这一项特别值得单独说。一个大响应在拼装过程中占用的内存远大于最终字节数:对象树(每个节点都是带字段名的结构体或哈希)、序列化缓冲区、以及压缩前的字节串,同时在内存里存在。一个 10 MB 的最终响应,中途的峰值内存量级可能是它的两三倍。这也是为什么内存排第二。
内存排第二,而且容易估低。除了上面说的响应拼装,还有三处:一是每个请求的 loader 实例和批次内的 key 集合(上一节讲过),二是每个请求的执行上下文(已解析 AST、变量、错误收集器、各中间件挂的状态),三是应用层缓存(如果有)。这三块都跟并发请求数线性相关。估法很简单:压测出单个请求的平均内存足迹(批量请求发一批 Query 之后,用进程 RSS 除以并发数),乘以目标并发数,再乘 1.5 到 2 的余量。注意这里的目标并发数要用峰值而不是均值,GraphQL 网关的请求到达模式经常是尖峰式的(首屏加载时所有组件一起发请求)。
出网带宽排第三,但要跟压缩一起算。响应体比 REST 大这个特点决定了出网流量偏高。估带宽时按"压缩后大小 × QPS"算,别用压缩前的数字,也不要反过来直接用网上的压缩比——自己抓一批真实响应压一下量。还要注意 CDN 或边缘缓存能挡掉多少:如果持久化查询 + GET 那条路走通了,出网压力会小很多;没走通就全部得从源站出,这是选型时容易算漏的一笔。内网侧(网关到数据源)通常是大包少:批量加载层把 N 个小查询合并成了 1 个大查询,包数下降、单包变大,对网卡来说反而更友好。
磁盘排在末位,但别完全忽略。网关层本身不写业务数据,所以常规磁盘 IO 很低。真正吃盘的是三类东西:访问日志、链路追踪数据、慢查询记录。全量 tracing 在高 QPS 下写盘量非常可观,接 tracer 时务必开采样率并按级别降级,完整 trace 只保留异常请求。日志走异步写、按大小滚动、并且不能在 JSON 序列化的关键路径上同步落盘——同步日志是网关层最典型的自伤方式。给 NVMe SSD 是为了扛住这种突发写,不是为了网关本身。
部署形态该怎么选。这里有个 GraphQL 特有的约束要强调:网关到数据源之间的一次往返延迟,会被 resolver 层数放大。如果网关到数据库之间有一次 5 毫秒的往返,一个五层嵌套的查询序列化执行下来,累计的网络等待可能是几十毫秒;如果走的是跨可用区甚至跨城的链路,往返延迟上到个位数毫秒,这种放大足以让整个接口不可用。所以网关层和它的数据源必须在同一个机房内网、低延迟互联,这条约束比带宽重要得多。
网关和数据源要不要同机?小规模业务同机能省一台机器和一段内网。但只要并发上来,强烈建议分开,理由有三:一是资源画像冲突,网关吃 CPU 核、数据库吃内存和大页缓存,混部时很难调;二是故障耦合,网关一个序列化死循环能把同机的数据库拖得一起慢;三是规模不匹配,网关无状态可以随时横向扩,数据库不行,绑在一起之后扩容粒度被迫统一。更常见的形态是两层:无状态网关层水平扩,数据源层单独规划。如果后端本来就是多个微服务,再加一层网关做数据聚合(GraphQL 联邦 / Schema Stitching),那网关层单独的 CPU 资源要按"聚合扇出倍数"额外加。
把上面的结论收成一条可执行的选型链:目标 QPS × 单请求 CPU 毫秒 → 需要的核数;目标并发 × 单请求内存足迹 → 需要的内存容量;压缩后响应大小 × QPS → 出网带宽;日志采样率 × QPS → 磁盘写入。这条链跟挑数据库服务器完全不同,后者是先算内存和磁盘。
具体档位给个起点(按常见业务量级推的配置方向,不是万能推荐值):日请求量在百万级以下、峰值 QPS 几百的网关,16 核左右、32 到 64 GB 内存是常见的落点;峰值 QPS 上千、或者查询形状普遍偏复杂的,往 32 核以上、64 GB 以上走会更从容。这里有个容易被忽略的点:宁可多核,不要太少核高主频,因为多出来的核同时也给了你在同一台机器上跑多个网关副本、做优雅重启和灰度发布的余地。
一万网络深耕 IDC 19 年(成立于 2007 年),在挑网关层机型时可以直接按这条链去比——把核数档位和内存容量两个条件先卡死,再看网卡规格、是否含 BGP 多线与免费 DDoS 防护,磁盘放到末尾再挑。具体机型、内存档位组合与当下费用以官网实时信息为准,未明示的部分需实时询价。配置单交给服务商时,把"峰值 QPS 和单请求成本"这两个数字给出去,比报一个 CPU 型号要有效得多。
这份清单按"能不能止血"排序,前五项是硬门槛,任一项没做到就先别开对外端口。
一、三道闸是不是都设了。深度上限、复杂度评分、分页条数上限,缺一个都算没做完。验证方式很直接:拿一个"宽而浅"的查询试(200 个字段、3 层深),再拿一个"深而窄"的试,两个都应该被拒。
二、批量加载层有没有覆盖列表字段。把所有返回列表的字段列出来,逐个确认它的下游 resolver 走了 loader。漏掉一个就是一个活着的 N+1。验收指标是在 staging 上跑一遍核心查询,记录数据源访问总次数——这个数字应该跟"层级数"同量级,而不是跟"返回的记录数"同量级。
三、超时配到不到位。请求级 deadline、resolver 级超时、数据源语句级超时,三层都要有。并且要实测"超时之后数据库的连接是不是真的被释放了"——很多团队配完超时发现慢查询还在库里跑。
四、限流是不是按客户端身份。如果是按 IP 的,直接重做。最好做成复杂度感知扣配额。
五、introspection 和调试堆栈有没有关。对着生产域名请求一次 __schema,应该拿不到完整 schema;故意触发一次内部错误,响应里不应该出现堆栈。
六、有没有一个"恶意但合法"的测试用例。这条最容易被跳过,也最有价值:写一条语法完全合规、但成本极高的查询(比如八层自引用 + 每 first: 100),放进 CI 里作为回归用例。每次改 schema 都跑一遍,确认它被拒绝了、且在拒绝之前没有执行任何 resolver。配好的防线会被开发迭代悄悄磨掉——新增字段忘了加约束、有人为了联调临时把预算调大了没调回来,这个用例是唯一能持续发现它们的机制。
七、观测有没有到位。至少要有四类指标:每请求的 resolver 调用次数(P50/P99/最大)、每请求的数据源访问次数、每请求的复杂度得分、以及被拒绝请求的数量与原因。没有这些,前面所有的配置都只是"配了",不知道是不是真的在起作用。
八、错误码是否可用。被拒绝时返回的信息要告诉客户端改哪里——减少分页条数、减少字段、拆分请求。含糊的错误会让对接方在工单里反复拉扯。
不要抄网上流传的数字,正确的做法是量自己的。把客户端实际会发出的所有查询收集起来(从网关访问日志里捞,比逐个问前端靠谱),找出最深的那条,往上加 2 到 3 层作为余量。多数业务落在这个方法的产物是 7 到 12 之间,但这只是常见区间不是推荐值。配完之后要确认两件事:计数时排除了 __typename 这类元字段;以及片段(fragment)展开是不是被正确计入了(内联 fragment 要计,片段定义本身不计)。也别指望它拦主力威胁,它真正的作用是低成本地废掉递归类查询。
权重应该由最了解数据源成本的人定,也就是写 resolver 的后端,而不是网关运维。有个省事的做法:上线初期全部字段给默认值,跑一段时间拿监控里的实际耗时反推——哪个字段的 P99 高,就把它的权重往上调,让评分结果跟真实延迟大致成正比。误杀是可以完全避免的,办法就是分阶段上线:第一阶段只算分不拒绝,收集一到两周的分布;第二阶段把预算设在 P99 的 1.5 到 2 倍,此时误杀概率极低;第三阶段按客户端身份分级配额。再留一个 bypass 通道给内部工具链和数据分析需求,走单独的高配额凭证。
分两类字段给不同的值。业务实体列表(订单、商品、评论、用户):单字段上限 50 到 100 起步,客户端需要更多就翻页。字典、枚举、小型配置类列表:可以给到 200 到 500,因为它们通常是全量返回的小集合。真正的重点不在单字段,而在嵌套乘积:外层 100、中层 100 这种组合,单看每个字段都合规,乘起来是 1 万个节点。所以除了单字段上限,还要有一个"单次请求总节点数"的累计上限,这个值按业务首屏实际需要的最大数据量来定,通常给到几千的量级就够用了。别忘了给 offset 本身设上限,大 offset 的数据库扫描成本跟返回行数无关。
会,但通常不到 milliseconds 的量级。它引入的额外等待是把同一执行轮次的 key 收集齐的时间,常见实现里是一个事件循环 tick。这笔账是划算的:用不到一毫秒的等待换掉几十次数据库往返。真正可能让延迟变差的是两个情况:一是批次设得太大,一次 IN 五千个 id 的单次查询本身就慢,还可能撞上 SQL 长度或数据包大小限制;二是轮次切分不合理,如果一个 resolver 内部用了 await 把加载拆到了不同的轮次,合并机会就丢了,既要延迟又没省钱。批次大小按 100 到 1000 这个量级试,用真实数据量压出拐点。
会,但有标准解法。前端的代码生成工具(比如根据 schema 生成 TypeScript 类型)确实依赖 introspection,解决方式是在 CI 里跑,不对生产跑:从代码仓库里的 schema 文件(SDL)直接生成类型,而不是请求线上接口拿。SDL 文件作为构建产物管理起来,还能顺便做 schema 变更的 diff 审查——谁加了一个可能拖垮服务的列表字段,在 PR 里就能看出来。如果团队已经离不开线上拉取,退一步的做法是只对持有内部凭证的请求开放 introspection,匿名和第三方请求一律关闭。
要,除非规模小到不值得多一台机器。核心理由是三条:资源画像冲突——网关吃 CPU 核数和 CPU 缓存,数据源吃内存和磁盘 IO,混部时两边都调不好;故障耦合——网关一个大响应序列化把 CPU 打满,同机的数据库跟着一起慢,监控系统还会把你带偏,看起来像数据库的问题;扩容粒度——网关是无状态的,加机器就能扩,数据库不是,绑在一起之后扩容成本被拉到数据库那一档。唯一要注意的是分开之后的网络延迟必须在 milliseconds 以下、最好在同一机房内网,因为每一层嵌套都会把这个往返延迟累加一遍。
该开,而且应该在网关层统一开。JSON 的重复度极高(字段名在每个数组元素里重复一遍),压缩比通常很可观,gzip 常见能压到原始大小的十分之一上下,br 通常更好。注意三件事:一是设体积阈值,太小的响应(比如 1 KB 以下)压了收益覆盖不了 CPU 开销;二是 CPU 账要算进去,压缩等级别调太高,高等级别带来的收益递减而 CPU 开销陡增,中等等级通常是性价比拐点;三是只对文本类型生效,别把已经压缩过的内容再压一遍。还有一个 GraphQL 特有的收益——开压缩之后,上一节说的出网带宽可以按压缩后的数字重新算一遍,往往能省下整档的带宽采购。
回到开头那 140 次数据库访问。它之所以能发生,不是因为服务端慢,而是因为服务端从头到尾没有问过一句"这个请求我扛不扛得住"。GraphQL 把查询形状的决定权交给了客户端,服务端如果不在执行之前把这笔账算清楚,就等于默认接受任意量级的放大——语法合法,语义荒谬。
顺序不能颠倒。先把三道闸设上:深度上限废掉递归,复杂度评分管住宽深扇出,分页条数上限把乘数 clamp 住。然后用 DataLoader 把必要的 N 次合成一次,再用应用层缓存把重复的省掉,这才轮到调 resolver、加索引、换机型。没有前面的上限就直接优化性能,等于在一个敞口的漏斗底下想办法接水。
三道闸也不是配一次就完事。每次改 schema 都可能引入新的列表字段、新的自引用关系、新的跨服务调用,防线会悄悄失效。把那个"恶意但合法"的查询放进 CI,让它在每次上线都跑一遍,是维持这道防线唯一可靠的办法。
本文出现的所有数字——141 次数据源访问、20 + 60 + 60 的展开、200 字段查询的 201 次 resolver 调用、递归查询在第 10 层到达数万量级、以及复杂度权重 5 到 50 的取值——全部是按 GraphQL 执行模型和架构关系推导出的算例,用来说明放大量是怎么构成的,不是任何真实环境下的实测数据,也不是压测结果。
真实数字取决于四件事:你的 resolver 实现(有没有懒加载、有没有批量层)、单次数据源访问的耗时、层间的实际扇出倍数、以及客户端真实会发出的查询形状。同一个 schema 在不同数据量下,差距可以到数倍。
真正能拿去指导配置和调整阈值的数字,必须自己测出来:给 resolver 加计数埋点,测出"调用次数"和"实际数据源访问次数"两个值;记录线上真实查询的复杂度得分分布,用 P99 反推预算;压出峰值并发下的单请求内存足迹。本文能提供的是这套推导方法和"先设上限,再谈性能"这个判断顺序,数字本身请以你自己的测量结果为准。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品