Một query GraphQL chạy thế nào — parse, validate, execute, và lỗi từng phần

Bài 1 nói "server đi qua cây query, gọi một resolver cho mỗi field". Bài này mở cái hộp đó ra. Thuật toán thực thi được viết thẳng trong spec, nên graphql-js (Node), webonyx/graphql-php (nền của Lighthouse trong Laravel), graphql-java... đều chạy giống nhau. Hiểu nó thì giải thích được những câu hỏi rất thực tế: vì sao một lỗi nhỏ xoá trắng cả màn hình, vì sao query chậm bằng nhánh chậm nhất, vì sao timeout không dừng được câu SQL đang chạy, và vì sao server nào cũng phải cache query đã validate.

Thuật ngữ dùng trong bài
lexer / token (bộ cắt chuỗi query thành từng mẩu có nghĩa: tên, dấu ngoặc, chuỗi...) · AST (Abstract Syntax Tree — cây cú pháp, dạng dữ liệu của query sau khi parse) · document (toàn bộ chuỗi client gửi: các operation và fragment) · validation rule (một luật kiểm tra tĩnh, vd "field phải tồn tại trên type") · executor (phần chạy query: đi cây, gọi resolver, ghép kết quả) · resolver(parent, args, context, info) (hàm lấy dữ liệu cho 1 field: object cha, tham số, dữ liệu chung của request, thông tin vị trí) · default resolver (resolver mặc định: đọc property cùng tên trên object cha) · coercion (ép và kiểm tra kiểu giá trị vào/ra) · non-null (kiểu có dấu !, cấm trả null) · null propagation (null lan truyền — lỗi ở field non-null làm null cả field cha gần nhất cho phép null) · partial response (response có cả data lẫn errors) · error masking (che message lỗi nội bộ trước khi trả cho client) · document cache (cache bản query đã parse + validate) · incremental delivery (@defer/@stream — trả phần nhanh trước, phần chậm gửi sau) · schema-first / code-first (viết SDL trước rồi gắn resolver / viết code rồi sinh ra schema).
Kiểm chứng: tài liệu qua Context7: GraphQL spec (Section 5 Validation, 6 Execution, 7 Response), graphql-js 17 (validate(), upgrade guide v16→v17, defer-stream), webonyx/graphql-php (data fetching, Deferred). Source đọc trực tiếp: node_modules/graphql/diagnostics.d.ts (17.0.2). Mọi output là output thật: graphql-js 17.0.2 (so sánh với 16.14.2) trên Node 22.23.3, container node:22-bookworm-slim, máy 4 vCPU dùng chung — số đo thời gian nhiễu, chỉ xem tỉ lệ. Lab: labs/graphql/node/02-execution.mjs.

Trả lời 30 giây

Server làm 3 bước. Parse chuỗi thành AST — rẻ. Validate AST với schema bằng các luật tĩnh (graphql-js 17 có 32 luật) — query sai bị chặn ở đây, chưa resolver nào chạy. Execute: đi cây từ gốc, mỗi field gọi resolver(parent, args, context, info), kết quả của cha thành parent của con. Với query, các field anh em chạy xen nhau (Node một thread nhưng chờ I/O song song) nên tổng thời gian bằng nhánh chậm nhất; với mutation, field gốc chạy nối tiếp. Lỗi ở một field thành null tại đó cộng một phần tử trong errors kèm path; nếu field đó non-null thì null "nổi" lên cha gần nhất cho phép null. Hệ quả thực tế: cache document đã validate, thiết kế nullability có chủ đích, che message lỗi, và đặt timeout ở tầng I/O — vì abort ở executor không dừng được câu SQL đang chạy.

1. Bài toán gốc và bối cảnh ra đời

Khi Facebook mở mã nguồn GraphQL năm 2015, họ không chỉ mở một thư viện mà mở một spec kèm một bản cài đặt tham chiếu là graphql-js. Lý do: muốn nhiều ngôn ngữ cùng cài đặt GraphQL mà client không cần biết server viết bằng gì. Vậy thì thứ tự chạy, cách báo lỗi, cách xử lý null phải được định nghĩa chặt như một thuật toán — nếu không, cùng một query sẽ cho kết quả khác nhau ở Node và PHP.

