DrillLab
第 07 / 17 节LESSON 07 / 17约 14 分钟~14 min

N+1 问题与 DataLoaderThe N+1 problem and DataLoader

客户端一句话,后端 100 次请求 —— 以及一个 30 行的解药。One client query, 100 backend requests — and a 30-line fix.

4 个练习4 exercisesFederation · 第 2 部分Federation · Part 2
这一页有什么On this page7
学完这节你会After this lesson you can
  • 解释 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
这在考试里考什么What the exam does with this

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.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
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.

JavaScriptorderResolvers.js源项目From source
1import DataLoader from 'dataloader';
2import { GraphQLError } from 'graphql';
3
4// Custom error codes
5const ErrorCodes = {
6 ORDER_NOT_FOUND: 'ORDER_NOT_FOUND',
7 INVALID_INPUT: 'INVALID_INPUT',
8 INVENTORY_ERROR: 'INVENTORY_ERROR',
9 SERVICE_ERROR: 'SERVICE_ERROR'
10};
11
12// DataLoader for batching shipping info requests
13function createShippingInfoLoader(shippingDataSource) {
14 return new DataLoader(async orderIds => {
15 console.log(`[DataLoader] Batching ${orderIds.length} shipping info requests`);
16
17 const shippingInfos = await Promise.all(
18 orderIds.map(id => shippingDataSource.getShippingInfo(id))
19 );
20
21 return shippingInfos;
22 });
23}
24
25// DataLoader for batching order requests
26function createOrderLoader(orderDataSource) {
27 return new DataLoader(async orderIds => {
28 console.log(`[DataLoader] Batching ${orderIds.length} order requests`);
29
30 const orders = await Promise.all(
31 orderIds.map(id => orderDataSource.getOrderById(id))
32 );
33
34 return orders;
35 });
36}
37
38export const resolvers = {
39 User: {
40 // Reference Resolver - extend User entity from Accounts subgraph
41 __resolveReference(user, { dataSources, loaders }) {
42 return { id: user.id };
43 },
44
45 // Field resolver with caching
46 async orders(user, _, { dataSources, loaders, correlationId }) {
47 // TODO: Implement orders resolver with proper error handling and correlation ID tracing
48 return [];
49 }
50 },
51
52 Order: {
53 // Field resolver for shipping info with DataLoader
54 async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
55 // TODO: Implement shipping info resolver using DataLoader to prevent N+1 queries
56 return null;
57 }
58 },
59
60 Query: {
61 async order(_, { id }, { dataSources, loaders, correlationId }) {
62 // TODO: Implement order query using DataLoader with structured error handling
63 return null;
64 },
65
66 async orders(_, { userId }, { dataSources, correlationId }) {
67 // TODO: Implement orders query with error handling and correlation ID logging
68 return [];
69 }
70 },
71
72 Mutation: {
73 // Mutation provided for reference - candidates focus on Query resolvers
74 async createOrder(_, { userId, items }, { dataSources, correlationId }) {
75 try {
76 console.log(`[${correlationId}] Creating order for userId: ${userId}`);
77
78 if (!userId || !items || items.length === 0) {
79 throw new GraphQLError('Invalid order input', {
80 extensions: {
81 code: ErrorCodes.INVALID_INPUT,
82 correlationId
83 }
84 });
85 }
86
87 const order = await dataSources.orderAPI.createOrder({ userId, items });
88 console.log(`[${correlationId}] Order created: ${order.id}`);
89
90 return order;
91 } catch (error) {
92 console.error(`[${correlationId}] Error creating order:`, error.message);
93 throw new GraphQLError('Failed to create order', {
94 extensions: {
95 code: ErrorCodes.SERVICE_ERROR,
96 correlationId,
97 originalError: error.message
98 }
99 });
100 }
101 }
102 }
103};
104
105export { createShippingInfoLoader, createOrderLoader };
Source: graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js
graphql-federation-practice/node-subgraph/package.jsondependencies 里那个 dataloader 就是提示The dataloader in dependencies is the hint
JSONpackage.json源项目From source
1{
2 "name": "order-subgraph",
3 "version": "1.0.0",
4 "description": "GraphQL Federation Subgraph for Order Management",
5 "main": "src/index.js",
6 "type": "module",
7 "scripts": {
8 "start": "node src/index.js",
9 "test": "NODE_OPTIONS=--experimental-vm-modules jest",
10 "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
11 },
12 "dependencies": {
13 "@apollo/server": "^4.10.0",
14 "@apollo/subgraph": "^2.7.0",
15 "graphql": "^16.8.1",
16 "graphql-tag": "^2.12.6",
17 "dataloader": "^2.2.2"
18 },
19 "devDependencies": {
20 "jest": "^29.7.0",
21 "@jest/globals": "^29.7.0"
22 },
23 "jest": {
24 "testEnvironment": "node",
25 "transform": {},
26 "testMatch": ["**/__tests__/**/*.test.js"]
27 }
28}
Source: graphql-federation-practice/node-subgraph/package.json
§01

