大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
服务端执行
这一课你将能做到
- 完整讲出一个查询从 HTTP 请求到 JSON 响应的四个阶段(解析→校验→执行→序列化)
- 写出 Resolver 函数并用 DataLoader 消灭 N+1 查询
- 给服务加上查询深度/复杂度限制,挡住恶意查询
为什么要学
客户端写出优雅的查询只是上半场。下半场在服务端:一个 20 个课时的列表,如果每个课时单独查一次作者,就是 21 次 SQL——这就是 GraphQL 最著名的 N+1 陷阱。不理解执行引擎的工作方式,你的服务会在流量上来后死得很难看,而你甚至不知道是哪段查询干的。
人话:Resolver 是图的每条「边」的收费站,执行引擎按你声明的子树逐个过站收费;收费站效率决定整条路的体验。
先建立图像
一次查询的服务端旅程:
POST /graphql
│
▼
① Parse 文本 → AST(语法树),语法错误在此报 400
│
▼
② Validate AST 对 Schema 校验(字段存在?类型匹配?变量用到?)→ 400
│
▼
③ Execute 从根字段开始,逐字段调用 Resolver:
│ query ──lesson──▶ track ──lessons──▶ [author ×20]
│ 同层并行,父子串行
▼
④ Serialize 按可空性规则冒泡拼装 → { data, errors? } → 200
跟着做
步骤 1:写出 Resolver(以 graphql-java 风格伪码 + JS 双示)
Resolver 的四要素:父对象、参数、上下文、信息。
// JS (graphql-yoga) 写法
const resolvers = {
Query: {
lesson: (_parent, { slug }, { services }) =>
services.lesson.findBySlug(slug)
},
Lesson: {
// 父对象 lesson 已加载,这里只补 author
author: (lesson, _args, { loaders }) =>
loaders.user.load(lesson.authorId),
track: (lesson, _args, { loaders }) =>
lesson.trackId ? loaders.track.load(lesson.trackId) : null
}
}
// Java (Spring for GraphQL) 等价写法
@QueryMapping
public Lesson lesson(@Argument String slug) {
return lessonService.findBySlug(slug);
}
@SchemaMapping(typeName = "Lesson", field = "author")
public CompletableFuture<User> author(Lesson lesson, DataLoader<String, User> loader) {
return loader.load(lesson.getAuthorId());
}
步骤 2:亲手制造一次 N+1
查询:
{ lessons(first: 20) { title author { name } } }
没有批处理时的 SQL 日志:
SELECT * FROM lesson LIMIT 20; -- 1 次
SELECT * FROM user WHERE id = 'u1'; -- 第 1 课时的作者
SELECT * FROM user WHERE id = 'u7'; -- 第 2 课时的作者
... -- × 20
21 次查询。列表变 100 就是 101 次。图越深越致命。
步骤 3:DataLoader 批处理 + 请求级缓存
核心思想:把同一 tick 内的多次 load 合并成一次 batch 查询。
function createUserLoader(db) {
return new DataLoader(async (ids: string[]) => {
const users = await db.user.findMany({ where: { id: { in: ids } } })
const byId = new Map(users.map(u => [u.id, u]))
return ids.map(id => byId.get(id) ?? null) // 必须按入参顺序返回、长度一致
})
}
// 每个请求新建一批 loader(缓存作用域 = 单次请求,防串数据)
现在 20 个 author 合并成一条 WHERE id IN (...),21 次变 2 次。附带收益:同一请求内 load('u1') 两次只查一次(缓存)。
SELECT * FROM lesson LIMIT 20;
SELECT * FROM user WHERE id IN ('u1','u7',...); -- 合并后 1 次
步骤 4:给执行引擎装上护栏
import depthLimit from 'graphql-depth-limit'
import { createComplexityLimitRule } from 'graphql-validation-complexity'
const server = createServer({
schema,
validationRules: [
depthLimit(10), // 深度上限
createComplexityLimitRule(1000, { // 复杂度上限
scalarCost: 1, objectCost: 2, listFactor: 10
})
]
})
恶意查询 a { b { c { d { ... 50 层 ... } } } } 在校验阶段就被 400 拒绝,resolver 一个都不会执行。
步骤 5:观察错误冒泡
让 Lesson.author 声明为 User!,resolver 抛错:
{
"data": { "lessons": [ { "title": "...", "author": null }, null, ... ] },
// author 非空却解析失败 → 冒泡:author 置 null 不够,整个 lesson 元素置 null
"errors": [{ "message": "用户服务超时", "path": ["lessons", 1, "author"] }]
}
path 数组精确定位出错的字段路径——排障时先看它。把 author 改成可空 User,错误就只影响 author 自己。
讲清楚要点
- 执行 = 按查询子树逐字段调 resolver:同层字段可并行,子字段依赖父结果。resolver 是服务端性能的决定性单元。
- 默认 resolver 存在:字段未实现 resolver 时,默认取父对象的同名属性/方法——简单字段不用写。
- DataLoader 是 N+1 的标准答案:批处理(batch)+ 每请求缓存(cache),两个能力缺一不可;loader 必须每请求新建,跨请求缓存会串用户数据。
- 校验发生在执行前:深度/复杂度/字段合法性全部在校验阶段拦截,恶意查询零成本被拒绝。
- context 是请求级工具箱:当前用户、数据源、loader 实例、追踪 ID 都放 context,resolver 第三个参数取用。
- 错误冒泡由可空性决定:非空字段失败向上冒泡到最近可空祖先;理解这个才能设计「局部失败不炸页」的 Schema。
- async 天然适配:resolver 可返回 Promise/CompletableFuture,执行引擎统一 await 后拼装。
你可能会懵的地方
- resolver 里能直接调别的微服务吗? 能,GraphQL 不关心数据源。常见组合:MySQL 查主体 + Redis 查计数 + RPC 查用户信息,全在一个字段 resolver 里聚合。
- DataLoader 会不会让请求变慢(等攒批)? 攒批窗口是同一事件循环 tick,微秒级,换来的是数量级的查询减少,稳赚。
- 校验规则会不会误伤正常查询? 会,阈值要按真实流量调。先上线「只记录不拒绝」模式采集分布,再收紧。
- 执行阶段报错 HTTP 状态码是什么? 规范下执行错误(resolver 抛错)仍返回 200 + errors;只有解析/校验失败才是 400。细节下一课「规范与传输」展开。
- Mutation 的 resolver 有什么特别? 没有。只是执行时根字段串行;resolver 内部该开事务开事务,和 REST 写接口一样。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 每个字段 resolver 独立查库 | N+1 雪崩 | DataLoader 批处理 |
| 全局单例 DataLoader | 跨请求缓存串数据、内存膨胀 | 每请求 new 一批 loader |
| 不做深度/复杂度限制 | 一条恶意查询打满 CPU | 校验规则护栏 |
| 非空字段随手抛异常 | 冒泡清空大片响应 | 可空设计 + 错误分类 |
| 在 resolver 里写业务大逻辑 | 难测试、难复用 | resolver 薄、Service 厚 |
| 用 try-catch 吞掉异常返回 null | 错误不可观测 | 抛出并记录,让 errors 携带 path |
卡点急救
- 接口偶发 10 秒超时:抓慢日志看是不是深层嵌套 + 无 loader 的 N×M 组合查询;先加复杂度限制止血,再上 DataLoader。
- 用户 A 看到了用户 B 的数据:DataLoader 做成全局单例,缓存跨请求命中了——改成每请求实例。
- 响应里 data 整体为 null 但 errors 有内容:根级非空字段冒泡到头了,检查该字段 resolver 异常并按需放宽可空性。
面试常问
- GraphQL 的 N+1 问题怎么产生、怎么解决? 列表字段的每个元素独立触发子字段 resolver,导致 1 次列表查询 + N 次关联查询。解法是 DataLoader:同一事件循环内的 load 调用合并为一次批查询,并做请求级缓存。Java 生态有 java-dataloader,与 CompletableFuture 配合。
- 一次查询的执行流程? Parse(文本→AST)→ Validate(对 Schema 校验,含深度/复杂度规则)→ Execute(根字段起逐层调 resolver,同层并行)→ Serialize(按可空性冒泡拼装 data/errors)。
- 执行阶段错误和校验错误的区别?
校验错误在执行前拦截,HTTP 400,resolver 不执行;执行错误是 resolver 运行期失败,HTTP 200,
data部分为 null +errors带 path 定位。
自测三问
- DataLoader 为什么要「每请求新建」而不是全局单例?
- 同层字段和父子字段的执行并发性分别是什么?
errors[0].path = ["lessons", 1, "author"]告诉你什么信息?
小结一句话
服务端执行的本质是「按声明的子树调度 resolver」:解析校验挡住坏请求,DataLoader 批处理治好 N+1,深度/复杂度护栏挡住恶意查询,可空性决定错误冒泡的边界。
延伸
- 官方文档:Execution
- DataLoader 仓库
- 下一课:规范与传输——GraphQL over HTTP 的状态码、媒体类型与请求格式细节。