Việc tách validate khỏi execute giải hai bài toán: (1) query sai bị chặn trước khi chạm vào dữ liệu — không có chuyện ghi được nửa chừng rồi mới phát hiện field không tồn tại; (2) kết quả validate chỉ phụ thuộc vào (schema, chuỗi query) nên cache được — query lặp lại không phải validate lại.

2. Mô hình tư duy cốt lõi

Query là một cây. Execute là đi cây theo chiều sâu: mỗi field gọi một resolver, giá trị trả về trở thành parent của các field con. Lỗi là một giá trị gắn vào một vị trí (path) trong cây.

Từ đó suy ra: thời gian = nhánh dài nhất; số lần gọi resolver = số cặp (object × field) trong response chứ không phải độ dài query; một lỗi chỉ ảnh hưởng nhánh của nó — trừ khi kiểu non-null bắt nó lan lên.

Ba bước parse, validate, execute và ba loại request Query hợp lệ đi qua parse, validate, execute rồi ra response. Query có field sai tên bị chặn ở validate, trả 2 lỗi, chưa resolver nào chạy. Code gọi thẳng hàm execute bỏ qua validate: resolver vẫn chạy và lỗi kiểu chỉ lộ ra lúc chạy. 3 bước của một request — query sai bị chặn trước khi chạm vào dữ liệu request parse chuỗi → AST validate 32 luật · tĩnh execute gọi resolver response code tự gọi execute(), không gọi validate() ✕ { user { emial } } → 2 lỗi validate, HTTP 400 số resolver đã chạy: 0 bỏ qua validate → resolver vẫn chạy (1 lần), field sai tên bị lờ đi, lỗi kiểu tham số chỉ lộ ra lúc chạy — và mọi luật giới hạn độ sâu/chi phí cũng bị bỏ qua
Hình 1 — Xanh lá: query hợp lệ đi hết 3 bước. Vàng → đỏ: query sai bị chặn ở validate, chưa đụng dữ liệu. Xanh dương: code tự gọi execute() mà không gọi validate() — đường vòng nguy hiểm, vì các luật bảo vệ (độ sâu, chi phí ở bài 4) đều nằm trong bước validate.

3. Internals và quyết định thiết kế

3.1 Parse: chuỗi → token → AST

Lexer cắt chuỗi thành token, parser dựng cây DocumentNode. Bước này rẻ và tuyến tính theo độ dài chuỗi. graphql-js 16+ có tuỳ chọn maxTokens để từ chối document quá lớn ngay từ đây — tuyến phòng thủ đầu tiên (bài 4).

parse → kind: Document | số definition: 1 | tokenCount: 17

3.2 Validate: luật tĩnh, chạy trước khi đụng dữ liệu

validate(schema, document) chạy một danh sách luật (specifiedRules) trong một lần đi cây. Số luật đo được: 32 ở graphql-js 17.0.2, 27 ở 16.14.2. Bản 17 thêm các luật cho @defer/@stream và MaxIntrospectionDepthRule (chặn introspection lồng quá sâu). Vài luật tiêu biểu: FieldsOnCorrectTypeRule (field phải tồn tại), ValuesOfCorrectTypeRule (giá trị đúng kiểu), NoFragmentCyclesRule (fragment không được tự gọi vòng), OverlappingFieldsCanBeMergedRule (hai field cùng tên trong response phải gộp được — luật này đắt, xem bài 4).

validate query sai → 2 lỗi, số resolver đã chạy = 0
  - Cannot query field "emial" on type "User". [{"line":1,"column":22}]
  - Int cannot represent non-integer value: "ba" [{"line":1,"column":41}]
execute() thẳng query sai (không validate) → resolver đã chạy = 1
   {"errors":[{"message":"Argument \"User.posts(first:)\" has invalid value: Int cannot represent non-integer value: \"ba\"",
    "locations":[{"line":1,"column":41}],"path":["user","posts"]}],"data":{"user":null}}

