N+1 问题与 DataLoader
N+1 是 GraphQL 嵌套查询的最典型性能陷阱:查 N 条记录时,额外又发了 N 次关联查询。
DataLoader 是官方推荐的解法——请求内批量合并 + 缓存。
N+1 问题是什么
看一个常见查询:
query {
users {
id
name
posts {
title
}
}
}
对应的 Resolver:
const resolvers = {
Query: {
users: () => db.user.findMany(),
},
User: {
posts: (parent) => db.post.findMany({ where: { authorId: parent.id } }),
},
};
执行顺序:
① db.user.findMany() → 返回 100 个 User
② 对每个 User 执行 User.posts:
db.post.findMany({ authorId: 1 })
db.post.findMany({ authorId: 2 })
db.post.findMany({ authorId: 3 })
...(共 100 次)
总计 1 + 100 = 101 次数据库查询(根查询 1 次 + 子查询 100 次),这就是 N+1。
Note
为什么 GraphQL 特别容易踩
REST 里 /users?include=posts 会写一次 JOIN 或 WHERE authorId IN (...),Controller 层自己控制。
GraphQL 的 resolver 是按字段级别执行的,每个 User.posts 字段都独立调用一次——运行时不知道你在”一批里取 100 个用户的 posts”,它只知道”当前这个 User 要拿 posts”。
DataLoader 的解法
DataLoader 做两件事:
- 批量合并:在同一次请求的同一个”事件循环 tick”里,把多次
load(id)调用攒起来,一次性调用batchFn(ids) - 请求内缓存:同一个 id 再次
load时直接命中缓存,不重复触发
import DataLoader from "dataloader";
// 批函数:一次接收一组 authorId,返回同样顺序的 Post[][]
const postsByAuthorLoader = new DataLoader(
async (authorIds: readonly string[]) => {
const posts = await db.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
// 必须按 authorIds 的顺序返回,每个元素是该 authorId 对应的 posts 数组
return authorIds.map((id) => posts.filter((p) => p.authorId === id));
},
);
Resolver 改为使用 loader:
const resolvers = {
User: {
posts: (parent, _, ctx) => ctx.postsByAuthorLoader.load(parent.id),
},
};
执行流程变成:
① db.user.findMany() → 返回 100 个 User
② 每个 User 调用 loader.load(id) → 攒起来不立即执行
③ 事件循环 tick 结束 → 一次性调用 batchFn([1,2,3,...,100])
→ 一次 SQL:WHERE authorId IN (1..100)
④ 结果按 id 分组回填给各个 resolver
总计 1 + 1 = 2 次查询(根查询 1 次 + 批量加载 1 次)。
为什么 “per-request”
DataLoader 实例必须每次请求新建,不能做全局单例:
// 错误:全局单例会跨请求泄漏缓存、跨用户共享数据
const globalLoader = new DataLoader(batchFn);
// 正确:在 context 中为每次请求创建
const server = new ApolloServer({
schema,
context: ({ req }) => ({
user: getUser(req),
postsByAuthorLoader: new DataLoader(batchFn),
}),
});
Note
要点
- 全局 loader 的缓存跨请求污染:用户 A 看到的数据可能因为用户 B 的查询被缓存了而读到过期结果
- 权限敏感场景更危险:不同用户可见的数据集不同,共享缓存会泄漏
- 每次请求重新创建,生命周期绑定到
context,请求结束自动释放
批函数(batchFn)的契约
DataLoader 对 batchFn 有严格要求:
- 输入:
readonly Key[](这次 tick 攒到的所有 key) - 输出:
Promise<(Value | Error)[]>,数组长度必须和输入 keys 一致,且顺序一一对应 - 缺失项:某个 key 查不到时,对应位置返回
null/undefined或一个Error实例(不要直接 throw,否则整批失败)
const userLoader = new DataLoader(async (ids: readonly 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) ?? new Error(`User ${id} not found`));
});
Note
陷阱:顺序不匹配
数据库返回的顺序通常不等于 ids 的顺序。必须显式按 key 重排,否则 DataLoader 会把错误的数据分发给 resolver。
多对多 / 一对多
DataLoader 默认假设 key → 单个 value(如 id → User)。
对于”一个 authorId → 多个 Post” 这样的一对多关系,batchFn 的返回值是 Post[][](每个位置是一个数组)。
const postsByAuthorLoader = new DataLoader<string, Post[]>(
async (authorIds) => {
const posts = await db.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
return authorIds.map((id) => posts.filter((p) => p.authorId === id));
},
);
什么时候不需要 DataLoader
| 场景 | 是否需要 |
|---|---|
嵌套查询(如 users { posts { comments } }) | 必须 |
| 单次根查询不涉及嵌套 | 不需要 |
| 已经在单个 resolver 里一次性 JOIN 完所有字段 | 不需要 |
| 非数据库的纯计算字段 | 不需要 |
判断原则:resolver 里有没有出现”对每个 parent 单独查一次外部数据源”的模式,有就用 loader。
回到 GraphQL 总览