N+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.

JavaScript示意Illustrative
1// ✗ 能过测试,但每个 order 一次请求
2async shippingInfo(parent, _, { dataSources }) {
3 return dataSources.shippingDataSource.getShippingInfo(parent.id);
4}
5
6// 查 100 个订单的日志会是:
7// getShippingInfo('order-1')
8// getShippingInfo('order-2')
9// ... 共 100 行
1// ✗ passes the tests, but one request per order
2async shippingInfo(parent, _, { dataSources }) {
3 return dataSources.shippingDataSource.getShippingInfo(parent.id);
4}
5
6// Ask for 100 orders and the log reads:
7// getShippingInfo('order-1')
8// getShippingInfo('order-2')
9// ... 100 lines in all
§02

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.

JavaScriptsrc/resolvers/orderResolvers.js(这个 loader 是对的)src/resolvers/orderResolvers.js (this loader is correct)源项目From source
1function createShippingInfoLoader(shippingDataSource) {
2 return new DataLoader(async orderIds => {
3 console.log(`[DataLoader] Batching ${orderIds.length} shipping info requests`);
4
5 const shippingInfos = await Promise.all(
6 orderIds.map(id => shippingDataSource.getShippingInfo(id))
7 );
8
9 return shippingInfos;
10 });
11}
Source: graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js
注意:这里仍然是 N 次 getShippingInfo 调用(用 Promise.all 并发)。真实系统里 batch 函数应该调一个「批量接口」(比如 WHERE id IN (...))。这个项目的数据源没有批量接口,所以只能这样 —— 但合并的结构是对的,考点也在结构上。Note this is still N calls to getShippingInfo, run concurrently with Promise.all. In a real system the batch function would call one batch API, such as WHERE id IN (...). The data source in this project has no batch API, so this is as far as you can go — but the structure of the merge is right, and the structure is what is being graded.
§03

batch 函数的两条硬约束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.

  1. 返回数组的长度必须等于 keys 的长度。短了,多出来的 load() 会永远 pending 或报错。
  2. 返回数组的顺序必须与 keys 一一对应。DataLoader 靠下标分发结果 ——results[0]keys[0]

所以 batch 函数里绝对不能用 filter(会变短),也不能改顺序keys.map(...) + Promise.all是最安全的写法 —— 它天然保证长度和顺序。

「找不到」怎么办?在对应位置放 null(或一个 Error 对象),不要跳过。这个项目的getShippingInfo 正是这么设计的: order-999 没有物流,返回 null, 数组长度不变。