Bẫy: hàm tiện graphql() làm đủ 3 bước, nhưng nhiều server tự viết (hoặc plugin tự chế) gọi parse rồi execute và quên validate. Kết quả ở trên: field sai tên emial bị lờ đi im lặng, lỗi kiểu chỉ lộ ra khi resolver đã chạy, và — tệ nhất — mọi luật giới hạn độ sâu/chi phí bạn cài thêm đều không chạy vì chúng là validation rule.

3.3 Execute: đi cây, gọi resolver

Theo spec (Section 6): ExecuteSelectionSet → CollectFields (gộp các field cùng tên trong response, áp @skip/@include, mở fragment) → ExecuteField → gọi resolver → CompleteValue: nếu là scalar thì serialize, nếu là list thì xử lý từng phần tử, nếu là object thì đệ quy với selection set con, nếu là interface/union thì hỏi resolveType trước.

// Chữ ký resolver — giống nhau ở mọi cài đặt
resolve(parent, args, context, info)
//  parent  : giá trị field cha trả về (với field gốc: rootValue)
//  args    : tham số đã được coerce theo kiểu trong schema
//  context : object dùng chung cho cả request — user đăng nhập, DataLoader, request id
//  info    : path, kiểu trả về, AST của field... (dùng để "nhìn trước" field con)
// Không khai báo resolver → default resolver: đọc parent[fieldName] (là hàm thì gọi hàm)

Lab B có 2 user; posts của user 1 mất 20ms, của user 2 mất 60ms (giả lập I/O). Đây là thứ tự thật:

  16ms Query.users        bắt đầu
  56ms Query.users        xong
  58ms User(1).posts     bắt đầu
  66ms User(2).posts     bắt đầu
  90ms User(1).posts     xong
  90ms Post(11).title     (đồng bộ)
  91ms Post(12).title     (đồng bộ)
 131ms User(2).posts     xong
 131ms Post(21).title     (đồng bộ)
 132ms response xong — tổng thời gian ≈ nhánh dài nhất (30 + 60), không phải tổng các nhánh
Dòng thời gian gọi resolver của một query lồng nhau Query.users chạy từ 16 đến 56ms. Ngay sau đó posts của user 1 và user 2 bắt đầu gần như cùng lúc. Posts của user 1 xong ở 90ms và title của các post của user 1 được resolve ngay, không chờ user 2. Posts của user 2 xong ở 131ms, rồi title của nó. Response xong ở 132ms. Không có "rào chắn theo tầng": nhánh nào xong trước thì đi tiếp trước 0 50 100 140 ms Query.users User(1).posts User(2).posts Post.title post 11, 12 (90ms) post 21 response xong 132ms
Hình 2 — Số liệu từ lab 02 mục B. Hai lời gọi posts bắt đầu gần như cùng lúc (song song về I/O dù JS chỉ có một thread). Title của user 1 được resolve ngay ở 90ms, không chờ nhánh user 2. Tổng ≈ nhánh dài nhất. Đây cũng là lý do DataLoader phải gom key "trong cùng một tick" — chi tiết ở bài 3.

Khác biệt giữa các cài đặt: graphql-js không có "rào chắn theo tầng" — con chạy ngay khi cha của nó xong. webonyx/graphql-php (nền của Lighthouse) chạy đồng bộ, không có event loop; nó gom các Deferred lại và xử lý theo hàng đợi — bài 3 cho thấy điều đó ảnh hưởng thế nào tới batching.

3.4 Query song song, mutation nối tiếp

Hai field gốc, mỗi field ngủ 100ms:

query { a b }      → 103ms (nhanh nhất / 5 lần)
mutation { a b }   → 202ms (nhanh nhất / 5 lần)

Spec cho phép field của query chạy theo bất kỳ thứ tự nào, kể cả song song, vì chúng "không có side effect". Field gốc của mutation phải chạy đúng thứ tự viết. Nối tiếp nhưng không có transaction — lab ở bài 1, mục 4: field 1 ghi xong, field 2 lỗi, field 1 không được rollback.

