N+1 问题与 DataLoaderThe N+1 problem and DataLoader
客户端一句话,后端 100 次请求 —— 以及一个 30 行的解药。One client query, 100 backend requests — and a 30-line fix.
这一页有什么On this page7
- 解释 N+1 问题在 GraphQL 里为什么天然会发生Explain why the N+1 problem happens naturally in GraphQL
- 说清 DataLoader 靠什么把 N 次合并成 1 次Explain how DataLoader merges N calls into one
- 知道 batch 函数的两条硬约束(长度与顺序)Know the two hard rules for a batch function: length and order
- 解释为什么 loader 必须每请求新建Explain why a loader must be created once per request
Order.shippingInfo 那个 TODO 原文就写着「using DataLoader to prevent N+1 queries」。绕过 loader 直接调数据源能过测试,但答不到考点。The Order.shippingInfo TODO says it in the task text: using DataLoader to prevent N+1 queries. Calling the data source directly and skipping the loader still passes the tests, but it misses what the task is testing.
graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js两个 loader 工厂函数(其中一个有埋雷)Two loader factory functions, one with a planted bug
提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.
graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.jsgraphql-federation-practice/node-subgraph/package.jsondependencies 里那个 dataloader 就是提示The dataloader in dependencies is the hint
graphql-federation-practice/node-subgraph/package.jsonN+1 是怎么产生的How N+1 happens
不是谁写错了。是 GraphQL 的执行模型天然如此。Nobody wrote anything wrong. This is how the GraphQL execution model works.
回忆执行流程:Query.orders 返回 N 个 order, 然后执行器对每一个 order 分别调Order.shippingInfo。
所以如果 shippingInfo 的实现是直接调数据源:
查 2 个订单 → 3 次数据源调用(1 次取列表 + 2 次取物流)。 查 100 个订单 → 101 次。 这就是 N+1 问题:1 次主查询 + N 次子查询。
为什么在 GraphQL 里特别严重?因为客户端决定形状 —— 后端无法预知这次查询会不会展开 shippingInfo。REST 里你可以手写一个 「带物流的订单列表」接口,用一次 JOIN 解决; GraphQL 里每个字段的 resolver 是独立的,各自不知道别人的存在。
Recall the execution flow: Query.orders returns N orders, and then the executor calls Order.shippingInfo once for each order.
So if shippingInfo is implemented by calling the data source directly:
Two orders → 3 data source calls (1 for the list, 2 for shipping). A hundred orders → 101. That is the N+1 problem: one main query plus N sub-queries.
Why is it especially bad in GraphQL? Because the client decides the shape — the backend cannot know in advance whether a given query will expand shippingInfo. In REST you can hand-write an “orders with shipping” endpoint and solve it with one JOIN; in GraphQL every field resolver is independent and none of them knows the others exist.
DataLoader 靠什么合并How DataLoader merges calls
靠 JavaScript 事件循环的一个特性:同一个 tick 里的调用可以攒起来。It uses one property of the JavaScript event loop: calls made in the same tick can be collected together.
你调 loader.load('order-456'), DataLoader 不会立刻去取数据。 它把这个 key 记下来,返回一个 Promise, 然后等当前这一轮微任务结束。
因为执行器是在同一个 tick 里对所有 order 调 shippingInfo 的,所以这一轮结束时, DataLoader 手上已经攒了 N 个 key。这时它调一次你给的 batch 函数,把整个数组传进去。
batch 函数返回一个结果数组,DataLoader 按位置把结果分发给各个 load() 的 Promise。
看真实的 batch 函数 —— 那行 console.log是绝好的观察窗口:
跑起来会看到[DataLoader] Batching 2 shipping info requests——一行,不是两行。这就是合并生效的证据。
另外 DataLoader 自带缓存: 同一个请求里对同一个 key 调多次 load(), 只会真的取一次。这对「同一个 order 在查询里出现两次」的场景很有用。
You call loader.load('order-456') and DataLoader does not fetch anything yet. It writes the key down, returns a Promise, and then waits for the current round of microtasks to finish.
Because the executor calls shippingInfo for every order inside the same tick, by the end of that round DataLoader is holding N keys. Now it calls your batch function once, passing the whole array in.
The batch function returns an array of results, and DataLoader hands them out by position to the Promise of each load().
Look at the real batch function — that console.log line is a great observation window:
Run it and you see [DataLoader] Batching 2 shipping info requests — one line, not two. That is your proof the batching works.
DataLoader also caches: calling load() several times with the same key inside one request only fetches once. Handy when the same order shows up twice in a query.
graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.jsbatch 函数的两条硬约束The two hard rules for a batch function
违反了会出现「A 拿到 B 的数据」这种最难查的 bug。Break them and you get the hardest kind of bug to find: A receives B's data.
- 返回数组的长度必须等于 keys 的长度。短了,多出来的
load()会永远 pending 或报错。 - 返回数组的顺序必须与 keys 一一对应。DataLoader 靠下标分发结果 ——
results[0]给keys[0]。
所以 batch 函数里绝对不能用 filter(会变短),也不能改顺序。keys.map(...) + Promise.all是最安全的写法 —— 它天然保证长度和顺序。
「找不到」怎么办?在对应位置放 null(或一个 Error 对象),不要跳过。这个项目的getShippingInfo 正是这么设计的: order-999 没有物流,返回 null, 数组长度不变。
真实系统里如果 batch 函数调的是WHERE id IN (...) 这种批量查询,数据库返回的顺序不保证和你传进去的一致, 而且缺失的行不会返回。这时必须自己重排:
- The returned array must be exactly as long as keys. Come up short and the extra
load()calls hang forever or throw. - The order of the returned array must match keys, one for one. DataLoader hands out results by index —
results[0]goes tokeys[0].
So a batch function must never use filter (that shortens the array) and must never reorder. keys.map(...) plus Promise.all is the safest shape: it keeps the length and the order correct without any extra work.
What about “not found”? Put a null (or an Error object) at that position and do not skip it. This project’s getShippingInfo is built exactly that way: order-999 has no shipping, it returns null, and the array keeps its length.
In a real system, if the batch function runs a bulk query like WHERE id IN (...), the database does not promise to return rows in the order you asked for, and missing rows do not come back at all. Then you have to reorder them yourself:
为什么 loader 必须每请求新建Why a loader must be created once per request
DataLoader 的缓存没有过期机制。 一旦某个 key 被 load 过,之后同一个 loader 实例上的load(同一个key) 永远返回缓存值。
如果建在模块顶层(一个全局实例),后果是:
- 数据永远不刷新。订单状态从 SHIPPED 变成 DELIVERED,用户永远看到 SHIPPED。
- 跨请求数据泄漏。如果 loader 的实现里带了权限过滤, 用户 A 的缓存会被用户 B 看到。这是安全问题。
所以正确做法就是 index.js 里那样 —— 在 context 函数里 new,每个请求一套全新的 loader。 请求结束,loader 和它的缓存一起被回收。
测试文件里的 beforeEach 也是同一个道理 —— 每个测试用例都重建 dataSources 和 loaders, 避免上一个用例的缓存影响下一个。
DataLoader’s cache has no expiry. Once a key has been loaded, load(thatSameKey) on the same loader instance returns the cached value forever.
Build it at module top level, as one global instance, and:
- The data never refreshes. An order goes from SHIPPED to DELIVERED and the user keeps seeing SHIPPED.
- Data leaks across requests. If the loader does any permission filtering, user A’s cache becomes visible to user B. That is a security problem.
So the right move is what index.js does — new them inside the context function, a fresh set of loaders per request. When the request ends, the loaders and their caches are collected with it.
The beforeEach in the test file is the same idea — every test case rebuilds dataSources and loaders so the previous case’s cache cannot affect the next one.
graphql-federation-practice/node-subgraph/src/index.js顺带说:另一个 loader 里有个埋雷One more thing: another loader has a planted bug
现在你已经有能力看出来了。You can now spot it yourself.
项目里有两个 loader 工厂。createShippingInfoLoader 是对的,createOrderLoader 有问题。
对照 OrderDataSource 的方法名看一眼: 它有 getOrder(id)、getOrdersByUserId(userId)、createOrder(userId, items) 三个方法。没有 getOrderById。
所以这个 loader 一被使用就会抛TypeError: orderDataSource.getOrderById is not a function。 审计实测:测试 should batch multiple order requests就是因为这个失败的。
这是三个埋雷之一,后面会有专门一节讲怎么系统地找出它们。现在只要记住这个教训:写 resolver 之前, 先把数据源的方法名抄一遍。
The project has two loader factories. createShippingInfoLoader is correct; createOrderLoader is not.
Check it against the method names on OrderDataSource: it has getOrder(id), getOrdersByUserId(userId) and createOrder(userId, items). There is no getOrderById.
So the moment this loader is used it throws TypeError: orderDataSource.getOrderById is not a function. Measured in the audit: the test should batch multiple order requests fails for exactly this reason.
This is one of the three planted bugs, and a later lesson covers how to hunt all of them down systematically. For now, take the lesson: before writing a resolver, copy out the method names of the data source.
graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js动手做Get your hands on it
填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.
下面哪一条最准确?
Which of these is most accurate?
batch 函数收到 ids = ['a', 'b', 'c'], 其中 b 在数据库里不存在。下面哪种返回是错的?
The batch function receives ids = ['a', 'b', 'c'], and b does not exist in the database. Which of these return values is wrong?
两个空。第一个要你填对数据源上真实存在的方法名, 第二个要你用 loader 而不是数据源。
Two blanks. The first wants the name of a method that really exists on the data source; the second wants you to go through the loader rather than the data source.
跑 npm test,其中一个 DataLoader 相关的测试挂了。 报错指向 loader 内部。
You run npm test and one of the DataLoader tests fails. The error points inside the loader.
换一道题也能用Works on other problems too
考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.
- N+1 是 GraphQL 执行模型的天然产物:1 次列表查询 + N 次字段 resolver。N+1 comes out of the GraphQL execution model: one list query plus N field resolver calls.
- DataLoader 攒同一个 tick 里的所有 load(),tick 结束时调一次 batch 函数。DataLoader collects every load() made in the same tick and calls the batch function once when the tick ends.
- batch 函数的两条硬约束:返回长度等于 keys 长度、顺序一一对应,缺失填 null。The two hard rules for a batch function: return as many items as there are keys, in the same order, and use null where an item is missing.
- loader 必须每请求新建 —— 否则数据不刷新,还可能跨用户泄漏。A loader must be created once per request. Otherwise data goes stale and can leak between users.
- createOrderLoader 里的 getOrderById 是埋雷,真实方法名是 getOrder。getOrderById inside createOrderLoader is a planted bug. The real method name is getOrder.