N+1 trong GraphQL — DataLoader (Node) và batch loader của Lighthouse (Laravel)
Đây là câu hỏi GraphQL gần như chắc chắn gặp trong phỏng vấn backend: "GraphQL bị N+1 thế nào, anh chữa ra sao?" Trả lời "dùng DataLoader" là mới được một nửa. Nửa còn lại — phần phân biệt senior — là: DataLoader gom key vào lúc nào, vì sao nó phải tạo mới cho mỗi request, nó âm thầm trả sai dữ liệu khi nào, và nó không chữa được gì (phân trang theo từng cha, batch quá to, lazy load giấu trong model). Bài này đo tất cả trên PostgreSQL 18.4 thật, bằng cả Node (DataLoader) lẫn Laravel (Lighthouse).
Thuật ngữ dùng trong bài
N+1 (1 câu lấy danh sách N phần tử, rồi N câu lấy dữ liệu con — một câu cho mỗi phần tử) · batching (gom nhiều lần hỏi lẻ thành một lần hỏi theo lô) · memoization (nhớ kết quả đã hỏi để lần sau trả ngay) · DataLoader (thư viện của GraphQL Foundation làm cả hai việc trên, theo phạm vi một request) · batch function (hàm nhận mảng key, trả mảng kết quả cùng độ dài, cùng thứ tự) · tick (một lượt chạy code đồng bộ cộng các microtask/nextTick sinh ra trước khi event loop đi tiếp) · eager loading / lazy loading (nạp quan hệ trước theo lô / nạp khi chạm vào thuộc tính — mỗi object một câu) · Deferred / SyncPromise (kiểu "promise đồng bộ" của webonyx/graphql-php — cách PHP hoãn việc mà không có event loop) · LATERAL join (join mà phía phải được chạy lại cho từng dòng phía trái — dùng để LIMIT theo từng cha) ·= ANY($1::int[])(truyền cả danh sách key bằng một tham số mảng thay vìIN ($1, $2, …)).
Kiểm chứng: tài liệu qua Context7: DataLoader README (/graphql/dataloader— batch scheduling, per-request loader, kiểu Options, danh sách lỗi), Lighthouse 6.63 docs (/nuwave/lighthouse— performance/n-plus-one,@hasMany, BatchLoader), webonyx/graphql-php (data fetching, Deferred). Source đọc trực tiếp:dataloader@2.2.3/index.jsdòng 231–243, Laravel 13.34.0Relation::whereInMethod(). Lab chạy thật: Node 22.23.3 + DataLoader 2.2.3 + pg 8.23.1 trên PostgreSQL 18.4 (my-test-pgdb, bảng tạmtmp_gql_*); Laravel 13.34.0 + Lighthouse 6.71.0 + webonyx/graphql-php 15.37.2 + PHP 8.3.33 (bảng tạmtmp_lh_*). Dữ liệu: 50 user × 10 post × 3 comment.
Trả lời 30 giây
Trong GraphQL, N+1 là mặc định chứ không phải lỗi hiếm: mỗi field có một resolver, chạy cho từng object, và server không biết trước client sẽ hỏi sâu tới đâu nên không eager load sẵn được. Lab của tôi: query users → posts → comments → author trên 50 user ra 2 051 câu SQL. DataLoader chữa bằng cách gom mọiload(key)phát ra trong cùng một tick, chạy một batchWHERE id = ANY($1), rồi chia kết quả về đúng chỗ — còn 4 câu, 572ms xuống 45ms. Số câu SQL thành "số tầng × số loại quan hệ", không phụ thuộc N. Ba điều phải nhớ: loader tạo mới cho mỗi request (dùng chung là lộ dữ liệu giữa user); batch function phải trả đúng độ dài và đúng thứ tự key (sai thứ tự thì dữ liệu sai mà không báo lỗi); và nó không làm được LIMIT theo từng cha — cần LATERAL join. Bên Laravel, Lighthouse làm việc này tự động cho@hasMany/@belongsTo, nhưng một method model có lazy load là N+1 quay lại ngay.
1. Bài toán gốc và bối cảnh ra đời
Với REST, N+1 là lỗi của một endpoint: lập trình viên biết endpoint trả gì nên eager load đúng quan hệ
(User::with('posts.comments')). Với GraphQL, hình dạng response do client quyết lúc chạy. Resolver
User.posts không biết nó đang chạy cho 1 user hay 50 user, càng không biết client có hỏi tiếp
comments không. Viết resolver "tự nhiên" — mỗi lần gọi một câu SQL — là N+1 ở mọi tầng.
Facebook gặp vấn đề này từ trước khi có GraphQL. README của DataLoader ghi: cách gom batch trong "một khung thực thi" chính là "the same behavior used in Facebook's original PHP implementation in 2010". Khi mở mã GraphQL năm 2015, DataLoader được viết lại bằng JavaScript, và ngày nay nằm dưới GraphQL Foundation.
- Tối ưu cho: tra cứu theo key (id, khoá ngoại) trong phạm vi một request — gom, bỏ trùng, nhớ.
- Không tối ưu cho: cache giữa các request (việc của Redis); LIMIT/phân trang theo từng cha; phép tổng hợp (đếm, tổng); ghi dữ liệu.
2. Mô hình tư duy cốt lõi
Gom mọi key được hỏi trong cùng một tick → chạy một batch → trả mỗi người hỏi đúng phần của mình. Và nhớ kết quả, nhưng chỉ trong một request.
Hệ quả: số câu SQL không còn tỉ lệ với N mà tỉ lệ với độ sâu của query × số loại quan hệ. 50 user hay 5 000 user thì vẫn 4 câu — chỉ có mỗi câu to hơn (mục 3.8 nói khi nào "to hơn" thành vấn đề).
load() (DataLoader bỏ trùng còn 50 id). Số liệu: lab 03 trên Node; với
Lighthouse là 2 051 câu / 6 194ms so với 4 câu / 367ms.3. Internals và quyết định thiết kế
3.1 Đo trên PostgreSQL: resolver tự nhiên và DataLoader
// resolver "tự nhiên" — mỗi lần gọi 1 câu SQL
User: { posts: (u) => db.query('SELECT * FROM tmp_gql_posts WHERE user_id = $1 ORDER BY id', [u.id]) },
Post: { comments: (p) => db.query('SELECT * FROM tmp_gql_comments WHERE post_id = $1 ORDER BY id', [p.id]) },
Comment: { author: async (c) => (await db.query('SELECT * FROM tmp_gql_users WHERE id = $1', [c.user_id]))[0] },
// cùng schema, mọi quan hệ đi qua DataLoader tạo MỚI trong context của request
User: { posts: (u, _, ctx) => ctx.loaders.postsByUser.load(u.id) },
Post: { comments: (p, _, ctx) => ctx.loaders.commentsByPost.load(p.id) },
Comment: { author: (c, _, ctx) => ctx.loaders.userById.load(c.user_id) },
$ ./run.sh 03-n-plus-1.mjs
resolver tự nhiên → 2051 câu SQL | 572ms (nhanh nhất / 3 lần) | response 94976 byte
DataLoader mỗi request → 4 câu SQL | 45ms (nhanh nhất / 3 lần) | response 94976 byte
kích thước từng batch: {"postsByUser":[50],"commentsByPost":[500],"userById":[50]}
các câu SQL đã chạy với DataLoader:
SELECT * FROM tmp_gql_users ORDER BY id
SELECT * FROM tmp_gql_posts WHERE user_id = ANY($1::int[]) ORDER BY id
SELECT * FROM tmp_gql_comments WHERE post_id = ANY($1::int[]) ORDER BY id
SELECT * FROM tmp_gql_users WHERE id = ANY($1::int[])
2 051 = 1 + 50 + 500 + 1 500. Response giống hệt nhau đến từng byte (94 976). Chú ý batch userById: có 1 500 lần
load() nhưng chỉ 50 key — DataLoader bỏ trùng trong một instance. DB ở đây nằm cùng máy (độ trễ mạng gần 0); với DB qua
mạng thật, mỗi câu thêm ~0,5–1ms, khoảng cách giữa 2 051 câu và 4 câu còn lớn hơn nữa.
3.2 DataLoader gom key vào lúc nào — đọc thẳng source
// dataloader@2.2.3 — index.js, dòng 231–243: lịch mặc định để chạy batch
var enqueuePostPromiseJob = typeof process === 'object' && typeof process.nextTick === 'function' ? function (fn) {
if (!resolvedPromise) {
resolvedPromise = Promise.resolve();
}
resolvedPromise.then(function () {
process.nextTick(fn);
});
} : typeof setImmediate === 'function' ? function (fn) {
setImmediate(fn);
} : function (fn) {
setTimeout(fn);
};
Lần load() đầu tiên của một batch đặt lịch: đợi một microtask, rồi xếp một process.nextTick.
Vì sao vòng vèo như vậy? Resolver thường nằm trong chuỗi Promise; load() của field con có thể được gọi trong các
.then nối tiếp. Đợi qua hàng microtask rồi mới nextTick nghĩa là: mọi load() phát ra trong code đồng bộ
và trong các microtask đang chờ đều kịp vào batch, nhưng batch vẫn chạy trước khi event loop đi xử lý I/O
(xem bài microtask và nextTick). Lab B kiểm chứng:
1 code đồng bộ: load(1)
2 microtask: load(2)
3 microtask nối tiếp (.then thứ 2): load(3)
4 process.nextTick: load(4)
5 >>> batch function chạy với keys = [1,2,3,4]
6 setImmediate: load(6)
7 >>> batch function chạy với keys = [6]
8 setTimeout 0: load(5)
9 >>> batch function chạy với keys = [5]
nextTick(dispatch)
chạy. Key 5 và 6 được hỏi sau khi event loop đã đi tiếp (sang phase check / timers) nên mỗi cái một batch. Thứ tự giữa
setImmediate và setTimeout 0 ở đây không cố định — xem bài event loop.Hệ quả thực tế — bẫy hay gặp nhất: resolver await một I/O khác (check quyền, đọc config, gọi service)
trước khi gọi load(). Mỗi I/O xong vào một thời điểm khác nhau, nên mỗi load() rơi vào một tick khác:
load() ngay → postsByUser chạy 1 batch, kích thước [10]
await I/O rồi mới load() → postsByUser chạy 10 batch, kích thước [1,1,1,1,1,1,1,1,1,1]
for + await → 5 câu SQL // for (const id of ids) await loader.load(id)
Promise.all(map) → 1 câu SQL // await Promise.all(ids.map((id) => loader.load(id)))
DataLoader vẫn đang "chạy", code review vẫn thấy "có dùng DataLoader", nhưng N+1 đã quay lại. Chữa: gọi load() ngay
đầu resolver; nếu phải check quyền thì check bằng một loader khác (cũng được batch), hoặc check sau khi có dữ liệu.
3.3 Hợp đồng của batch function: đúng độ dài, đúng thứ tự
DataLoader trả kết quả cho từng load() theo vị trí: phần tử thứ i của mảng kết quả là của key thứ i. Nó
chỉ kiểm tra được độ dài:
// batch fn trả thẳng rows từ DB — key 999 không tồn tại nên mảng ngắn hơn
DataLoader must be constructed with a function which accepts Array<key> and returns Promise<Array<value>>, but the
function did not return a Promise of an Array of the same length as the Array of keys.
// batch fn trả đủ 3 dòng nhưng theo ORDER BY id, còn key hỏi theo thứ tự [30, 10, 20]
load(30) → {"id":10,"name":"user 10"} | load(10) → {"id":20,"name":"user 20"} | load(20) → {"id":30,"name":"user 30"}
ORDER BY id.Mẫu code đúng — luôn ghép theo key, trả Error cho key không có:
new DataLoader(async (ids) => {
const rows = await db.query('SELECT * FROM users WHERE id = ANY($1::int[])', [ids]);
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? new Error(`user ${id} không tồn tại`)); // đúng độ dài, đúng thứ tự
});
// quan hệ 1-nhiều: nhóm theo khoá ngoại, key không có dòng nào → mảng rỗng
const groupBy = (rows, key, keys) => {
const m = new Map(keys.map((k) => [k, []]));
for (const r of rows) m.get(r[key]).push(r);
return keys.map((k) => m.get(k));
};
Một chi tiết hay bị quên: id từ DB là số, key từ GraphQL (ID) thường là chuỗi — Map so sánh
nghiêm ngặt nên "10" và 10 là hai key khác nhau. Chuẩn hoá kiểu key ở một chỗ (hoặc dùng
cacheKeyFn).
3.4 Cache trong một request — và vì sao không được dùng chung
DataLoader nhớ Promise của mỗi key trong một Map của chính instance đó. README khuyên tạo loader mới cho mỗi
request "để cache không lộ dữ liệu giữa các user". Lab C3 tạo loader một lần ở cấp module (lỗi rất hay gặp khi người viết nghĩ
"tạo loader tốn kém"), batch function lọc theo tenant của viewer:
// post 1 thuộc tenant 2, post 11 thuộc tenant 1
request 1, viewer A (tenant 1) đọc post 1, 11: [null,{"id":11,"title":"post 11","tenant_id":1}]
request 2, viewer B (tenant 2) đọc post 1, 11: [null,{"id":11,"title":"post 11","tenant_id":1}]
→ B đọc được post 11 của tenant 1, còn post 1 của chính B lại thành null
sau UPDATE title post 11, request 3 vẫn thấy : {"id":11,"title":"post 11","tenant_id":1}
Hai lỗi cùng lúc: lộ dữ liệu giữa tenant (B thấy post của A, vì kết quả được cache theo quyền của A) và
dữ liệu cũ vĩnh viễn (cache không bao giờ hết hạn, Map còn lớn dần tới khi hết RAM). Chữa: tạo loader trong
hàm dựng context của mỗi request. Sau mutation trong cùng request, gọi loader.clear(id) hoặc
prime(id, giá trị mới) để field đọc lại không thấy bản cũ.
3.5 PHP không có event loop: webonyx dùng Deferred, Lighthouse tự batch theo đường đi
Laravel chạy PHP đồng bộ, không có microtask hay nextTick. webonyx/graphql-php giải bằng GraphQL\Deferred: resolver
không trả dữ liệu mà trả một Deferred kèm hàm "lát nữa hãy gọi". Executor chạy hết các resolver nó tới được, gom các
Deferred vào hàng đợi, rồi mới chạy hàng đợi — lúc đó một loader đã thu đủ key của cả tầng. Lighthouse đóng gói việc này:
BatchLoaderRegistry::instance($resolveInfo->path, …) giữ một batch loader cho mỗi đường đi trong query (docs
Lighthouse, performance/n-plus-one), và với @hasMany, @belongsTo... nó dùng eager loading của Eloquent cho
cả tầng.
sequenceDiagram participant E as Executor webonyx participant R as Resolver User.posts participant B as BatchLoader của đường users.posts participant DB as PostgreSQL E->>R: gọi cho user 1 R->>B: thêm user 1, trả Deferred E->>R: gọi cho user 2 ... user 50 R->>B: thêm user 2 ... 50, trả Deferred Note over E: hết resolver chạy được, bắt đầu chạy hàng đợi Deferred E->>B: Deferred đầu tiên chạy B->>DB: 1 câu: posts where user_id in (1..50) B-->>E: trả posts cho từng user
Laravel 13.34.0 | Lighthouse v6.71.0 | webonyx/graphql-php v15.37.2 | PHP 8.3.33
batchload_relations=true → HTTP 200 | 4 câu SQL | 367ms (nhanh nhất / 3 lần) | 94976 byte
select * from "tmp_lh_users"
select * from "tmp_lh_posts" where "tmp_lh_posts"."user_id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, …
select * from "tmp_lh_comments" where "tmp_lh_comments"."post_id" in (1, 2, 3, 4, 5, 6, 7, 8, …
select * from "tmp_lh_users" where "tmp_lh_users"."id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, …
batchload_relations=false → HTTP 200 | 2051 câu SQL | 6194ms (nhanh nhất / 3 lần) | 94976 byte
Cùng con số 2 051 → 4 như Node. Thời gian tuyệt đối của PHP lớn hơn nhiều (367ms so với 45ms) vì có cả boot Laravel, hydrate 2 050 model Eloquent, và máy lab dùng chung — chỉ nên so tỉ lệ trong cùng một stack (~17 lần ở PHP, ~13 lần ở Node).
Chi tiết đáng biết: Eloquent viết thẳng danh sách số vào SQL (in (1, 2, …)) thay vì bind tham số —
Relation::whereInMethod() chọn whereIntegerInRaw khi khoá là primary key kiểu int. Với primary key
kiểu chuỗi (vd UUID) nó dùng whereIn có bind từng giá trị — tức là sẽ đụng giới hạn tham số ở mục 3.8 khi có quá nhiều
cha (đọc từ source Laravel 13.34.0; chưa chạy lab với UUID).
3.6 Lighthouse: lazy load trong model là N+1 quay lại
Batch của Lighthouse chỉ áp dụng cho field có directive quan hệ. Một field dùng @method (hoặc accessor, hoặc resolver
tự viết) mà bên trong chạm vào $this->posts là lazy load — mỗi user một câu:
// app/Models/User.php
public function postCountLazy(): int { return $this->posts->count(); }
// schema: postCount: Int! @method(name: "postCountLazy")
{ users { name postCount } } → HTTP 200 | 51 câu SQL
{ users { name postCountBatched } } (@count) → HTTP 200 | 2 câu SQL
select * from "tmp_lh_users"
select "id", (select count(*) from "tmp_lh_posts" where "tmp_lh_users"."id" = "tmp_lh_posts"."user_id") as "posts_count"
from "tmp_lh_users" where "tmp_lh_users"."id" in (1, 2, 3, …, 50)
sau Model::preventLazyLoading() → HTTP 200 | 1 câu SQL
errors[0].message: Internal server error
errors[0].extensions.debugMessage: Attempted to lazy load [posts] on model [App\Models\User] but lazy loading is disabled.
Model::preventLazyLoading() biến N+1 âm thầm thành lỗi rõ ràng. Bật nó ở môi trường dev và test
(Model::preventLazyLoading(! app()->isProduction())) để bắt lỗi trước khi lên production. Chữa field đếm: dùng
postCountBatched: Int! @count(relation: "posts") — lab đo được 2 câu SQL (một subquery đếm cho cả 50 user), thay vì
nạp cả quan hệ rồi đếm.
3.7 "3 bài mới nhất của mỗi user": LIMIT trong batch là LIMIT chung
DataLoader gom key thành WHERE user_id = ANY(…). Thêm LIMIT 3 vào câu đó là giới hạn cả câu, không
phải mỗi user:
sai (LIMIT 3 chung): [[],[],[30,29,28]]
đúng (LATERAL) : [[10,9,8],[20,19,18],[30,29,28]]
SELECT u.id AS user_id, p.id
FROM unnest($1::int[]) AS u(id)
CROSS JOIN LATERAL (
SELECT id FROM tmp_gql_posts WHERE user_id = u.id ORDER BY created_at DESC LIMIT 3
) p;
EXPLAIN:
Nested Loop
-> Function Scan on unnest u
-> Limit
-> Index Scan using tmp_gql_posts_user_id_created_at_idx on tmp_gql_posts
Index Cond: (user_id = u.id)
Với index (user_id, created_at DESC), PostgreSQL đọc đúng 3 dòng cho mỗi user — không quét hết bài rồi mới cắt. Cách
khác là window function row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) <= 3, nhưng nó phải đọc mọi
dòng của các user đó trước khi lọc.
Lighthouse giải bài này theo cách riêng — lab với postsPaged: [Post!]! @hasMany(type: SIMPLE):
HTTP 200 | 3 câu SQL | user 1 → ["post 1","post 2","post 3"]
select * from "tmp_lh_users"
select "id", (select count(*) from "tmp_lh_posts" where "tmp_lh_users"."id" = "tmp_lh_posts"."user_id") as "posts_count"
from "tmp_lh_users" where "tmp_lh_users"."id" in (1, 2, 3, …, 50)
(select * from "tmp_lh_posts" where "tmp_lh_posts"."user_id" in (1) limit 3 offset 0) union all
(select * from "tmp_lh_posts" where "tmp_lh_posts"."user_id" in (2) limit 3 offset 0) union all … (50 nhánh)
Một câu đếm cho mọi user, rồi một câu UNION ALL có 50 nhánh, mỗi nhánh LIMIT riêng. Đúng kết quả, vẫn là số
câu cố định; nhưng độ dài câu SQL và thời gian lập kế hoạch tăng theo số cha — với vài nghìn cha thì nên đo lại (chưa đo ở quy
mô lớn trong lab này).
3.8 Batch quá to: giới hạn 65 535 tham số của PostgreSQL
Giao thức PostgreSQL ghi số tham số của một câu bằng số nguyên 16 bit. Batch function viết kiểu
IN ($1, $2, …, $70000) sẽ hỏng — với thông báo lỗi rất khó hiểu:
IN ($1..$70000) → lỗi: bind message has 4464 parameter formats but 0 parameters
= ANY($1::int[]) → chạy được, khớp 50 dòng (1 tham số duy nhất là mảng)
maxBatchSize: 1000 → 70 000 key chia thành 70 câu SQL
4 464 = 70 000 − 65 536: số tham số bị tràn số. Lỗi này "nổ trễ" kinh điển: dev và staging không bao giờ có 65 536 key trong
một batch; production gặp vào ngày có một tenant lớn. Chữa: luôn truyền mảng bằng một tham số
(= ANY($1::int[])), và đặt maxBatchSize để câu SQL không quá to (kế hoạch thực thi và bộ nhớ cũng có giới hạn).
4. Đảm bảo và giới hạn
- DataLoader đảm bảo: mỗi tick mỗi loader tối đa một batch (hoặc nhiều batch nếu vượt
maxBatchSize); key trùng trong một instance chỉ hỏi một lần; mỗiload()nhận phần tử cùng vị trí trong mảng kết quả. - Không đảm bảo: dữ liệu đúng nếu batch function sai thứ tự (mục 3.3); nhất quán giữa các loader (hai loader đọc
DB ở hai thời điểm); dữ liệu mới sau khi ghi (phải
clear/prime); gom key qua các tick khác nhau. - Độ phức tạp: số câu SQL = O(độ sâu × số loại quan hệ); kích thước mỗi câu = O(số key của tầng đó); bộ nhớ cache = O(số key đã hỏi trong request).
- Giới hạn cứng: 65 535 tham số mỗi câu ở PostgreSQL (mục 3.8); thời gian lập kế hoạch tăng với danh sách
INrất dài hoặcUNION ALLnhiều nhánh.
5. Failure modes
| Hỏng thế nào | Lab tái hiện | Hệ quả | Phát hiện | Chữa |
|---|---|---|---|---|
| Loader dùng chung giữa request | C3 | Lộ dữ liệu giữa tenant; dữ liệu cũ vĩnh viễn; RAM tăng dần | Test 2 user khác quyền đọc cùng key | Tạo loader trong context mỗi request |
| Batch function sai thứ tự | C2 | User thấy dữ liệu của người khác, không có lỗi | Test với key hỏi theo thứ tự ngẫu nhiên | Ghép theo key bằng Map |
| Batch function sai độ dài | C1 | Mọi load() của batch đó lỗi | Message lỗi của DataLoader | Trả Error/null cho key thiếu |
Await I/O trước load() | B2, C4 | N+1 quay lại dù "có dùng DataLoader" | Đếm số batch mỗi request | load() ngay đầu resolver; Promise.all |
| Batch vượt 65 535 tham số | C5 | Lỗi "bind message has … parameter formats" | Lỗi chỉ xuất hiện với tenant lớn | ANY($1::type[]) + maxBatchSize |
| LIMIT theo cha viết sai | C6 | Cha đầu có dữ liệu, cha sau rỗng | Test nhiều cha | LATERAL / window function |
| Lazy load trong model (Laravel) | Lighthouse B | 51 câu thay vì 2 | preventLazyLoading ở dev/test | Directive quan hệ, @count, withCount |
| N+1 trong reference resolver (federation) | Bài 5: 20 → 1 | Gateway gom được, subgraph vẫn hỏi lẻ | Đếm SQL ở subgraph | DataLoader trong __resolveReference |
6. Vận hành trong production
- Metric: số câu SQL mỗi operation (histogram, alert khi vượt ngưỡng), số batch và kích thước batch mỗi loader,
thời gian mỗi batch. DataLoader có option
nameđể APM gắn tên cho span. - Test "ngân sách câu SQL" trong CI: chạy các operation chính trên dữ liệu có nhiều cha (vd 50) và assert số câu SQL
≤ số tầng. Ở Laravel:
DB::listenđếm câu, bậtModel::preventLazyLoading()trong test. Đây là cách duy nhất chặn được lỗi B2 (await trước load) trước khi lên production. - Tham số:
maxBatchSizetheo giới hạn DB và độ lớn câu SQL chấp nhận được;cache: true(mặc định) trong phạm vi request; hầu như không nên đổibatchScheduleFn— gom theo cửa sổ 100ms nghĩa là cộng thêm 100ms vào mọi request. - Lúc 3 giờ sáng: "DB CPU cao, số connection đầy" + "số câu SQL mỗi request tăng sau deploy" = một resolver mới đã bỏ qua loader. Bảng số câu SQL theo operation chỉ ra ngay operation nào.
7. So sánh và ma trận quyết định
| Cách chống N+1 | Số câu SQL | Linh hoạt | Gắn chặt DB | Phân quyền | Ghi chú |
|---|---|---|---|---|---|
| DataLoader (batch theo tick) | Số tầng × số quan hệ | Cao: nguồn nào cũng được (SQL, REST, gRPC) | Thấp | Làm trong batch fn hoặc ở field | Mặc định cho Node |
| Eager load theo directive / lookahead (Lighthouse) | Như trên | Trung bình: chủ yếu cho Eloquent | Trung bình | @can, Policy | Mặc định cho Laravel; cẩn thận @method |
| Biên dịch cả query thành 1 câu SQL (kiểu Hasura, PostGraphile) | 1 | Thấp: schema bám sát DB | Cao | Thường dựa vào Row-Level Security | Chưa đo trong lab này |
| Cache kết quả (Redis) | 0 khi trúng | — | — | Khó (cache theo quyền) | Bổ sung, không thay thế batching |
Dùng DataLoader cho mọi quan hệ trong server Node, không ngoại lệ. Dùng directive quan hệ của Lighthouse trong Laravel và cấm lazy load ở dev/test. Tránh tự viết lookahead dựng SQL bằng tay, và tránh dùng DataLoader làm cache dài hạn.
8. Góc nhìn tech lead: tác động tổ chức
- Quy ước bắt buộc: "resolver không gọi DB trực tiếp — chỉ gọi loader hoặc service có batch". Đưa vào checklist code review và test ngân sách câu SQL.
- Một chỗ duy nhất tạo loader: hàm
createContext(req). Không cho phépnew DataLoaderở cấp module — có thể chặn bằng lint rule đơn giản. - Đào tạo: dev phải hiểu tick/microtask (bài Node) thì mới hiểu vì sao
awaittrướcload()phá batch. Đây là kiến thức nền, không phải chi tiết thư viện. - Rủi ro dài hạn: loader trở thành "lớp truy cập dữ liệu thứ hai" song song với repository/ORM. Gom chúng về chung một tầng data access để không có hai bộ luật phân quyền.
9. Anti-patterns và bài học thực tế
- Loader global/singleton — lộ dữ liệu, dữ liệu cũ, rò RAM (C3).
- Batch function trả rows thẳng từ DB không ghép theo key (C1, C2).
IN ($1…$n)với n không giới hạn (C5).- Check quyền bằng
awaittrướcload()(B2) — gặp rất nhiều trong code thật vì nó "trông hợp lý". - LIMIT/OFFSET trong batch cho phân trang theo cha (C6).
- Dùng DataLoader để ghi dữ liệu — ghi theo lô làm khó transaction và báo lỗi từng phần tử; ghi nên đi qua service.
- Tin rằng framework lo hết — Lighthouse batch
@hasManynhưng không batch được những gì nằm trong@method/accessor (mục 3.6).
Điều tài liệu không nói rõ nhưng người vận hành lâu đều biết: N+1 hiếm khi xuất hiện lúc viết code mới — nó xuất hiện khi ai đó thêm một field "nhỏ" vào một type đang nằm trong list, vài tháng sau. Vì vậy phòng thủ đúng là test ngân sách câu SQL chạy trên mọi operation quan trọng, không phải review từng resolver.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- DataLoader khác cache Redis thế nào?
- DataLoader chỉ sống trong một request, mục đích là gom và bỏ trùng. Redis sống qua nhiều request, mục đích là tránh hỏi lại DB — và phải tự lo hết hạn, phân quyền.
- Vì sao DataLoader dùng nextTick mà không dùng setTimeout?
- setTimeout cộng thêm độ trễ và để I/O chen vào. Đợi một microtask rồi nextTick thì gom được mọi
load()trong chuỗi Promise hiện tại mà vẫn chạy batch trước I/O (source dòng 231–243). - Query sâu 6 tầng thì bao nhiêu câu SQL?
- Với DataLoader: khoảng 6 × số loại quan hệ ở mỗi tầng, không phụ thuộc số phần tử. Nhưng mỗi câu có thể rất to — kết hợp giới hạn độ sâu/chi phí ở bài 4.
- PHP không có event loop thì batch thế nào?
- webonyx dùng
Deferred: resolver trả "hứa hẹn đồng bộ", executor chạy hết resolver tới được rồi mới chạy hàng đợi — lúc đó loader đã có đủ key. Lighthouse gói việc này vào directive quan hệ. - Phân trang theo từng cha làm sao không N+1?
- LATERAL join với index phù hợp (lab C6), hoặc window function; Lighthouse dùng một câu UNION ALL nhiều nhánh.
- Làm sao phát hiện N+1 trước production?
- Test đếm câu SQL theo operation trên dữ liệu có nhiều cha; ở Laravel bật
preventLazyLoadingtrong test.
Phụ lục: lab đã chạy
Môi trường: Node 22.23.3 (node:22-bookworm-slim), DataLoader 2.2.3, graphql 17.0.2, pg 8.23.1; Laravel 13.34.0,
Lighthouse 6.71.0, webonyx/graphql-php 15.37.2, PHP 8.3.33 (image laravel-app); PostgreSQL 18.4 container
my-test-pgdb (DB isolation_demo), mọi bảng là tmp_* và bị xoá cuối lab. Output:
labs/graphql/out/03-n-plus-1.txt, 08-lighthouse.txt.
cd labs/graphql/node
./run.sh 03-n-plus-1.mjs # chạy trong container dùng chung network với my-test-pgdb
cd ../laravel
./setup.sh /tmp/lh-app # tạo app Laravel + Lighthouse TẠM ngoài repo, chạy mọi kịch bản
# kịch bản liên quan bài này: n1 (2 lần: batch bật/tắt), lazy, paginate
Chưa verify: giới hạn tham số với khoá UUID trong Eloquent (mới đọc source); hiệu năng UNION ALL của Lighthouse với hàng nghìn cha; cách "biên dịch cả query thành 1 câu SQL" của Hasura/PostGraphile.