3.5 Null lan truyền: một lỗi nhỏ có thể xoá trắng cả khối

Ba schema chỉ khác nhau ở dấu !. Post số 2 có resolver title ném lỗi:

posts: [PostA]   title: String   → data={"a":{"posts":[{"id":"1","title":"ok 1"},{"id":"2","title":null},{"id":"3","title":"ok 3"}]}}  errors[0].path=["a","posts",1,"title"]
posts: [PostB]   title: String!  → data={"b":{"posts":[{"id":"1","title":"ok 1"},null,{"id":"3","title":"ok 3"}]}}  errors[0].path=["b","posts",1,"title"]
posts: [PostC!]! title: String!  → data={"c":null}  errors[0].path=["c","posts",1,"title"]
Null lan truyền lên cha gần nhất cho phép null Ba cây giống nhau, lỗi ở title của post 2. Cây a: title cho phép null nên chỉ title thành null. Cây b: title non-null nhưng phần tử list cho phép null nên post 2 thành null. Cây c: mọi thứ non-null nên null nổi qua post 2, qua posts, tới field gốc c, mất cả 3 post. a) title: String a posts post 1 post 2 post 3 ok 1 lỗi ok 3 null mất: 1 title [ok 1, {title: null}, ok 3] b) [PostB] · title: String! b posts post 1 post 2 post 3 ok 1 lỗi ok 3 null mất: cả post 2 [ok 1, null, ok 3] c) [PostC!]! · title: String! c posts post 1 post 2 post 3 ok 1 lỗi ok 3 c: null mất: cả 3 post { "c": null }
Hình 3 — Vòng đỏ là "null đang tìm chỗ đậu". Nó nổi lên cho tới khi gặp một vị trí cho phép null. Ở (c) không có chỗ nào cho phép null nên mất cả field gốc — một post lỗi xoá trắng cả feed. Nếu field gốc cũng non-null thì data thành null toàn bộ (bài 5 gặp đúng chuyện này khi một subgraph chết).

Quyết định thiết kế đằng sau: GraphQL để field mặc định nullable chính là để lỗi bị chặn ở chỗ nhỏ nhất. Dấu ! là một lời hứa "field này không bao giờ lỗi". Quy tắc thực tế:

3.6 Định dạng lỗi và chuyện che message

{"errors":[{"message":"SQLSTATE[42P01]: relation \"users_v2\" does not exist; host=10.0.3.17 user=app_rw",
  "locations":[{"line":1,"column":3}],"path":["me"]}],"data":{"me":null}}

graphql-js trả nguyên văn message của exception: tên bảng, IP nội bộ, user DB đều lộ. Việc che lỗi là của framework bọc ngoài, và mỗi framework làm khác nhau — lab ở bài 5 đo được: Apollo Server 5 vẫn trả nguyên message kể cả khi NODE_ENV=production (chỉ bỏ stacktrace), còn Lighthouse trả "Internal server error" và chỉ đưa message thật vào debugMessage khi APP_DEBUG=true.

Lỗi nghiệp vụ nên là dữ liệu, không phải exception. "Hết hàng", "thẻ bị từ chối" là kết quả bình thường mà client phải xử lý có kiểu, không nên lẫn với lỗi hệ thống trong mảng errors:

union PlaceOrderResult = Order | OutOfStock | PaymentDeclined
type OutOfStock { productId: ID! available: Int! }
type PaymentDeclined { reason: String! }
type Mutation { placeOrder(input: PlaceOrderInput!): PlaceOrderResult! }

mutation { placeOrder(input: { … }) {
  __typename
  ... on Order { id total }
  ... on OutOfStock { productId available }
  ... on PaymentDeclined { reason }
} }

Đánh đổi: schema dài hơn, client phải xử lý từng nhánh. Đổi lại: lỗi nghiệp vụ có kiểu, được codegen, không làm bẩn metric lỗi hệ thống.