真实系统里如果 batch 函数调的是WHERE id IN (...) 这种批量查询,数据库返回的顺序不保证和你传进去的一致, 而且缺失的行不会返回。这时必须自己重排:

  1. The returned array must be exactly as long as keys. Come up short and the extra load() calls hang forever or throw.
  2. The order of the returned array must match keys, one for one. DataLoader hands out results by indexresults[0] goes to keys[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:

JavaScript长度与顺序的正确处理Getting the length and the order right示意Illustrative
1// 真实系统里 batch 函数的标准写法
2new DataLoader(async ids => {
3 const rows = await db.query('SELECT * FROM shipping WHERE order_id IN (?)', [ids]);
4
5 // 数据库返回的顺序不保证,缺失的行也不会返回 → 必须自己按 ids 重排
6 const byId = new Map(rows.map(r => [r.order_id, r]));
7 return ids.map(id => byId.get(id) ?? null); // 长度与顺序都对上了
8});
1// The standard shape of a batch function in a real system
2new DataLoader(async ids => {
3 const rows = await db.query('SELECT * FROM shipping WHERE order_id IN (?)', [ids]);
4
5 // The database order is not guaranteed and missing rows never come back
6 const byId = new Map(rows.map(r => [r.order_id, r]));
7 return ids.map(id => byId.get(id) ?? null); // length and order now both match
8});
这个项目里因为数据源没有批量接口,用 map + Promise.all 天然满足两条约束,不需要重排。但这个模式值得记住 —— 面试常问。In this project the data source has no batch API, so map plus Promise.all satisfies both constraints on its own and no reordering is needed. The pattern is still worth remembering: interviewers ask about it often.
§04

为什么 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.

JavaScript源项目From source
1// ✓ index.js 里的正确做法:每请求新建
2context: async ({ req }) => {
3 const orderDataSource = new OrderDataSource();
4 const shippingDataSource = new ShippingDataSource();
5
6 const shippingInfoLoader = createShippingInfoLoader(shippingDataSource);
7 const orderLoader = createOrderLoader(orderDataSource);
8
9 return { dataSources: {...}, loaders: { shippingInfoLoader, orderLoader }, correlationId };
10}
1// ✓ what index.js does right: build them per request
2context: async ({ req }) => {
3 const orderDataSource = new OrderDataSource();
4 const shippingDataSource = new ShippingDataSource();
5
6 const shippingInfoLoader = createShippingInfoLoader(shippingDataSource);
7 const orderLoader = createOrderLoader(orderDataSource);
8
9 return { dataSources: {...}, loaders: { shippingInfoLoader, orderLoader }, correlationId };
10}
Source: graphql-federation-practice/node-subgraph/src/index.js
JavaScript示意Illustrative
1// ✗ 模块顶层:缓存跨请求共享,数据不刷新 + 可能泄漏
2const shippingInfoLoader = createShippingInfoLoader(new ShippingDataSource());
3
4export const resolvers = {
5 Order: {
6 async shippingInfo(parent) {
7 return shippingInfoLoader.load(parent.id); // 全局缓存
8 }
9 }
10};
1// ✗ module top level: one cache shared by every request, stale and leaky
2const shippingInfoLoader = createShippingInfoLoader(new ShippingDataSource());
3
4export const resolvers = {
5 Order: {
6 async shippingInfo(parent) {
7 return shippingInfoLoader.load(parent.id); // a global cache
8 }
9 }
10};
§05

顺带说:另一个 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.

JavaScriptsrc/resolvers/orderResolvers.js(埋雷 1)src/resolvers/orderResolvers.js (planted bug 1)源项目From source
1function createOrderLoader(orderDataSource) {
2 return new DataLoader(async orderIds => {
3 console.log(`[DataLoader] Batching ${orderIds.length} order requests`);
4
5 const orders = await Promise.all(
6 orderIds.map(id => orderDataSource.getOrderById(id)) // ← 这个方法不存在
7 );
8
9 return orders;
10 });
11}
1function createOrderLoader(orderDataSource) {
2 return new DataLoader(async orderIds => {
3 console.log(`[DataLoader] Batching ${orderIds.length} order requests`);
4
5 const orders = await Promise.all(
6 orderIds.map(id => orderDataSource.getOrderById(id)) // ← no such method
7 );
8
9 return orders;
10 });
11}
Source: graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js
练习Practice

动手做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.

L1认出来Spot itDataLoader 靠什么把 N 次合并成 1 次How DataLoader turns N calls into 1

下面哪一条最准确?

Which of these is most accurate?

先选一个选项Pick an option first
L1认出来Spot itbatch 函数里哪种写法是错的Which return value from a batch function is wrong

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?

先选一个选项Pick an option first
L2填空Fill the blanks修好 createOrderLoader 并写出 shippingInfoFix createOrderLoader and write shippingInfo

两个空。第一个要你填对数据源上真实存在的方法名, 第二个要你用 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.

JSsrc/resolvers/orderResolvers.js2 个空2 blanks
1function createOrderLoader(orderDataSource) {
2 return new DataLoader(async orderIds => {
3 const orders = await Promise.all(
4 orderIds.map(id => orderDataSource.(id))
5 );
6 return orders;
7 });
8}
9
10// Order.shippingInfo —— 必须走 loader,否则 N+1 考点没答到
11async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
12 const shippingInfo = await loaders..load(parent.id);
13 return shippingInfo ?? null;
14}
把 2 个空都填上才能检查(还差 2 个)Fill all 2 blanks to check (2 to go)
L2Debug LabDebug LabDebug Lab · DataLoader 报 is not a functionDebug Lab · DataLoader reports is not a function

