GraphQL N+1 Query Problem
GraphQL 的逐字段 Resolver 容易把一个关系查询放大成 1+N 次数据库访问;需要用批量查询、JOIN、DataLoader 或预取策略消除重复 I/O。
[!info] related notes
- 所属 MOC: graphql-moc
- 前置概念: graphql-query-and-resolver
- 上位概念: N+1 Query Problem
- 相关策略: ORM 的 Eager Loading 与 Lazy Loading
GraphQL N+1 Query Problem
一句话定义
N+1 是“先查 1 次父集合,再为 N 个父对象各查 1 次关联数据”的重复 I/O 问题。
GraphQL 很容易暴露它,因为 Resolver 天然按字段、按对象执行。
最小场景
客户端:
query {
users {
id
name
diagnoses { id summary }
}
}
一种直接但糟糕的实现:
SELECT * FROM users; -- 1 次
SELECT * FROM diagnoses WHERE user_id = 1;
SELECT * FROM diagnoses WHERE user_id = 2;
SELECT * FROM diagnoses WHERE user_id = 3;
...
100 个用户就可能变成:
1 + 100 = 101 次查询
为什么 GraphQL 容易出现
执行模型可能是:
Query.users resolver
↓ 返回 100 users
User.diagnoses resolver
↓ 对每个 user 各调用一次
GraphQL 只知道“这个字段要被解析 100 次”,不会自动知道应该把 100 个 user_id 合并成一条 SQL。
常见解决方式
1. Batch Query
把:
getDiagnoses(user1)
getDiagnoses(user2)
...
收集成:
SELECT *
FROM diagnoses
WHERE user_id IN (...);
然后在内存里按 user_id 分组回填。
2. DataLoader
DataLoader 不是“一次客户端请求”或“一次数据库查询”的名字。
更准确地说,它是某一种数据加载关系的 request-scoped batcher + memoizer。
例如:
assessmentsByUserId
userById
tasksByGoalId
都可以各自有一个 DataLoader。
Resolver:
User: {
assessments: (user, _args, ctx) =>
ctx.loaders.assessmentsByUserId.load(user.id)
}
同一次 GraphQL Request 中可能产生:
load(1)
load(2)
load(3)
load(2)
DataLoader 会在合适的批次窗口内收集 key,并对同一 request 的重复 key 复用结果:
[1, 2, 3, 2]
↓ batch + request memoization
[1, 2, 3]
↓
SELECT *
FROM assessments
WHERE user_id IN (1, 2, 3);
然后再按原始 key 把结果分组映射回各个 Resolver。
这同时解决两件事:
Batching
load(1), load(2), load(3)
→ 一次批量 I/O
Memoization
load(2), load(2)
→ 本 request 内不重复加载同一 key
3. JOIN / Preload
当查询结构固定、数据库关系明确时,可以直接在 Repository 层 JOIN 或 preload,而不必所有问题都用 DataLoader。
GraphQL 不要求每个关系字段都必须通过 DataLoader。
“每个 GraphQL Request 一个 DataLoader”是什么意思
一次请求:
query {
users {
id
assessments { score }
}
}
虽然可能执行 100 个 User.assessments Resolver,但它们属于同一次 GraphQL operation/request。
典型生命周期:
HTTP / GraphQL Request 开始
↓
创建 Context
↓
创建本 request 的 DataLoader 实例
↓
本请求中所有相关 Resolver 共享它
↓
批量 / 去重
↓
Request 结束
↓
Loader 与其 memoized cache 一起丢弃
所以不是:
每个 Resolver 一个 Loader ❌
整个 Server 永久一个 Loader ❌
而是:
每个 Request 创建 Loader 实例
同一 Request 的 Resolver 共享 ✅
为什么 DataLoader 通常要 request-scoped
如果跨请求共享 loader cache:
User A request
User B request
就可能出现:
- 上一个请求留下的陈旧数据;
- 不同认证身份之间的缓存污染;
- 带权限的数据意外跨请求复用;
- 全局 cache 持续增长。
例如:
Request A / Sean
→ load Assessment 789
→ 缓存结果
Request B / Bob
→ 同一个全局 Loader load 789
→ 可能直接复用 A 的结果
如果底层加载逻辑依赖身份或授权,这甚至可能升级成安全问题。
因此常见设计是:
每个 GraphQL request
→ 创建自己的 loaders
→ 放进 context
→ request 结束后丢弃
DataLoader 与全局应用缓存不同
DataLoader 的 request-scoped memoization 主要解决:
本次执行里的重复加载
Redis / CDN / application cache 等解决的则可能是:
跨 request / 跨实例的长期复用
不能因为都叫 cache 就把它们混成同一个生命周期。
N+1 不只属于 GraphQL
ORM lazy loading、模板渲染循环、REST 聚合服务都可能出现 N+1。
GraphQL 只是因为字段关系查询非常自然,所以这个问题尤其常见、尤其值得显式治理。
如何验证是否真的有问题
不要只看代码猜:
固定一个 GraphQL Query
→ 打开 SQL query log / tracing
→ N 从 1、10、100 增长
→ 看 SQL 次数是否也线性增长
理想状态应更接近固定数量或少量批次,而不是 1 + N。
核心判断
GraphQL 解决的是客户端“怎么表达我要哪些关系数据”,DataLoader 解决的是一次 GraphQL Request 内“如何把重复字段加载批量化并去重”;两者都不会自动替数据库选择最佳 JOIN 或查询计划。