3.7 context và info

context được tạo mới cho mỗi request: user đã xác thực, DataLoader, request id. Đặt DataLoader ở đâu khác ngoài context là mở cửa cho lỗi lộ dữ liệu giữa các request (bài 3 có lab). info cho biết vị trí và AST của field — dùng để "nhìn trước" field con (lookahead), vd chỉ SELECT những cột được hỏi hoặc eager load đúng quan hệ cần. Lighthouse làm việc này qua directive. Tự viết lookahead thì dễ gãy với fragment, alias, @include.

3.8 Chi phí theo field: vì sao list lớn chậm

executor + stringify: 84.6ms | map tay + stringify: 7.1ms | gấp 11.9 lần | 1508920 byte | 100 000 lần gọi resolver mặc định

10 000 object × 10 field = 100 000 lần gọi resolver (kể cả resolver mặc định chỉ đọc property), cộng với coerce và dựng kết quả. Chi phí thực thi tỉ lệ với số cặp (object × field) trong response. Trên Node, 85ms CPU cho một request nghĩa là một core chỉ phục vụ được ~12 request như vậy mỗi giây và event loop bị chặn suốt thời gian đó (xem bài chặn event loop). Cách chữa: phân trang thật (giới hạn first), không trả list hàng nghìn phần tử qua GraphQL, hoặc dùng executor biên dịch sẵn như graphql-jit (chưa đo trong lab này).

3.9 Parse + validate cũng tốn — nên cache document

Với một query nhỏ, trung bình 5 000 lần, đo cả graphql-js 17.0.2 và 16.14.2:

# graphql 17.0.2 (lab 02, mục H)
vòng 2, trung bình 5000 lần → parse    7µs | validate  329µs | execute   32µs
vòng 3, trung bình 5000 lần → parse    4µs | validate  381µs | execute   69µs
# graphql 16.14.2 (cùng query, cùng máy)
round 1: parse 3µs validate 183µs execute 23µs
round 3: parse 8µs validate 198µs execute 14µs

Validate đắt gấp ~5–10 lần execute với query nhỏ — ở cả hai bản. Vì kết quả validate chỉ phụ thuộc (schema, chuỗi query), server nghiêm túc đều giữ một LRU cache document đã parse + validate theo chuỗi query. Điều kiện để cache trúng: chuỗi query phải giống hệt nhau giữa các lần gọi — lý do phải dùng biến thay vì nối giá trị vào chuỗi, và lý do persisted query (bài 5) càng có lợi.

3.10 @defer: trả phần nhanh trước

 304ms không defer → 1 response: {"data":{"product":{"name":"Áo thun","reviews":["đẹp","vừa size"]}}}
   5ms defer → initialResult: {"data":{"product":{"name":"Áo thun"}},"pending":[{"id":"0","path":["product"],"label":"rv"}],"hasNext":true}
 324ms defer → subsequent:   {"hasNext":false,"incremental":[{"id":"0","data":{"reviews":["đẹp","vừa size"]}}],"completed":[{"id":"0"}]}
sequenceDiagram
  participant C as Client
  participant S as Server (graphql-js 17)
  C->>S: query product name, fragment reviews có @defer
  S-->>C: initialResult (5ms): name + pending + hasNext true
  Note over S: resolver reviews chạy ~300ms
  S-->>C: subsequent (324ms): reviews + completed + hasNext false

Trạng thái: graphql-js 17 có @defer/@stream qua hàm riêng experimentalExecuteIncrementally(); hàm execute() không hỗ trợ. Docs graphql-js ghi rõ incremental delivery "pending GraphQL specification work" — chưa vào spec chính thức. Muốn dùng phải bật directive trong schema, client phải hiểu định dạng nhiều phần, và tầng HTTP phải stream được — proxy không được gom response lại: bài SSR/RSC đã đo nginx bật gzip gom cả stream thành 1 chunk (thủ phạm là gzip, không phải buffering).