npm test,其中一个 DataLoader 相关的测试挂了。 报错指向 loader 内部。

You run npm test and one of the DataLoader tests fails. The error points inside the loader.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
● Order Resolvers › DataLoader functionality › should batch multiple order requests TypeError: orderDataSource.getOrderById is not a function 29 | 30 | const orders = await Promise.all( > 31 | orderIds.map(id => orderDataSource.getOrderById(id)) | ^ 32 | ); 33 | 34 | return orders; at src/resolvers/orderResolvers.js:31:42 at Array.map (<anonymous>) at DataLoader._batchLoadFn (src/resolvers/orderResolvers.js:31:16)
JavaScriptsrc/resolvers/orderResolvers.js示意Illustrative
1function createOrderLoader(orderDataSource) {
2 return new DataLoader(async orderIds => {
3 const orders = await Promise.all(
4 orderIds.map(id => orderDataSource.getOrderById(id))
5 );
6 return orders;
7 });
8}
9
10// 参考:OrderDataSource 上真实存在的方法
11// class OrderDataSource {
12// async getOrder(id) { ... }
13// async getOrdersByUserId(userId) { ... }
14// async createOrder(userId, items) { ... }
15// }
1function createOrderLoader(orderDataSource) {
2 return new DataLoader(async orderIds => {
3 const orders = await Promise.all(
4 orderIds.map(id => orderDataSource.getOrderById(id))
5 );
6 return orders;
7 });
8}
9
10// For reference: the methods OrderDataSource really has
11// class OrderDataSource {
12// async getOrder(id) { ... }
13// async getOrdersByUserId(userId) { ... }
14// async createOrder(userId, items) { ... }
15// }
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
迁移Transfer

换一道题也能用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.

「一个列表里每项都要查关联数据」Every item in a list needs related data fetched
N+1 风险,上 DataLoaderN+1 risk; use a DataLoader
TODO 里出现「prevent N+1」A TODO says prevent N+1
必须走 loader.load(),不能直接调数据源Go through loader.load(); do not call the data source directly
写 batch 函数You are writing a batch function
keys.map + Promise.all;长度和顺序必须对齐,缺失填 nullkeys.map plus Promise.all; keep length and order aligned, fill missing entries with null
「数据不刷新」或「看到了别人的数据」Data does not refresh, or one user sees another user's data
查 loader 是不是建在了模块顶层Check whether the loader was created at module top level
xxx is not a functionxxx is not a function
去被调对象的定义里核对方法名Open the definition of the object you called and check the method name
这节的要点What to take away
  1. N+1 是 GraphQL 执行模型的天然产物:1 次列表查询 + N 次字段 resolver。N+1 comes out of the GraphQL execution model: one list query plus N field resolver calls.
  2. DataLoader 攒同一个 tick 里的所有 load(),tick 结束时调一次 batch 函数。DataLoader collects every load() made in the same tick and calls the batch function once when the tick ends.
  3. 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.
  4. loader 必须每请求新建 —— 否则数据不刷新,还可能跨用户泄漏。A loader must be created once per request. Otherwise data goes stale and can leak between users.
  5. createOrderLoader 里的 getOrderById 是埋雷,真实方法名是 getOrder。getOrderById inside createOrderLoader is a planted bug. The real method name is getOrder.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises4 个,就在这一页上面 —— 别攒着最后一起做4 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson先读题:四个 TODO、三处埋雷、十个测试Read the task first: four TODOs, three planted bugs, ten tests
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: entity、@key 与 __resolveReferenceentity, @key and __resolveReference