← [ BACK_TO_NOTES ]

GraphQL N+1 Query Problem

GraphQL 的逐字段 Resolver 容易把一个关系查询放大成 1+N 次数据库访问;需要用批量查询、JOIN、DataLoader 或预取策略消除重复 I/O。

#concept #growing #backend #graphql #database

[!info] related notes

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 或查询计划。

创建于 2026/9/11 更新于 2026/9/15
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本