3.11 Timeout: abort executor không dừng I/O đang chạy

  54ms execute() ném lỗi: AbortedGraphQLExecutionError - The operation was aborted due to timeout
 509ms   (resolver slow vẫn chạy tới cuối dù đã abort)

graphql-js 17 nhận abortSignal (vd AbortSignal.timeout(50)): sau 50ms execute() ném AbortedGraphQLExecutionError và client có câu trả lời. Nhưng resolver đang chờ I/O vẫn chạy tới 500ms — nếu đó là một câu SQL nặng thì DB vẫn gánh. Timeout đúng phải có ở 3 tầng: proxy/HTTP server, executor, và chính nguồn dữ liệu (statement_timeout của PostgreSQL, timeout của HTTP client gọi service khác, truyền signal xuống driver nếu driver hỗ trợ).

3.12 Quan sát được từng resolver (graphql-js 17)

graphql-js 17 phát sự kiện trên node:diagnostics_channel với các kênh graphql:parse, graphql:validate, graphql:execute, graphql:resolve... APM chỉ cần subscribe, không phải bọc schema. Lab mục K nghe kênh graphql:resolve:

   users                  Query.users  async 35.1ms
   users.0.name           User.name    đồng bộ, resolver mặc định
   users.0.posts          User.posts   async 23.8ms
   users.1.posts          User.posts   async 63.8ms
   users.0.posts.0.title  Post.title   đồng bộ

Mỗi sự kiện có fieldPath, kiểu cha, có phải resolver mặc định không — đủ để vẽ flame graph theo field. Lưu ý chi phí: trace mọi resolver của một list 100 000 phần tử sẽ tốn đáng kể; production thường chỉ trace resolver không phải mặc định, hoặc lấy mẫu.

3.13 Schema-first hay code-first

Schema-first (SDL trước)Code-first (code sinh schema)
Ví dụLighthouse (SDL + directive), Apollo Server + resolver mapPothos, Nexus, TypeGraphQL; rebing/graphql-laravel
Mạnh ởHợp đồng đọc được, review dễ, frontend tham gia thiết kếType của ngôn ngữ và schema không lệch nhau; refactor an toàn
Yếu ởResolver và SDL có thể lệch (cần codegen/test)Phải đọc code mới thấy schema; review thay đổi API khó hơn

4. Đảm bảo và giới hạn

5. Failure modes

Hỏng thế nàoLab tái hiệnPhát hiệnChữa
1 field lỗi xoá trắng cả khối (non-null dùng bừa)Mục 3.5: {"c":null}Tỉ lệ data null theo operationNullable cho field gọi nguồn khác
Message lỗi lộ hạ tầngMục 3.6: lộ bảng, IP, user DBReview response lỗi, test bảo mậtError masking + mã lỗi trong extensions.code
Bỏ qua validateMục 3.2: resolver chạy với query saiTest: query sai phải trả 0 lần gọi resolverDùng hàm/server chuẩn; không tự ghép parse + execute
Resolver chậm kéo cả responseMục 3.3: tổng = nhánh dài nhấtTrace theo field (3.12)Timeout từng nguồn, @defer, field nullable
Timeout nhưng DB vẫn chạyMục 3.11: abort 54ms, resolver chạy tới 509msSố query đang chạy ở DB tăng sau khi client đã nhận lỗistatement_timeout, truyền signal xuống driver
List lớn ăn CPU, chặn event loopMục 3.8: 85ms CPU / requestEvent loop delay, CPU theo operationPhân trang thật, giới hạn first

6. Vận hành trong production

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

Cài đặtNgôn ngữMô hình chạyGhi chú
graphql-jsJS/TSPromise, không rào chắn theo tầngBản tham chiếu; nền của Apollo, Yoga, Mercurius
webonyx/graphql-phpPHPĐồng bộ + hàng đợi Deferred (SyncPromise)Port của graphql-js; nền của Lighthouse
graphql-jitJSBiên dịch query thành hàm JSNhanh hơn cho list lớn; ít tính năng hơn (chưa đo)
graphql-java, Hot Chocolate (.NET), async-graphql (Rust)—Thread pool / async runtimeCùng spec, khác chi tiết hiệu năng (chưa đo)

