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.js dòng 231–243, Laravel 13.34.0 Relation::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ạm tmp_gql_*); Laravel 13.34.0 + Lighthouse 6.71.0 + webonyx/graphql-php 15.37.2 + PHP 8.3.33 (bảng tạm tmp_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ọi load(key) phát ra trong cùng một tick, chạy một batch WHERE 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.

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 đề).

Số câu SQL theo từng tầng: resolver tự nhiên và DataLoader Bên trái, resolver tự nhiên: tầng users 1 câu, tầng posts thêm 50 câu, tầng comments thêm 500 câu, tầng author thêm 1500 câu, tổng 2051 câu, 572ms. Bên phải, DataLoader: mỗi tầng chỉ thêm đúng một câu dùng ANY với cả danh sách key, tầng author gộp 1500 lần load còn 50 id, tổng 4 câu, 45ms. Resolver "tự nhiên" DataLoader tạo mới mỗi request ① users → 1 câu ② posts → +50 câu (mỗi user 1 câu) ③ comments → +500 câu (mỗi post 1 câu) ④ author → +1 500 câu (mỗi comment 1 câu) ① users → 1 câu ② posts → +1 câu, ANY(50 user_id) ③ comments → +1 câu, ANY(500 post_id) ④ author → +1 câu, 50 id (gộp 1 500 load) PostgreSQL PostgreSQL tổng: 1 câu tổng: 51 câu tổng: 551 câu tổng: 2 051 câu · 572ms tổng: 1 câu tổng: 2 câu tổng: 3 câu tổng: 4 câu · 45ms
Hình 1 — Mỗi dòng là một tầng của query, hiện dần theo thứ tự chạy. Bên trái mỗi tầng nhân số câu SQL lên theo số object; bên phải mỗi tầng chỉ thêm đúng 1 câu, dù tầng author có 1 500 lầ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]
DataLoader gom key trong một tick Trong cùng một tick: load 1 từ code đồng bộ, load 2 và 3 từ microtask, load 4 từ nextTick. Sau đó DataLoader chạy batch với 4 key thành một câu SQL. Sau khi event loop đi tiếp, load 6 từ setImmediate và load 5 từ setTimeout mỗi cái thành một batch riêng. cùng 1 tick (trước khi event loop xử lý I/O) event loop đi tiếp code đồng bộ microtask nextTick batch 1 2 3 4 nextTick(dispatch) keys [1,2,3,4] → 1 câu SQL 6 setImmediate batch [6] 5 setTimeout 0 batch [5]
Hình 2 — Theo đúng output lab B. Key 1–4 vào chung một batch vì đều được hỏi trước khi 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"}
Batch function trả đúng độ dài nhưng sai thứ tự Key được hỏi theo thứ tự 30, 10, 20. Câu SQL trả về theo thứ tự id 10, 20, 30. Ghép theo vị trí thì load 30 nhận user 10, sai mà không báo lỗi. Ghép theo id bằng Map thì mỗi key nhận đúng user của nó. key (thứ tự load) SQL trả về (ORDER BY id) mỗi load() nhận 30 10 20 user 10 user 20 user 30 ghép theo VỊ TRÍ (index) load(30) → user 10 ✗ load(10) → user 20 ✗ load(20) → user 30 ✗ ghép theo ID (Map) load(30) → user 30 ✓ load(10) → user 10 ✓ load(20) → user 20 ✓
Hình 3 — Lỗi nguy hiểm nhất của DataLoader: không exception, không log, chỉ là user A nhìn thấy tên (hoặc email, số dư) của user B. Nó còn "nổ trễ": trên môi trường dev, key thường được hỏi theo thứ tự tăng dần nên trùng khớp ngẫu nhiên với 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

5. Failure modes

Hỏng thế nàoLab tái hiệnHệ quảPhát hiệnChữa
Loader dùng chung giữa requestC3Lộ dữ liệu giữa tenant; dữ liệu cũ vĩnh viễn; RAM tăng dầnTest 2 user khác quyền đọc cùng keyTạo loader trong context mỗi request
Batch function sai thứ tựC2User thấy dữ liệu của người khác, không có lỗiTest với key hỏi theo thứ tự ngẫu nhiênGhép theo key bằng Map
Batch function sai độ dàiC1Mọi load() của batch đó lỗiMessage lỗi của DataLoaderTrả Error/null cho key thiếu
Await I/O trước load()B2, C4N+1 quay lại dù "có dùng DataLoader"Đếm số batch mỗi requestload() ngay đầu resolver; Promise.all
Batch vượt 65 535 tham sốC5Lỗi "bind message has … parameter formats"Lỗi chỉ xuất hiện với tenant lớnANY($1::type[]) + maxBatchSize
LIMIT theo cha viết saiC6Cha đầu có dữ liệu, cha sau rỗngTest nhiều chaLATERAL / window function
Lazy load trong model (Laravel)Lighthouse B51 câu thay vì 2preventLazyLoading ở dev/testDirective quan hệ, @count, withCount
N+1 trong reference resolver (federation)Bài 5: 20 → 1Gateway gom được, subgraph vẫn hỏi lẻĐếm SQL ở subgraphDataLoader trong __resolveReference

6. Vận hành trong production

7. So sánh và ma trận quyết định

Cách chống N+1Số câu SQLLinh hoạtGắn chặt DBPhân quyềnGhi chú
DataLoader (batch theo tick)Số tầng × số quan hệCao: nguồn nào cũng được (SQL, REST, gRPC)ThấpLàm trong batch fn hoặc ở fieldMặc định cho Node
Eager load theo directive / lookahead (Lighthouse)Như trênTrung bình: chủ yếu cho EloquentTrung bình@can, PolicyMặ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)1Thấp: schema bám sát DBCaoThường dựa vào Row-Level SecurityChư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

9. Anti-patterns và bài học thực tế

  1. Loader global/singleton — lộ dữ liệu, dữ liệu cũ, rò RAM (C3).
  2. Batch function trả rows thẳng từ DB không ghép theo key (C1, C2).
  3. IN ($1…$n) với n không giới hạn (C5).
  4. Check quyền bằng await trước load() (B2) — gặp rất nhiều trong code thật vì nó "trông hợp lý".
  5. LIMIT/OFFSET trong batch cho phân trang theo cha (C6).
  6. 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.
  7. Tin rằng framework lo hết — Lighthouse batch @hasMany như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 preventLazyLoading trong 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.