Vận hành GraphQL production — HTTP và cache, giám sát, tiến hoá schema, federation nhiều team
JD ghi "kinh nghiệm GraphQL" thường có nghĩa là công ty đã chạy GraphQL production một thời gian, với nhiều team và nhiều phiên bản app. Câu hỏi khi đó không còn là "viết resolver thế nào" mà là: dashboard có thấy lỗi không, cache ở đâu, sửa schema mà không làm vỡ app cũ thế nào, và khi 6 team cùng góp vào một schema thì ai sở hữu cái gì. Bài cuối của series đo những chuyện đó trên Apollo Server 5 và Apollo Federation 2, cộng với công cụ so sánh schema của graphql-js 17.
Thuật ngữ dùng trong bài
GraphQL over HTTP (spec riêng mô tả cách gửi GraphQL qua HTTP: method, content type, status code) · APQ (Automatic Persisted Queries — client gửi hash của query; server chưa có thì client gửi lại cả query để server nhớ) · trusted documents (danh sách query đăng ký lúc build — allowlist) · CDN (mạng máy chủ cache đặt gần người dùng) ·@cacheControl(directive gợi ý thời gian cache cho từng field; server gộp lại thành headerCache-Control) · normalized cache (cache phía client lưu từng object theo__typename+id) · schema registry (nơi lưu các phiên bản schema và thống kê field nào còn được dùng) · breaking / dangerous / safe change (chắc chắn làm vỡ client cũ / có thể làm client chạy sai / an toàn) · expand → migrate → contract (thêm cái mới, chuyển client sang, rồi mới xoá cái cũ) · federation (nhiều service, mỗi service sở hữu một phần schema, ghép thành một đồ thị) · subgraph / supergraph (schema của một service / schema tổng sau khi ghép) · router (gateway) (server đứng trước, nhận query, chia việc cho các subgraph) · entity /@key(type có khoá, nhiều subgraph cùng góp field cho nó) · reference resolver (__resolveReference— hàm của subgraph biến{ __typename, id }thành object đầy đủ) · query plan (kế hoạch router lập: gọi subgraph nào, theo thứ tự nào) · composition (bước ghép các subgraph thành supergraph và kiểm tra xung đột) · schema stitching (cách cũ: gateway tự ghép schema của nhiều server).
Kiểm chứng: tài liệu qua Context7: Apollo Server docs (APQ, gateway setup,IntrospectAndCompose), graphql.org (serving over HTTP, debug errors, FAQ versioning), graphql-js 17 (schema evolution:findBreakingChanges,findSchemaChanges; upgrade guide v16→v17), Hive federation-composition (README). Đọc trực tiếp: GitHub Docs "Breaking changes" của GraphQL API; blog Apollo "Federation 2 is now generally available" (04/2022); Netflix Tech Blog "How Netflix Scales its API with GraphQL Federation" (2020); license qua GitHub API vànpm viewngày 01/10/2026. Lab chạy thật: graphql-js 17.0.2 (so sánh schema); Apollo Server 5.5.1,@apollo/subgraph2.15.1,@apollo/gateway2.14.4, graphql 16.14.2 trên Node 22.23.3. Code:labs/graphql/node/05-schema-evolution.mjs,labs/graphql/federation/.
Trả lời 30 giây
Vận hành GraphQL khác REST ở bốn chỗ. Giám sát: URL luôn là/graphqlvà lỗi resolver trả HTTP 200, nên metric phải tính theooperationNamevà đếm mảngerrors— lab của tôi có operation lỗi 100% mà không có một HTTP 5xx nào. Cache: POST không cache được ở CDN, nên dùng GET + persisted query (hash ngắn) + headerCache-Controlsinh từ@cacheControl; nhớ là một field không có gợi ý cache sẽ biến cả response thànhno-store, và cache APQ phải dùng chung giữa các instance. Tiến hoá schema không cần version: thêm field mới,@deprecatedfield cũ, đo xem còn ai dùng, chỉ xoá khi về 0; chặn breaking change trong CI bằng so sánh schema. Nhiều team: federation — mỗi team một subgraph, router lập query plan và gọi_entitiesđể ghép. Cái giá: thêm lượt mạng nối tiếp, N+1 dời vào reference resolver, một subgraph chết làmdatathànhnullnếu field ở ranh giới là non-null, và rủi ro license — Apollo Router dùng Elastic License 2.0, có router thay thế MIT/Apache.
1. Bài toán gốc và bối cảnh ra đời
Khi GraphQL rời giai đoạn thử, bốn vấn đề vận hành xuất hiện gần như theo cùng một thứ tự ở mọi công ty:
- Không thấy lỗi. Mọi request là
POST /graphql 200. Dashboard HTTP xanh trong khi người dùng thấy màn hình lỗi. - Mất cache HTTP. Hình dạng response do client quyết, request là POST — CDN không giúp được gì.
- Sửa schema sợ vỡ app cũ. GraphQL chọn hướng không version: graphql.org viết GraphQL "encourages a versionless approach to API development by providing tools for continuous schema evolution". Nhưng "không version" chỉ an toàn khi có quy trình và công cụ.
- Một schema, nhiều team. Schema nguyên khối trở thành nút thắt: mọi team sửa cùng một repo, cùng một lịch deploy. Cách đầu tiên là schema stitching (gateway tự ghép schema của nhiều server). Apollo đưa ra Federation năm 2019 và Federation 2 GA tháng 4/2022. Netflix mô tả cách họ chuyển sang federation năm 2020: bắt đầu bằng việc coi cả monolith là một subgraph, rồi tách dần từng domain.
- Các công cụ trong bài tối ưu cho: nhiều client, nhiều phiên bản client, nhiều team cùng góp vào một đồ thị.
- Không tối ưu cho: team nhỏ với một client — ở đó federation, schema registry, persisted query là chi phí thuần.
2. Mô hình tư duy cốt lõi
- Schema là một sản phẩm có người dùng. Mỗi field là một lời hứa với mọi client đang chạy ngoài kia, kể cả app bản cũ không ai cập nhật. Tiến hoá bằng cộng thêm; chỉ xoá khi có dữ liệu chứng minh không còn ai dùng.
- Federation là một đồ thị có nhiều chủ. Mỗi subgraph sở hữu một phần type và field. Entity (type có
@key) là chỗ nối giữa các subgraph. Router biến một query thành một kế hoạch nhiều lượt gọi — và mọi chi phí, mọi lỗi của federation đều nằm trong kế hoạch đó.
3. Internals và quyết định thiết kế
3.1 HTTP: mã trả về phụ thuộc vào lỗi xảy ra ở bước nào
# Apollo Server 5.5.1 (lab 06, mục A)
cú pháp sai "{ ok " → HTTP 400 | "Syntax Error: Expected Name, found <EOF>." code GRAPHQL_PARSE_FAILED
field không tồn tại "{ nope }" → HTTP 400 | "Cannot query field \"nope\" on type \"Query\"." code GRAPHQL_VALIDATION_FAILED
resolver ném lỗi "{ boom }" → HTTP 200 | errors[0].code INTERNAL_SERVER_ERROR, data.boom = null
một phần lỗi "{ ok boom }" → HTTP 200 | data.ok = "ok", data.boom = null
accept: application/graphql-response+json, "{ nope }" → HTTP 400
Quy luật: lỗi trước khi chạy (parse, validate) → 4xx; lỗi trong khi chạy → 200 kèm errors. graphql.org giải thích lý do:
response có data khác null là một response GraphQL hợp lệ, nên vẫn là 2xx. Media type mới application/graphql-response+json
(spec GraphQL over HTTP) cho phép server dùng status khác 2xx an toàn hơn, vì proxy không bao giờ tự sinh ra media type đó. Hệ quả vận hành ở mục
3.2. Batching mảng và CSRF đã đo ở bài 4.
3.2 Giám sát theo operation, không theo URL
Một plugin Apollo Server ghi một dòng metric cho mỗi request:
const metricsPlugin = {
async requestDidStart() {
const t0 = performance.now();
let errors = [];
return {
async didEncounterErrors(ctx) { errors = ctx.errors.map((e) => e.extensions?.code ?? 'UNKNOWN'); },
async willSendResponse(ctx) {
emit({ operation: ctx.operationName ?? '(không tên)', http: ctx.response.http.status ?? 200,
errorCodes: errors, ms: Math.round(performance.now() - t0) });
},
};
},
};
=== A. Metric theo operationName: HTTP luôn 200, lỗi chỉ thấy khi đọc errors[] ===
{"operation":"ProductPage","http":200,"errorCodes":[],"ms":14}
{"operation":"Checkout","http":200,"errorCodes":["UNKNOWN"],"ms":8}
{"operation":"Checkout","http":200,"errorCodes":["UNKNOWN"],"ms":3}
{"operation":"(không tên)","http":200,"errorCodes":[],"ms":2}
tổng hợp: {"ProductPage":{"requests":1,"http5xx":0,"withErrors":0},"Checkout":{"requests":2,"http5xx":0,"withErrors":2},…}
Checkout lỗi 2/2 lần, http5xx = 0. Một dashboard dựng từ log nginx sẽ báo "0% lỗi". Hai chi tiết thực tế lab lộ ra:
"UNKNOWN": ở hookdidEncounterErrors, lỗi do resolver ném ra chưa cóextensions.code— mãINTERNAL_SERVER_ERRORchỉ được gắn lúc định dạng response. Metric phải tự phân loại, hoặc tốt hơn: code nghiệp vụ luôn némGraphQLErrorcó mã rõ ràng."(không tên)": query không đặt tên thì mọi metric dồn vào một nhóm. Bắt buộcoperationName(có thể từ chối query không tên ở production).
Thêm vào đó: thời gian theo field (graphql-js 17 phát sự kiện trên node:diagnostics_channel — bài 2, mục
3.12), và tên + phiên bản client (gửi qua header, vd quy ước apollographql-client-name/-version của Apollo) — để biết lỗi
đến từ app bản nào.
3.3 Cache: ba tầng, mỗi tầng một cái bẫy
Tầng HTTP / CDN: GET + @cacheControl
type Query {
product(id: ID!): Product @cacheControl(maxAge: 60)
me: User @cacheControl(maxAge: 30, scope: PRIVATE)
flaky: String # không có gợi ý cache
}
# lab 06b, mục B
GET { product } → HTTP 200 | cache-control: max-age=60, public
POST { product } → HTTP 200 | cache-control: max-age=60, public
GET { product me } (PRIVATE) → HTTP 200 | cache-control: max-age=30, private
GET { product flaky } → HTTP 200 | cache-control: no-store
Server tính chính sách cho cả response = chặt nhất trong mọi field: maxAge nhỏ nhất, có một field PRIVATE thì cả
response private, có một field không gợi ý thì no-store. Bẫy nổ trễ: ai đó thêm một field "nhỏ" không có
@cacheControl vào query của trang chủ → tỉ lệ trúng cache CDN rơi về 0 mà không có lỗi nào. Và dù POST cũng nhận header, CDN thường chỉ
cache GET — nên muốn cache CDN phải gửi query bằng GET, và để URL không quá dài thì dùng persisted query.
APQ: hash thay cho chuỗi query
# lab 06, mục F–G
1) GET chỉ có hash → HTTP 200 | PersistedQueryNotFound (code PERSISTED_QUERY_NOT_FOUND)
2) GET hash + query (đăng ký) → HTTP 200 | {"data":{"ok":"ok"}}
3) GET chỉ có hash (lần sau) → HTTP 200 | {"data":{"ok":"ok"}}
hash sai với query → HTTP 400 | "provided sha does not match query"
đăng ký trên instance A → HTTP 200 | {"data":{"ok":"ok"}}
hash-only tới instance B (sau load balancer) → HTTP 200 | PersistedQueryNotFound
Nhắc lại từ bài 4: APQ không phải allowlist — lab đăng ký được một query lạ bất kỳ. Nếu cần chặn query lạ, dùng trusted documents (danh sách đăng ký lúc build). Cả hai cùng cho URL ngắn và cache được; chỉ trusted documents cho bảo mật.
Tầng server và tầng client
- Server: DataLoader là cache trong một request (bài 3). Cache dài hạn theo object (Redis) đặt ở tầng service, có khoá theo quyền
người xem — không đặt trong DataLoader. Lighthouse có directive
@cache(thấy trong configcache_directive_tags; chưa dựng lab). - Client: Apollo Client, Relay lưu từng object theo
__typename+id(normalized cache). Hệ quả với thiết kế API: mọi type nên cóidổn định, và mutation nên trả về object đã đổi (kèmidvà field vừa đổi) để cache tự cập nhật — không thì màn hình hiện dữ liệu cũ. So với cache theo key của TanStack Query ở bài state & data fetching.
3.4 Tiến hoá schema không cần version
graphql-js có sẵn công cụ so sánh hai schema. Lab 05 dựng v1 và v2 với những thay đổi hay gặp:
=== findBreakingChanges(v1, v2) — client cũ CHẮC CHẮN vỡ ===
REQUIRED_ARG_ADDED A required argument Query.product(locale:) was added.
ARG_CHANGED_KIND Argument Query.search(term:) has changed type from String to String!.
FIELD_REMOVED Field Product.name was removed.
FIELD_CHANGED_KIND Field Product.price changed type from Int! to Int.
REQUIRED_INPUT_FIELD_ADDED A required field ReviewInput.body was added.
=== findDangerousChanges(v1, v2) — client cũ CÓ THỂ chạy sai ===
ARG_DEFAULT_VALUE_CHANGE Query.search(first:) has changed defaultValue from 10 to 20.
OPTIONAL_ARG_ADDED An optional argument Query.search(sort:) was added.
VALUE_ADDED_TO_ENUM Enum value OrderStatus.REFUNDED was added.
TYPE_ADDED_TO_UNION Brand was added to union type SearchHit.
OPTIONAL_INPUT_FIELD_ADDED An optional field ReviewInput.note was added.
=== findSchemaChanges(v1, v2) — thêm cả thay đổi an toàn (graphql-js 17) ===
TYPE_ADDED Brand was added.
FIELD_ADDED Field Product.title was added.
FIELD_CHANGED_KIND_SAFE Field Product.description changed type from String to String!.
=== Query của app mobile bản cũ (đã cài trên máy người dùng) validate với schema mới ===
- Cannot query field "name" on type "Product".
- Argument "Query.product(locale:)" of type "String!" is required, but it was not provided.
=== Thêm giá trị enum là "dangerous": code client cũ switch đủ case sẽ ném lỗi khi gặp giá trị lạ ===
PAID → Đã thanh toán
REFUNDED → app crash: Trạng thái không biết: REFUNDED
Ba quy tắc rút ra, đáng học thuộc vì interviewer hay hỏi:
- Nullability đi ngược chiều ở input và output. Output:
Int!→Intlà breaking (client tin là không bao giờ null),String→String!là an toàn. Input:String→String!là breaking (client cũ không gửi). - Đổi tên = xoá + thêm. Không có "rename" an toàn; phải đi đường expand → migrate → contract.
- "Dangerous" vẫn làm sập app. Thêm giá trị enum không làm query lỗi, nhưng app được codegen với
switchđủ case sẽ ném lỗi (lab:REFUNDED → app crash). Client phải luôn có nhánh "giá trị lạ"; server nên báo trước như breaking change.
Đưa vào CI: so schema của nhánh PR với schema đang chạy production, chặn merge khi có breaking change chưa được duyệt:
// scripts/schema-check.mjs — chạy trong CI, so với schema của nhánh main
import { buildSchema, findBreakingChanges, findDangerousChanges } from 'graphql';
import { readFileSync } from 'node:fs';
const [oldSdl, newSdl] = process.argv.slice(2).map((f) => readFileSync(f, 'utf8'));
const breaking = findBreakingChanges(buildSchema(oldSdl), buildSchema(newSdl));
const dangerous = findDangerousChanges(buildSchema(oldSdl), buildSchema(newSdl));
for (const c of dangerous) console.warn('DANGEROUS', c.type, c.description);
for (const c of breaking) console.error('BREAKING ', c.type, c.description);
process.exit(breaking.length ? 1 : 0);
flowchart LR PR["PR đổi schema"] --> B["Dựng schema mới"] B --> C["So với schema production"] C -->|"breaking"| X["Chặn merge: cần deprecate trước hoặc duyệt có chủ đích"] C -->|"dangerous"| W["Cảnh báo + báo trước cho client"] C -->|"chỉ an toàn"| OK["Cho merge"] OK --> U["Theo dõi usage field đã deprecated"]
Công cụ so sánh của graphql-js chỉ thấy thay đổi về hình dạng. Nó không thấy thay đổi về ý nghĩa: đổi đơn vị của price từ
nghìn đồng sang đồng, đổi thứ tự sắp xếp mặc định, đổi khi nào field trả null. Những thay đổi đó cần review của người. Công cụ có
thống kê usage và registry: GraphQL Inspector, GraphQL Hive, Apollo GraphOS (chưa đánh giá trong lab này).
Cách GitHub làm (API công khai): báo trước breaking change "at least three months", áp dụng vào ngày đầu quý, và chia đúng hai loại Breaking / Dangerous như graphql-js (GitHub Docs, Breaking changes).
3.5 Federation: query plan, entity và reference resolver
# subgraph users — sở hữu User
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"])
type Query { me: User }
type User @key(fields: "id") { id: ID! name: String! }
# resolver: User.__resolveReference({ id }) → tìm user theo id
# subgraph posts — sở hữu Post, GÓP thêm field User.posts
type Query { posts(first: Int = 20): [Post!]! }
type Post @key(fields: "id") { id: ID! title: String! author: User! writer: User }
type User @key(fields: "id") { id: ID! posts: [Post!]! }
# resolver: Post.author = (p) => ({ __typename: 'User', id: p.authorId }) ← chỉ trả "tham chiếu"
# lab 07 — query { posts(first: 20) { title author { name } } }
QueryPlan {
Sequence {
Fetch(service: "posts") {
{ posts(first: 20) { title author { __typename id } } }
},
Flatten(path: "posts.@.author") {
Fetch(service: "users") {
{ ... on User { __typename id } } =>
{ ... on User { name } }
},
},
},
}
request tới subgraph: {"posts":{"http":1,"representations":[]},"users":{"http":1,"representations":[20]}}
Router gọi posts trước, lấy được 20 tham chiếu { __typename: "User", id }, rồi gửi một request
_entities(representations: [...]) sang users với cả 20 tham chiếu — dù chỉ có 5 user khác nhau (router không gộp trùng).
N+1 không biến mất — nó dời vào subgraph:
__resolveReference không có DataLoader → "câu SQL" ở subgraph users: 20
__resolveReference qua DataLoader → "câu SQL" ở subgraph users: 1
Router đã gom thành một request, nhưng thư viện subgraph gọi __resolveReference một lần cho mỗi representation. Viết
__resolveReference bằng một câu SELECT … WHERE id = $1 là N+1 ở subgraph. Luôn đi qua DataLoader như bài 3.
Mỗi lần đổi subgraph là thêm một lượt nối tiếp: query { me { name posts { title author { name posts { title } } } } }
cho kế hoạch 3 bước: users (me) → posts (me.posts, author.posts) → users (tên author). Độ trễ ≥ tổng 3 lượt. Thiết kế
ranh giới subgraph sao cho các field hay đi cùng nhau nằm cùng subgraph.
Một subgraph chết thì sao:
HTTP 200 | data = null
error: request to http://localhost:37739/ failed, reason: connect ECONNREFUSED 127.0.0.1:37739 | code: INTERNAL_SERVER_ERROR
dùng field nullable "writer: User" → HTTP 200 | data = {"posts":[{"title":"post 1","writer":null},{"title":"post 2","writer":null}]}
query KHÔNG cần users → HTTP 200 | data = {"posts":[{"title":"post 1"},{"title":"post 2"}]}
include_subgraph_errors để kiểm soát việc này — chưa
dựng lab Router).Composition bắt xung đột trước khi chạy:
[INVALID_FIELD_SHARING] Non-shareable field "User.name" is resolved from multiple subgraphs: it is resolved from subgraphs "crm"
and "users" and defined as non-shareable in all of them
Hai team cùng khai báo User.name mà không ai đánh dấu @shareable. Lỗi này nên nổ ở CI (bước compose), không phải lúc deploy
router. Vì vậy Apollo ghi rõ trong docs: "We strongly recommend against using IntrospectAndCompose in production" — lab dùng nó cho
nhanh, production nên compose sẵn supergraph trong CI (rover, Hive composition) rồi mới đưa cho router.
3.6 Subscription và realtime
Subscription giữ một kết nối lâu dài (WebSocket theo giao thức graphql-ws, hoặc SSE) để server đẩy sự kiện. Nó khác query ở chỗ có
trạng thái: mỗi kết nối chiếm bộ nhớ và file descriptor, scale ngang cần pub/sub chung (Redis...) để sự kiện tới đúng instance đang giữ kết
nối, deploy phải xử lý kết nối đang mở. Lighthouse làm subscription qua driver kiểu Pusher/Laravel Echo. Phần này chưa dựng lab — nhiều team chọn
polling hoặc SSE thường cho đơn giản, chỉ dùng subscription khi độ trễ cập nhật thật sự quan trọng.
3.7 Nâng cấp thư viện: hai bẫy có thật
# graphql 17 đã ra, nhưng Apollo Server 5 vẫn chỉ nhận graphql 16 (labs/graphql/federation/eresolve.sh)
npm error code ERESOLVE
npm error Found: graphql@17.0.2
npm error Could not resolve dependency:
npm error peer graphql@"^16.11.0" from @apollo/server@5.5.1
# @apollo/subgraph 2.9.0 nhận buildSubgraphSchema({ typeDefs, resolvers }); 2.15.1 chỉ nhận mảng hoặc DocumentNode
buildSubgraphSchema({ typeDefs, resolvers }) → TypeError: doc.definitions is not iterable (cannot read property undefined)
- Thư viện GraphQL hiếm khi đứng một mình: server, subgraph, gateway, codegen, plugin cùng phụ thuộc vào
graphqllàm peer dependency. Nânggraphqlphải đợi cả hệ sinh thái — thêm vào kế hoạch nâng cấp, đừng dùng--legacy-peer-depscho qua: nó cho cài một tổ hợp mà chính npm báo là "incorrect (and potentially broken)", và hai bảngraphqlcùng nằm trongnode_modulescó thể làm hỏng các phép kiểm tra kiểu giữa thư viện (chưa dựng lab cho trường hợp này). - Lỗi thứ hai "nổ trễ" theo kiểu khó chịu nhất: bản nâng cấp minor (2.9 → 2.15), TypeScript bắt được lúc build, nhưng code JavaScript thuần chỉ chết lúc khởi động — với ví dụ chép từ blog cũ.
- graphql-js 17 cũng đổi hành vi:
execute()không còn chạy@defer/@stream(phải dùngexperimentalExecuteIncrementally()), và có thêm luật validate (32 so với 27) — chạy lại bộ query thật của client trước khi nâng.
4. Đảm bảo và giới hạn
- APQ: đảm bảo URL ngắn và cache được; không đảm bảo gì về query nào được chạy; cache mặc định theo từng instance.
Cache-Controltự sinh: chặt nhất trong mọi field — một field thiếu gợi ý làno-store.- So sánh schema: bắt được thay đổi hình dạng (xoá field, đổi kiểu, thêm tham số bắt buộc); không bắt được thay đổi ý nghĩa.
- Federation: không có transaction giữa các subgraph; dữ liệu giữa các subgraph chỉ nhất quán "cuối cùng"; độ trễ ≥ tổng các lượt nối tiếp trong query plan; router nằm trên đường đi của mọi request — phải chạy nhiều bản, có health check; composition chỉ bảo vệ bạn nếu chạy trước khi deploy.
- Không version: chỉ an toàn khi có dữ liệu usage theo field và theo phiên bản client; không có dữ liệu thì "không version" nghĩa là "không bao giờ dám xoá".
5. Failure modes
| Hỏng thế nào | Lab | Hệ quả | Phát hiện | Chữa |
|---|---|---|---|---|
| Dashboard xanh, người dùng thấy lỗi | 06b-A | Operation lỗi 100%, 0 HTTP 5xx | Đếm errors[] theo operation | Plugin metric, mã lỗi chuẩn |
| APQ trượt ở instance khác | 06-G | Thêm 1 lượt mạng mỗi request; nặng sau deploy | Tỉ lệ PERSISTED_QUERY_NOT_FOUND | Cache APQ dùng chung |
Một field làm cả response no-store | 06b-B | Tỉ lệ trúng CDN rơi về 0 | Theo dõi hit ratio CDN theo operation | Gợi ý cache cho mọi field công khai; lint schema |
| Breaking change lọt ra | 05 | App bản cũ lỗi "Cannot query field" | Lỗi validate tăng theo phiên bản client | Schema check trong CI |
| Thêm giá trị enum | 05 | App cũ crash ở switch | Crash report phía client | Báo trước; client có nhánh giá trị lạ |
| Subgraph chết | 07-D | data: null, lộ URL nội bộ | Lỗi gateway theo subgraph | Field ranh giới nullable; che lỗi subgraph |
| N+1 ở reference resolver | 07-B | 20 câu SQL thay vì 1 | Đếm SQL ở subgraph | DataLoader trong __resolveReference |
| Xung đột composition | 07-E | Không ghép được supergraph | CI compose | @shareable hoặc chuyển quyền sở hữu field |
| Nâng cấp vỡ | eresolve, 07-F | Không cài được / chết lúc khởi động | CI cài sạch + smoke test | Ghim version, nâng theo cả hệ sinh thái |
6. Vận hành trong production
- Metric cần có: theo operation (số request, tỉ lệ có
errors, p95/p99, kích thước response, client + phiên bản); theo field (thời gian, lấy mẫu; số lần dùng — để biết khi nào xoá được); theo subgraph (độ trễ fetch, tỉ lệ lỗi); router (tỉ lệ trúng cache query plan); APQ (tỉ lệPERSISTED_QUERY_NOT_FOUND); CDN (hit ratio theo operation). - Tham số ảnh hưởng lớn:
@cacheControltrên field công khai; cache APQ dùng chung + TTL; timeout từng subgraph ở router; kích thước cache query plan; che lỗi subgraph; chính sách từ chối query không tên. - Capacity planning: router tốn CPU cho lập kế hoạch (có cache) và RAM cho việc ghép response; mỗi subgraph phải chịu được tải
_entitiestheo lô; tính độ trễ theo số lượt nối tiếp của các operation chính, không theo một request đơn lẻ. - Runbook lúc 3 giờ sáng: operation nào lỗi → của client phiên bản nào → query plan đi qua subgraph nào → subgraph nào chậm/chết → field ranh giới nào non-null. Rollback schema (supergraph) tách khỏi rollback code của subgraph.
- Chi phí: nền tảng quản lý của hãng (registry, usage, router có hỗ trợ) thường tính phí; giá thay đổi theo thời gian nên không ghi số ở đây (chưa verify bảng giá).
7. So sánh và ma trận quyết định
| Kiến trúc | Sở hữu theo team | Lượt mạng thêm | Độ phức tạp vận hành | Cô lập lỗi | License / lock-in |
|---|---|---|---|---|---|
| Một server GraphQL (module theo domain) | Theo thư mục/CODEOWNERS | 0 | Thấp | Thấp (một process) | Tuỳ server |
| Schema stitching | Theo service | Có | Trung bình; logic ghép nằm ở gateway | Trung bình | Thư viện mở |
| Federation — Apollo Router / Gateway | Theo subgraph | Theo query plan | Cao | Cao (nếu field ranh giới nullable) | Router & gateway: Elastic License 2.0 |
| Federation — router mở | Theo subgraph | Theo query plan | Cao | Cao | Hive Gateway MIT; WunderGraph Cosmo Apache-2.0; Grafbase MPL-2.0 |
| BFF theo client (REST) | Theo client | 0–1 | Trung bình | Cao | Không |
Elastic License 2.0 cho phép dùng nội bộ và sửa đổi, nhưng không cho đem phần mềm cung cấp lại cho bên thứ ba như một dịch vụ được quản lý (managed service). Với đa số công ty dùng nội bộ thì không vướng; với công ty bán nền tảng thì phải đọc kỹ. Hive composition tự giới thiệu là thay thế "drop-in" cho composition của Apollo (README) — nghĩa là supergraph không bị trói vào một hãng.
Dùng federation khi: từ khoảng 3–4 team trở lên cùng sở hữu các domain rõ ràng và schema nguyên khối đã thành nút thắt về lịch deploy. Tránh khi: 1–2 team — một server GraphQL chia module là đủ, đỡ cả một tầng mạng, một hệ thống compose và một nguồn lỗi. Nếu đi theo đường federation, học cách Netflix: biến monolith thành subgraph đầu tiên, rồi tách dần từng phần.
8. Góc nhìn tech lead: tác động tổ chức
- Ranh giới subgraph = ranh giới team (định luật Conway). Ai sở hữu
User? Field nào được team khác góp vào? Viết ra, đưa vào CODEOWNERS của file schema. - Chính sách deprecation viết vào ADR: thời gian tối thiểu giữ field deprecated, điều kiện xoá (usage = 0 trên mọi phiên bản app còn hỗ trợ), kênh báo cho client (changelog, như cách GitHub làm).
- Schema review là một bước trong quy trình, không phải phần phụ của code review: nullability ở ranh giới, gợi ý cache, phân trang, quyền, breaking/dangerous.
- Exit strategy: giữ subgraph đúng spec federation, tránh tính năng chỉ một router có; compose bằng công cụ thay thế được; danh sách trusted documents là bản đồ để chuyển từng operation sang REST nếu cần.
- Kế hoạch nâng cấp theo cả hệ sinh thái (mục 3.7), có bộ query thật của client để chạy lại.
ADR — Federation cho API khách hàng
Bối cảnh: 5 team (catalog, cart, order, payment, account) cùng sửa 1 schema; lịch deploy GraphQL là nút thắt.
Quyết định: Federation 2; monolith hiện tại là subgraph đầu tiên; tách dần theo domain. Router: chọn sau khi so license
(Apollo Router — Elastic-2.0; Hive Gateway — MIT; Cosmo — Apache-2.0) và năng lực vận hành của team platform.
Bắt buộc: compose trong CI (không compose lúc chạy); field lấy từ subgraph khác phải nullable; DataLoader trong mọi
__resolveReference; metric theo operation + subgraph; schema check chặn breaking change.
Hệ quả: thêm 1 tầng mạng và 1 dịch vụ hạ tầng phải trực; team tự chủ lịch deploy.
Xem lại khi: số team giảm, hoặc p99 tăng quá ngân sách vì số lượt nối tiếp.
9. Anti-patterns và bài học thực tế
- Version GraphQL theo URL (
/v2/graphql) — mất đúng lợi thế "không version", lại gánh hai schema. - Xoá field không có dữ liệu usage — lab 05: app cũ lỗi
Cannot query field "name". - Monitor bằng HTTP status — lab 06b.
- Cache APQ trong RAM sau load balancer — lab 06 G.
- Federation cho hai team — "monolith phân tán": thêm độ trễ và điểm hỏng mà không thêm tự chủ.
- Reference resolver không batch — lab 07 B.
- Field non-null ở ranh giới subgraph — lab 07 D.
- Compose lúc chạy ở production (
IntrospectAndCompose) — Apollo khuyên rõ là không. - Trả nguyên lỗi subgraph cho client — lộ địa chỉ nội bộ (lab 07 D).
Bài học công khai: Netflix (Netflix Tech Blog, 2020) — bắt đầu federation bằng cách coi monolith là một subgraph, chuyển field sang các Domain Graph Service từng phần mà không làm vỡ client; GitHub — chính sách breaking change công khai, báo trước ít nhất 3 tháng, theo quý; Apollo — Federation 2 (GA 04/2022) viết lại composition và query planning, tương thích ngược để chuyển từng subgraph một.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- GraphQL trả 200 khi lỗi — monitor thế nào?
- Plugin ghi metric theo
operationName, đếmerrors[]và mã lỗi; alert theo tỉ lệ operation có lỗi, không theo 5xx. - Cache GraphQL ở CDN được không?
- Được với query qua GET + persisted query cho URL ngắn +
Cache-Controltừ@cacheControl. Cẩn thận: một field không gợi ý làno-storecả response; dữ liệu theo người dùng phảiPRIVATE. - Làm sao xoá một field an toàn?
- Expand → migrate → contract: thêm field mới,
@deprecatedfield cũ, đo usage theo client + phiên bản, xoá khi về 0; CI chặn breaking change ngoài kế hoạch. - Thêm giá trị enum có phải breaking không?
- graphql-js xếp vào "dangerous", nhưng app cũ có thể crash (lab 05). Báo trước như breaking, client phải có nhánh giá trị lạ.
- Federation khác schema stitching thế nào?
- Stitching: gateway chứa logic ghép. Federation: mỗi subgraph tự khai báo phần mình sở hữu (
@key, field góp vào entity), router chỉ lập kế hoạch theo khai báo; xung đột bị bắt ở bước compose. - Federation có N+1 không?
- Router gom tham chiếu thành một request
_entities, nhưng subgraph gọi reference resolver cho từng tham chiếu — phải dùng DataLoader (lab: 20 → 1). - Một subgraph chết thì trang có trắng không?
- Tuỳ nullability ở ranh giới: non-null thì null lan tới
data(lab:data = null); nullable thì chỉ thiếu phần đó. - Vendor lock-in với Apollo?
- Spec GraphQL và federation có nhiều bên cài đặt; Apollo Router/Gateway dùng Elastic License 2.0, có router MIT/Apache thay thế; giữ subgraph đúng spec và compose bằng công cụ thay được.
Phụ lục: lab đã chạy
Môi trường: graphql-js 17.0.2 (lab 05); Apollo Server 5.5.1, @apollo/subgraph 2.15.1, @apollo/gateway 2.14.4 (Elastic-2.0),
graphql 16.14.2, DataLoader 2.2.3 (lab 06, 06b, 07); Node 22.23.3 trong node:22-bookworm-slim. Output: labs/graphql/out/05-schema-evolution.txt,
06-apollo-http.txt, 06-apollo-http.production.txt, 06b-apollo-cache-metrics.txt, 06-graphql17-eresolve.txt,
07-federation.txt.
cd labs/graphql/node
./run.sh 05-schema-evolution.mjs
cd ../federation
docker run --rm -v "$PWD":/lab -w /lab node:22-bookworm-slim npm ci # graphql 16 + Apollo (Apollo chưa nhận graphql 17)
./run.sh 06-apollo-http.mjs
./run.sh 06b-apollo-cache-metrics.mjs
./run.sh 07-federation.mjs
./eresolve.sh # tái hiện xung đột graphql 17 với @apollo/server 5
Chưa verify: Apollo Router (Rust) và tuỳ chọn include_subgraph_errors; Hive Gateway, Cosmo, Grafbase chạy thật; cache APQ dùng
Redis; subscription (graphql-ws, Lighthouse + Pusher); Lighthouse làm subgraph federation; bảng giá các nền tảng quản lý schema.