Schema-first khi API là hợp đồng giữa nhiều team và cần review thiết kế trước; code-first khi một team TypeScript muốn type an toàn từ đầu tới cuối. Với Laravel, Lighthouse (schema-first) là lựa chọn phổ biến nhất; rebing/graphql-laravel là code-first.

8. Góc nhìn tech lead: tác động tổ chức

Những quy ước nên viết thành ADR ngay từ đầu, vì sửa sau rất đắt (mỗi cái là breaking change):

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

  1. Non-null ở khắp nơi "cho gọn kiểu" → một lỗi nhỏ xoá trắng màn hình (mục 3.5).
  2. Ném exception cho lỗi nghiệp vụ → client parse message tiếng Anh để biết "hết hàng"; metric lỗi hệ thống bị bẩn.
  3. Trả message thô của exception → lộ hạ tầng (mục 3.6).
  4. Client bỏ qua errors khi có data → hiển thị dữ liệu thiếu mà không ai biết.
  5. Nối giá trị vào chuỗi query thay vì dùng biến → cache document không bao giờ trúng, mở đường cho injection ở client.
  6. Logic nghiệp vụ trong resolver → không tái dùng được ở REST, job, CLI.
  7. Tự ghép parse + execute trong middleware tự chế → mất validate (mục 3.2).

10. Câu hỏi phụ interviewer hay hỏi tiếp

Field của query chạy song song thật không?
Trên Node là "song song về I/O": JS một thread, nhưng các lời gọi I/O được phát ra cùng lúc (lab: 2 lời gọi posts bắt đầu ở 58ms và 66ms). Spec chỉ nói "có thể chạy theo thứ tự bất kỳ".
Làm sao một field biết dữ liệu của cha?
Qua tham số đầu tiên parent — giá trị field cha đã trả về.
Lỗi ở field non-null thì sao?
Null lan lên cha gần nhất cho phép null; errors vẫn chỉ ra đúng path gốc của lỗi.
Có nên dùng info để tối ưu SQL không?
Được, nhưng dễ gãy với fragment, alias, directive. Ưu tiên DataLoader (bài 3); dùng lookahead qua thư viện có sẵn (Lighthouse directive) hơn là tự viết.
Vì sao server cache query đã validate?
Vì validate đắt hơn execute với query nhỏ (lab: ~330µs so với ~30–70µs) và kết quả chỉ phụ thuộc (schema, chuỗi query).
Timeout cho GraphQL đặt ở đâu?
Ba tầng: HTTP/proxy, executor (abortSignal), và nguồn dữ liệu (statement_timeout). Chỉ abort executor thì DB vẫn chạy (lab: 54ms so với 509ms).
@defer có vào spec chưa?
Chưa vào spec chính thức (docs graphql-js 17 ghi "pending specification work"); graphql-js 17 hỗ trợ qua hàm thử nghiệm.

Phụ lục: lab đã chạy

Môi trường: graphql-js 17.0.2 (và 16.14.2 để so sánh mục 3.9), Node 22.23.3 trong node:22-bookworm-slim, máy 4 vCPU dùng chung (load 5–9 lúc đo). Output đầy đủ: labs/graphql/out/02-execution.txt.

cd labs/graphql/node
./run.sh 02-execution.mjs     # các mục A–K trong bài

./run.sh bench-validate.mjs   # mục 3.9 với graphql 17

# cùng script với graphql 16 (thư mục tạm, không đụng lab)
mkdir -p /tmp/g16 && cp bench-validate.mjs /tmp/g16/ && cd /tmp/g16 && echo '{"type":"module"}' > package.json
docker run --rm -v "$PWD":/w -w /w node:22-bookworm-slim sh -c 'npm i graphql@16 >/dev/null && node bench-validate.mjs'

Chưa verify: hiệu năng graphql-jit, graphql-java, Hot Chocolate; định dạng @defer qua HTTP thật (multipart) với Apollo Client — lab chỉ gọi hàm trong process.