GĐ20 — Microservices: ranh giới, giao tiếp, và cái giá thật

Lập trường của tài liệu này, nói thẳng ngay đầu: microservices là một giải pháp cho vấn đề tổ chức, không phải vấn đề kỹ thuật. Nếu bạn không có nhiều đội đang giẫm chân nhau trong cùng một codebase, bạn gần như chắc chắn không cần nó — và bạn sẽ trả toàn bộ chi phí mà không nhận được lợi ích nào.

Bạn học giai đoạn này vì hai lý do: (1) bạn sẽ vào một công ty đã có microservices và phải làm việc hiệu quả trong đó; (2) câu hỏi "khi nào tách service" là câu phỏng vấn senior kinh điển, và câu trả lời tốt luôn bắt đầu bằng "hầu hết trường hợp là không".

Điều kiện vào: đã học GĐ19. Không có nó, mọi thứ ở đây chỉ là sơ đồ hộp và mũi tên.


1. Định nghĩa và động cơ thật#

Định nghĩa. Microservices là kiến trúc mà ứng dụng được chia thành nhiều service nhỏ, triển khai độc lập, mỗi service sở hữu dữ liệu của mình, giao tiếp qua mạng.

Hai chữ quan trọng nhất: triển khai độc lập. Nếu bạn phải deploy 5 service cùng lúc mới chạy được, bạn không có microservices — bạn có một monolith phân tán, kiến trúc tệ nhất trong mọi lựa chọn: đủ mọi chi phí của hệ phân tán, không lợi ích nào.

Động cơ thật (theo thứ tự trung thực):

Động cơCó thật không
Nhiều đội deploy độc lập, không chờ nhau✅ Lý do chính đáng số một
Các phần scale rất khác nhau (video encoding vs CRUD)✅ Có thật, nhưng hiếm
Cần công nghệ khác nhau cho phần khác nhau✅ Có thật, hiếm hơn nữa
Cách ly lỗi⚠️ Chỉ đúng nếu thiết kế cẩn thận; sai cách thì lỗi lan rộng hơn
"Sạch hơn", "hiện đại hơn"❌ Không phải lý do
"Netflix làm vậy"❌ Bạn không có vấn đề của Netflix

Luật Conway (1967) — quan sát then chốt:

Tổ chức thiết kế hệ thống sẽ tạo ra thiết kế sao chép cấu trúc giao tiếp của chính tổ chức đó.

Hệ quả thực tiễn (inverse Conway manoeuvre): muốn kiến trúc nào, hãy tổ chức đội theo hình dạng đó. Và ngược lại: tách service không khớp với cấu trúc đội sẽ luôn đau — bạn tạo ra ranh giới mà không ai sở hữu.


2. Cái giá — bảng đầy đủ#

Bạn nhậnBạn trả
Deploy độc lậpVersioning API, tương thích ngược, đội mất khả năng thay đổi nguyên tử
Scale độc lậpN pipeline CI/CD, N dashboard, N on-call rotation
Cách ly lỗiCascading failure, cần circuit breaker ở mọi nơi
Tự do công nghệN hệ sinh thái phải bảo trì, kiến thức phân mảnh
Đội tự chủMất transaction — mọi thứ thành saga
Codebase nhỏDebug xuyên service, bắt buộc phải có tracing
Ranh giới rõRanh giới sai thì cực kỳ đắt để sửa

Ba cái đắt nhất, thường bị đánh giá thấp:

1. Mất transaction. Trong monolith: BEGIN; ... COMMIT; — miễn phí, đúng đắn tuyệt đối. Tách service: mỗi thao tác nhiều bước thành saga có bồi hoàn, có trạng thái trung gian nhìn thấy được, có test phức tạp gấp bội (→ GĐ19 mục 8). Đây là một lần đánh đổi ảnh hưởng tới mọi tính năng bạn viết sau đó.

2. Debug xuyên service. Bug ở monolith: một stack trace. Bug ở microservices: tương quan log của 5 service, thời gian lệch nhau, và nếu không có distributed tracing thì gần như không thể. Tracing trở thành bắt buộc, không phải tuỳ chọn.

3. Test. Test tích hợp cần dựng bao nhiêu service? Dựng hết thì chậm và giòn; mock hết thì test qua nhưng production hỏng. Đây là bài toán chưa có lời giải đẹp (contract testing giúp nhưng không giải quyết hết — mục 7).

Chi phí bắt buộc trước khi có service thứ hai:

  • CI/CD tự động hoàn toàn (deploy tay 8 service là bất khả thi)
  • Distributed tracing
  • Log tập trung
  • Service discovery
  • Registry chứa API contract
  • Môi trường dev local dựng được toàn bộ hệ trong một lệnh

Nếu bạn chưa có sáu thứ này, tách service sẽ làm mọi thứ chậm lại, không nhanh lên.


3. Modular monolith — thứ bạn nên xây#

Đây là khuyến nghị chính của tài liệu này cho DA3 và các dự án sau nó.

Một deployment, một database — nhưng ranh giới module được thực thi bằng công cụ:

textReady
src/modules/  orders/    index.ts          ← API CÔNG KHAI duy nhất của module    domain/           ← nội bộ, module khác KHÔNG được import    application/    infrastructure/  billing/    index.ts    ...  users/    index.ts

Ba quy tắc, thực thi bằng lint chứ không bằng kỷ luật:

  1. Module chỉ import qua index.ts của module khác — không đâm xuyên vào trong.
  2. Không import vòng giữa module.
  3. Bảng của module A không được truy vấn bởi module B (dùng schema riêng trong cùng Postgres, hoặc tiền tố tên bảng, và kiểm tra bằng test).
typescriptReady
// eslint.config.js — ESLint 10 chỉ còn flat config (.eslintrc đã bị gỡ)import { defineConfig } from 'eslint/config'import importX from 'eslint-plugin-import-x'import tseslint from 'typescript-eslint'const modules = ['ordering', 'billing', 'identity']export default defineConfig([  importX.flatConfigs.typescript,          // parser + resolver cho import-x (thiếu thì no-cycle im lặng)  ...modules.map((m) => ({    files: [`src/modules/${m}/**/*.ts`],    languageOptions: { parser: tseslint.parser },    plugins: { 'import-x': importX },    rules: {      // module m chỉ được import module khác qua '@modules/<tên>'; alias vào chính m thì được      'no-restricted-imports': ['error', {        patterns: [{          group: ['@modules/*/*', `!@modules/${m}/*`, `!@modules/${m}/**`],          message: 'Chỉ import module khác qua index.ts công khai',        }],      }],      'import-x/no-cycle': 'error',    },  })),])

Đã chạy (ESLint 10.12.0, eslint-plugin-import-x 4.17.1, eslint-import-resolver-typescript 4.4.5, typescript-eslint 8.71 với TypeScript 6.0.3; thư mục thử có tsconfig.json với paths như bài tập mục 10 và các file mẫu bên dưới), eslint src báo 4 lỗi:

File mẫuNội dungKết quả
ordering/a-violation-alias.tsimport ... from '@modules/billing/domain/invoice'Bị chặn (no-restricted-imports)
ordering/c-own-alias.tsimport ... from '@modules/ordering/domain/order' (alias vào chính module)Qua
billing/d-cycle.ts + ordering/index.tsbilling import ordering và ordering import billingBị chặn vòng (import-x/no-cycle), báo ở 3 file trong vòng
ordering/b-violation-relative.tsimport ... from '../billing/domain/invoice.ts'Lọt (không báo gì)

Ba điều rút ra từ lần chạy này. (1) Preset importX.flatConfigs.typescript là phần bắt buộc: bản cấu hình ngắn hơn (chỉ files, parser và plugins) không có resolver nên import-x/no-cycle im lặng bỏ qua vòng đi qua alias. (2) Mẫu group phải loại trừ chính module (!@modules/${m}/*), nếu không luật chặn luôn cả import alias vào module của chính nó; vì vậy cấu hình sinh một khối cho mỗi module. (3) no-restricted-imports so khớp chuỗi trong câu import, nên import tương đối kiểu ../billing/domain/x vẫn lọt: quy ước "chỉ dùng alias" là một phần của luật. Phương án chặn theo đường dẫn thật (eslint-plugin-boundaries, hoặc dependency-cruiser) chưa chạy ở đây; chọn khi quy ước alias không giữ nổi. typescript-eslint 8 chưa chạy với TS 7 (cần TS 6 cho bước lint), xem GĐ02 mục 9.

Vì sao đây là lựa chọn đúng:

  • Bạn nhận được phần lớn lợi ích: ranh giới rõ, sở hữu rõ, code dễ hiểu.
  • Bạn giữ transaction, giữ refactor nguyên tử, giữ một stack trace.
  • Nếu sau này thật sự cần tách, module đã sạch ranh giới → tách là việc cơ học, không phải viết lại.

Nguyên tắc: monolith-first. Tách service khi nỗi đau tổ chức vượt chi phí phân tán — không phải khi codebase lớn, không phải khi bạn chán.


4. Ranh giới service: nơi tách và nơi không#

Sai lầm số một: tách theo tầng kỹ thuật.

textReady
❌ user-service, auth-service, notification-service,   database-service, api-service

Kết quả: thêm một trường vào hồ sơ người dùng phải sửa 4 service và deploy đồng bộ. Đây là monolith phân tán.

Đúng: tách theo năng lực nghiệp vụ (business capability).

textReady
✅ ordering, billing, catalog, identity, shipping

Mỗi cái sở hữu trọn vẹn một mảng nghiệp vụ: dữ liệu, quy tắc, API, và một đội.

Bounded Context (DDD) — công cụ tìm ranh giới tốt nhất. Ý tưởng: cùng một từ có nghĩa khác nhau ở các ngữ cảnh khác nhau, và ranh giới nghĩa chính là ranh giới service.

TừTrong orderingTrong billingTrong shipping
CustomerNgười đặt hàng, có giỏ hàngBên chịu trách nhiệm trả tiền, có hạn mứcNgười nhận, có địa chỉ + giờ nhận

Ba khái niệm khác nhau chia sẻ một cái tên. Cố nhồi chúng vào một model Customer dùng chung là nguyên nhân của những class 60 trường mà không ai hiểu.

Bốn phép thử ranh giới, hỏi theo thứ tự:

  1. Phép thử transaction. Hai thứ này có cần thay đổi nguyên tử không? Có → cùng một service. Đây là phép thử mạnh nhất.
  2. Phép thử thay đổi. Chúng có thường thay đổi cùng nhau trong cùng một PR không? Có → cùng service.
  3. Phép thử đội. Một đội sở hữu được toàn bộ không? Không → ranh giới sai.
  4. Phép thử dữ liệu. Service này có phải hỏi service kia mới trả lời được câu hỏi cơ bản của mình không? Có → ranh giới sai.

Phản mẫu: nano-service. Service chỉ có 3 endpoint và không sở hữu dữ liệu gì đáng kể. Nó chỉ thêm một round-trip mạng. Nếu service không có DB riêng, nó có lẽ không nên là service.


5. Giao tiếp: đồng bộ hay bất đồng bộ#

Đồng bộ (REST/gRPC)Bất đồng bộ (event/queue)
Ghép nốiChặt — B phải sống thì A mới chạyLỏng — B chết, event vẫn nằm chờ
Độ trễCộng dồn qua từng chặngĐẩy ra khỏi đường request
Nhất quánNgay lập tứcEventual
DebugDễ — stack rõKhó — cần tracing
Dùng khiCần câu trả lời ngay để tiếp tụcThông báo việc đã xảy ra

Quy tắc thực dụng: mặc định bất đồng bộ; đồng bộ chỉ khi thật sự cần câu trả lời để tiếp tục xử lý request hiện tại.

Vì sao chuỗi gọi đồng bộ nguy hiểm. Availability nhân nhau:

textReady
A → B → C → D,  mỗi service uptime 99,9%Availability thực tế = 0.999⁴ ≈ 99,6%  →  gấp 4 lần thời gian chết

Và độ trễ cộng dồn: 4 chặng × 50ms = 200ms chỉ riêng phần mạng, chưa tính xử lý.

Tính lại: availability nhân nhau
textReady
Một service 99,9%: thời gian chết/năm = 0,001 × 8.760 h = 8,76 hChuỗi 4 service : 0,999^4 = 0,996006 -> 99,60%                  thời gian chết/năm = (1 - 0,996006) × 8.760 h ≈ 35,0 h                  35,0 / 8,76 ≈ 4,0 lần

Giả định: lỗi độc lập và mỗi lời gọi bắt buộc thành công. Khi lỗi tương quan dương (hay gặp), độ khả dụng của chuỗi nằm giữa 99,6% và 99,9%; chuỗi không bao giờ sẵn sàng hơn mắt xích kém nhất. Cách giảm: bỏ bước đồng bộ khỏi đường request (event), hoặc có đường dự phòng (cache, giá trị mặc định) cho phụ thuộc không thiết yếu. Đã chạy: Node 24.21.0, tính các số trên.

Event-driven — ba loại event, khác nhau quan trọng:

typescriptReady
// 1. Event notification — mỏng, người nhận phải hỏi lại nếu cần thêm{ type: 'order.placed', orderId: '01H...', occurredAt: '...' }// 2. Event-carried state transfer — dày, người nhận không cần hỏi lại{ type: 'order.placed', order: { id, userId, totalMinor, currency, lines: [...] }}// 3. Domain event (event sourcing) — event LÀ nguồn sự thật

Loại 1 ghép lỏng nhất nhưng gây "N+1 qua mạng". Loại 2 giảm gọi lại nhưng làm dữ liệu bị sao chép và có thể cũ. Thực dụng: dùng loại 2 với các trường ổn định (id, tổng tiền, thời điểm), để người nhận hỏi lại phần hay đổi.

Quy tắc thiết kế event:

  • Đặt tên ở thì quá khứ: order.placed, không phải place-order (event là sự thật đã xảy ra, không phải mệnh lệnh).
  • Có eventId (để khử trùng lặp), occurredAt, version.
  • Không bao giờ thay đổi ý nghĩa của event đã phát hành — thêm phiên bản mới.
  • Người nhận luôn phải idempotent (→ GĐ10). Kiểu envelope và consumer có bảng inbox chạy được: mục 11 của giai đoạn này.

6. Dữ liệu: mỗi service một database#

Quy tắc bất di bất dịch: không service nào đọc trực tiếp bảng của service khác.

Chia sẻ database là con đường nhanh nhất quay về monolith phân tán — bạn không thể đổi schema mà không phá service khác, tức là mất luôn lý do duy nhất để tách.

Ba hệ quả và cách xử lý:

1. Không JOIN được xuyên service. Cần "đơn hàng + tên khách hàng":

  • API composition — gọi cả hai rồi ghép ở tầng gọi. Đơn giản, nhưng N+1 qua mạng.
  • Sao chép read model — service ordering giữ một bản customer_name cập nhật qua event. Nhanh, nhưng có thể cũ và phải xử lý drift (giống bài toán đồng bộ search ở GĐ11).
  • CQRS read model riêng — một view tổng hợp từ nhiều service. Mạnh, đắt.

2. Không có transaction xuyên service. → Saga (GĐ19 mục 8 cho khái niệm; bảng trạng thái chạy tiếp được sau khi sập ở mục 12 của giai đoạn này).

3. Không có FK xuyên service. Tính toàn vẹn tham chiếu trở thành trách nhiệm của ứng dụng. Bạn sẽ có orders trỏ tới userId không còn tồn tại. Cần job đối soát và chính sách xử lý dữ liệu mồ côi.

Cách phát event đáng tin cậy: outbox. Chính xác cùng cơ chế ở GĐ10 mục 4 — ghi event vào bảng outbox trong cùng transaction với thay đổi dữ liệu, relay đẩy sang broker. Không có nó, bạn rơi vào bài toán hai-lần-ghi và event sẽ mất.

Sơ đồ và code tham chiếu: outbox cộng consumer idempotent
textReady
 ordering                                   broker             billing ┌─────────────────────────────┐ │ BEGIN                       │ │  INSERT orders              │ │  INSERT outbox(event_id,..) │   relay (loop) │ COMMIT  (cùng transaction)  │ ─► SELECT ... FOR UPDATE SKIP LOCKED └─────────────────────────────┘    publish(event) ──────► queue ──► handler                                    UPDATE published_at          │                                    (chết giữa chừng => phát lại)│                      at-least-once: event có thể đến 2 lần ◄────┘                      consumer: INSERT processed_events(event_id) UNIQUE
textReady
-- Phía consumer (billing): khử trùng lặp và tác dụng phụ trong CÙNG transactionBEGIN;INSERT INTO billing.processed_events (event_id) VALUES ($1)  ON CONFLICT (event_id) DO NOTHING;      -- 0 hàng bị ảnh hưởng = đã xử lý rồi-- chỉ khi INSERT ở trên thêm được 1 hàng mới chạy tiếp:INSERT INTO billing.invoices (order_id, total_minor) VALUES ($2, $3);COMMIT;

Kết quả mong đợi (suy ra từ ràng buộc UNIQUE; phiên bản chạy được trên SQLite ở mục 11): giao cùng event_id hai lần thì invoices chỉ có một hàng. Ở code ứng dụng, đọc số hàng INSERT ... ON CONFLICT DO NOTHING trả về, nếu bằng 0 thì ROLLBACK hoặc bỏ qua phần ghi tiếp theo. Cơ chế relay, SKIP LOCKED và phần giới hạn: GĐ10 mục 4 (đã có sơ đồ).


7. Contract và versioning#

Vấn đề. Service A gọi service B. B đổi response. A vỡ ở production. Không ai biết trước khi khách hàng báo.

Consumer-driven contract testing (Pact) — ý tưởng:

textReady
1. Consumer (A) viết test mô tả CHÍNH XÁC những gì nó cần từ B   → sinh ra file "pact" (hợp đồng)2. Hợp đồng được đăng lên broker3. CI của Provider (B) chạy MỌI hợp đồng của MỌI consumer4. B làm vỡ hợp đồng → CI của B đỏ, TRƯỚC KHI deploy

Điểm mạnh: B biết mình sắp phá A tại thời điểm build, không cần dựng cả hệ thống, và không cần A tham gia. Đây là công cụ tốt nhất hiện có cho bài toán này.

Ví dụ tối giản: contract test không cần Pact

Khi chưa có Pact, một "schema test" của consumer đã bắt được lỗi đổi tên field. Consumer khai báo đúng phần nó đọc, provider chạy test đó trong CI của mình:

typescriptReady
import { z } from 'zod'// consumer (billing) sở hữu file này; provider (ordering) import nó vào CIexport const OrderPlacedContract = z.object({  orderId: z.string(),  totalMinor: z.number().int(),  currency: z.string().length(3),})   // zod mặc định bỏ qua field lạ: đúng quy tắc Postel// test ở providerit('response /orders/:id thoả contract của billing', async () => {  const body = await getOrder('o1')  expect(() => OrderPlacedContract.parse(body)).not.toThrow()})

Đã chạy (zod 4.6.5, Node 24.21.0, safeParse thay cho parse để in kết quả): thêm field note thì vẫn hợp lệ (true); đổi totalMinor thành total thì false với lỗi totalMinor: Invalid input: expected number, received undefined, nên CI của provider đỏ trước khi deploy. Giới hạn so với Pact: contract nằm trong code thay vì được sinh từ test thật của consumer và đăng lên broker, nên dễ lệch với cách consumer thực sự dùng; không xác minh tương tác theo trạng thái.

Pact: consumer test và provider verification#

Schema test ở trên là bản tối giản. Pact làm đúng bốn bước ở sơ đồ trên bằng công cụ: test thật của consumer sinh ra file hợp đồng, rồi provider phát lại hợp đồng đó vào server thật của mình.

Code và kết quả đã chạy: consumer sinh pact, provider xác minh, rồi cố ý phá

Consumer (billing) viết test cho client mà nó thật sự dùng, mock server của Pact trả về đúng hợp đồng và ghi ra pacts/billing-ordering.json:

typescriptReady
import path from 'node:path'import { it, expect } from 'vitest'import { PactV3, MatchersV3 } from '@pact-foundation/pact'const { string, integer, regex } = MatchersV3// Consumer (billing) khai báo CHÍNH XÁC phần nó đọc từ provider (ordering).const pact = new PactV3({  consumer: 'billing',  provider: 'ordering',  dir: path.resolve(process.cwd(), 'pacts'),})// Client của consumer: đây mới là thứ cần test, không phải gọi HTTP trần.async function fetchOrder(baseUrl: string, id: string) {  const res = await fetch(`${baseUrl}/orders/${id}`, { headers: { accept: 'application/json' } })  if (!res.ok) throw new Error(`ordering ${res.status}`)  return (await res.json()) as { orderId: string; totalMinor: number; currency: string }}it('billing đọc được đơn hàng o1', async () => {  pact    .given('đơn o1 tồn tại')    .uponReceiving('GET đơn o1')    .withRequest({ method: 'GET', path: '/orders/o1', headers: { accept: 'application/json' } })    .willRespondWith({      status: 200,      headers: { 'content-type': 'application/json' },      body: { orderId: string('o1'), totalMinor: integer(250000), currency: regex(/^[A-Z]{3}$/, 'VND') },    })  await pact.executeTest(async (mock) => {    const order = await fetchOrder(mock.url, 'o1')    expect(order.totalMinor).toBe(250000)  })})

Provider (ordering) chạy server thật rồi cho Verifier phát lại từng tương tác trong file pact vào nó. stateHandlers đưa dữ liệu về đúng trạng thái mà consumer đã khai báo bằng given(...):

typescriptReady
import http from 'node:http'import { Verifier } from '@pact-foundation/pact'// "Provider" thật của ordering; MODE=break đổi tên totalMinor -> total.const broken = process.env.MODE === 'break'const server = http.createServer((req, res) => {  if (req.url === '/orders/o1') {    const body = broken      ? { orderId: 'o1', total: 250000, currency: 'VND' }      : { orderId: 'o1', totalMinor: 250000, currency: 'VND', note: 'field lạ vẫn ổn' }    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body))  } else res.writeHead(404).end()})await new Promise<void>((r) => server.listen(0, r))const port = (server.address() as { port: number }).porttry {  await new Verifier({    provider: 'ordering',    providerBaseUrl: `http://127.0.0.1:${port}`,    pactUrls: ['./pacts/billing-ordering.json'],    stateHandlers: { 'đơn o1 tồn tại': async () => ({ description: 'seed o1' }) },  }).verifyProvider()  console.log('VERIFY OK')} catch {  console.log('VERIFY FAIL')  process.exitCode = 1} finally {  server.close()}

Đã chạy (Node 24.21.0, @pact-foundation/pact 17.1.4, pact-core 20.2.0, vitest 5.0.3; đặt PACT_DO_NOT_TRACK=true để tắt thống kê ẩn danh mà thư viện gửi theo mặc định):

BướcKết quả
Chạy test consumer1 passed, sinh pacts/billing-ordering.json
Chạy provider bình thường (có thêm field lạ note)Verification successful, in VERIFY OK
Chạy provider với MODE=break (totalMinor đổi thành total)thất bại, in VERIFY FAIL, lý do Actual map is missing the following keys: totalMinor

Ba điểm cần nhớ. (1) Matcher kiểu integer(250000), regex(...) khai báo hình dạng chứ không ép đúng giá trị, nên theo luật khớp ghi trong file pact, provider trả số nguyên khác vẫn qua và chỉ field thiếu hoặc sai kiểu mới đỏ (trường hợp đổi giá trị chưa chạy riêng). (2) Ở đây file pact đọc từ đĩa (pactUrls); trong CI thật, consumer đăng file lên Pact Broker (hoặc PactFlow) và provider kéo về, kèm bước can-i-deploy trước khi release. Phần broker chưa chạy ở đây. (3) Pact hợp đồng cho từng cặp consumer và provider qua HTTP; với event qua queue, Pact có chế độ message nhưng chưa chạy ở đây.

Quy tắc versioning API — bất đối xứng có chủ đích:

Thay đổiAn toàn?
Thêm field tuỳ chọn vào response✅
Thêm field tuỳ chọn vào request✅
Thêm giá trị mới vào enum⚠️ Chỉ khi consumer xử lý được giá trị lạ
Đổi tên/xoá field❌ Cần version mới
Đổi kiểu dữ liệu❌
Siết chặt validation❌ Phá consumer đang gửi dữ liệu cũ

Quy tắc Postel áp dụng vào đây: khoan dung với thứ bạn nhận, nghiêm ngặt với thứ bạn gửi. Consumer phải bỏ qua field lạ, không phải lỗi khi gặp.

Quy trình gỡ bỏ field — giống expand-migrate-contract của schema DB (→ GĐ14):

  1. Thêm field mới, giữ field cũ, ghi cả hai.
  2. Đo xem còn ai đọc field cũ không (log truy cập ở tầng API).
  3. Chỉ xoá khi số đo bằng 0 trong một khoảng đủ dài.

8. Hạ tầng: gateway, discovery, và tuỳ chọn mesh#

API Gateway — cửa duy nhất từ ngoài vào:

Nên đặt ở gatewayNên để trong service
TLS terminationLogic nghiệp vụ
Xác thực (verify JWT)Phân quyền chi tiết (service biết dữ liệu của mình)
Rate limit toàn cụcRate limit theo nghiệp vụ
Định tuyến, aggregation
Request ID, log truy cập

Pitfall — gateway phình thành monolith. Khi logic nghiệp vụ bò vào gateway, nó trở thành điểm nghẽn tổ chức: mọi đội phải sửa nó, mọi thay đổi phải deploy nó. Giữ gateway "ngu" một cách nghiêm ngặt. Gateway xác thực người dùng ngoài; còn service gọi nhau thì phải tự chứng minh danh tính và được phân quyền: xem mục 13.

Service discovery. Trong K8s bạn gần như không cần nghĩ — DNS nội bộ (billing.default.svc.cluster.local) + Service làm cả discovery lẫn cân bằng tải. Ngoài K8s: Consul, hoặc load balancer với target group.

Service mesh (Istio, Linkerd) đưa mTLS, retry, circuit breaker, tracing xuống tầng hạ tầng (sidecar proxy) thay vì thư viện trong app.

Đánh giá trung thực: với dưới ~10 service, chi phí vận hành mesh vượt lợi ích. Thư viện resilience trong app (→ GĐ21) rẻ hơn nhiều. Nếu buộc phải chọn, Linkerd đơn giản hơn Istio rất đáng kể.


9. Đường di trú: tách service khi thật sự cần#

Strangler Fig pattern (Martin Fowler) — tên lấy từ loài cây bóp nghẹt: cây mới mọc quanh cây cũ và dần thay thế nó, không có ngày "chặt cây".

textReady
Giai đoạn 1:  Client → MonolithGiai đoạn 2:  Client → Proxy → Monolith                            ↘ Service mới (một phần nhỏ traffic)Giai đoạn 3:  Client → Proxy → Monolith (phần còn lại)                            ↘ Service mới (nhiều hơn)Giai đoạn 4:  Client → Proxy → Service mới    (monolith bị gỡ bỏ)

Bảy bước tách một module ra khỏi monolith:

  1. Làm module sạch trước trong monolith (mục 3). Nếu module chưa sạch ranh giới, tách nó ra chỉ chuyển mớ rối vào trong mạng.
  2. Tách schema DB trước, tách deployment sau. Chuyển bảng sang schema riêng, gỡ FK xuyên module, sửa mọi truy vấn xuyên module thành gọi qua API nội bộ. Bước này là bước khó nhất — làm xong nó thì 80% công việc đã xong.
  3. Đưa proxy vào để có thể chuyển hướng traffic theo tỉ lệ.
  4. Tách deployment, ban đầu vẫn dùng chung DB (tạm thời, có ghi rõ hạn chót).
  5. Tách DB thật sự. Nhân bản dữ liệu, đọc song song đối chiếu, rồi chuyển ghi.
  6. Chuyển traffic dần 1% → 10% → 50% → 100%, theo dõi lỗi và độ trễ ở mỗi mức.
  7. Xoá code cũ. Nếu bỏ qua bước này, bạn có hai bản logic và sẽ sửa nhầm bản.

Dấu hiệu bạn tách quá sớm: mọi tính năng đều phải sửa nhiều hơn một service. Đó không phải vấn đề thực thi — đó là ranh giới sai, và cách sửa đúng thường là gộp lại.


10. Bài tập — không xây microservices, chuẩn bị cho nó#

Sản phẩm giao là phân tích và một lần tách có kiểm soát, không phải một rừng service.

Yêu cầu.

  1. Biến DA3 thành modular monolith có kiểm soát bằng công cụ: module theo năng lực nghiệp vụ, mỗi module một index.ts công khai, lint chặn import xuyên biên và import vòng. CI phải đỏ khi vi phạm — chứng minh bằng một commit cố tình vi phạm.

    Lời giải và cách kiểm tra

    Cấu trúc thư mục như mục 3; mỗi module chỉ lộ index.ts; cấu hình ESLint ở mục 3 cộng alias @modules/<tên> trong tsconfig (paths, không dùng baseUrl; TS 7 đã gỡ baseUrl):

    jsonReady
    { "compilerOptions": { "paths": { "@modules/*": ["./src/modules/*"] } } }

    paths chỉ dạy TypeScript, không tự resolve lúc chạy (GĐ02 mục 4). Rule import-x/no-cycle cần preset importX.flatConfigs.typescript (resolver TypeScript) thì mới thấy vòng đi qua alias; đã chạy ở mục 3. Chứng minh: tạo nhánh có commit cố ý import @modules/billing/domain/invoice từ ordering, chạy npx eslint src phải thoát mã khác 0 (ở thư mục mẫu mục 3 nó báo lỗi no-restricted-imports đúng dòng đó), và CI cho job đó đỏ. Giữ commit ấy trong nhánh (hoặc ảnh chụp CI) làm bằng chứng.

  2. Tách schema DB theo module (Postgres schema riêng: ordering, billing, identity). Viết một test/query kiểm tra không có FK xuyên schema.

    Lời giải và cách kiểm tra

    Tạo schema bằng migration (CREATE SCHEMA ordering; ALTER TABLE ... SET SCHEMA ordering;). Test không có FK xuyên schema:

    textReady
    SELECT c.conrelid::regclass AS bang, c.confrelid::regclass AS tro_toiFROM pg_constraint cJOIN pg_class a ON a.oid = c.conrelidJOIN pg_class b ON b.oid = c.confrelidWHERE c.contype = 'f' AND a.relnamespace <> b.relnamespace;

    Kết quả mong đợi: 0 hàng; mỗi hàng trả về là một FK cần gỡ (thay bằng cột id thường kèm đối soát, mục 6). Chạy câu này trong test tích hợp (dòng expect(rows).toHaveLength(0)). Chưa chạy trên PostgreSQL thật; câu SQL dùng catalog chuẩn pg_constraint.

    Nếu dùng Prisma, khai báo nhiều schema trong cùng một datasource (PostgreSQL, CockroachDB, SQL Server; không có cho SQLite và MySQL) rồi gắn @@schema cho từng model:

    textReady
    datasource db {  provider = "postgresql"  schemas  = ["ordering", "billing"]}model Order {  id String @id  @@schema("ordering")}

    Theo trang tài liệu Prisma về multi-schema (https://www.prisma.io/docs/orm/prisma-schema/data-model/multi-schema, kiểm ngày 2026-10-05), trang đó không nhắc tới previewFeatures cho tính năng này (Prisma 7); nếu bản bạn dùng báo lỗi, kiểm lại ghi chú phát hành. Chưa chạy prisma migrate ở đây. Hai lưu ý từ docs: chuyển model sang schema khác thì Prisma xoá bảng ở schema cũ và tạo bảng mới (cần migration viết tay nếu muốn giữ dữ liệu), và hai bảng trùng tên ở hai schema phải có tên model khác nhau cùng @@map.

  3. Bản đồ Bounded Context: vẽ các context và ghi rõ một danh từ có nghĩa khác nhau ở mỗi context (như bảng Customer ở mục 4).

    Lời giải và cách kiểm tra

    Mỗi context một hộp, mỗi cặp context một mũi tên ghi kiểu quan hệ (upstream/downstream), và một bảng "danh từ đa nghĩa" theo mẫu Customer ở mục 4 (ví dụ khác: Product trong catalog là mô tả, trong ordering là dòng hàng với giá chốt tại lúc đặt).

  4. Áp dụng 4 phép thử ranh giới cho từng module và ghi kết quả. Kết luận: module nào có thể tách, module nào không nên, vì sao.

    Lời giải và cách kiểm tra

    Khung và ví dụ cách đọc:

    ModuleTransaction chung với module khác?Đổi cùng PR?Một đội sở hữu?Phải hỏi module khác mới trả lời được?Kết luận
    orderingCó, với billing (đặt đơn và giữ tiền)thường cùng billingGiữ cùng billing, hoặc chấp nhận saga
    notifications/mailerKhông (chỉ nhận event)HiếmCóKhông (event mang đủ dữ liệu cần)Có thể tách
    identityKhông (module khác chỉ giữ id người dùng, không chung transaction)Có, mọi module phải hỏi identityKhông tách sớm, nhiều phụ thuộc đồng bộ

    Đọc bảng: chỉ cần một ô "Có" ở phép thử transaction là kết luận "không nên tách" cho cặp đó. Điền bảng bằng module thật của DA3, không dùng nguyên hàng mẫu.

  5. Tách đúng MỘT service — chọn cái độc lập nhất (gợi ý: xử lý ảnh, gửi email, hoặc sinh báo cáo). Giao tiếp bất đồng bộ qua queue, không phải HTTP đồng bộ.

    Lời giải và cách kiểm tra

    Chọn module gần như không cần transaction với phần còn lại (mailer, xử lý ảnh, báo cáo). API chỉ ghi sự kiện vào outbox rồi trả 202; service mới là consumer của queue và có state riêng (kể cả chỉ là bảng processed_events).

  6. Phát event qua outbox; consumer idempotent; có test chứng minh xử lý hai lần vẫn đúng.

    Lời giải và cách kiểm tra

    Dùng cơ chế ở GĐ10 mục 4 và đoạn SQL ở mục 6 của bài này. Test: gửi cùng eventId hai lần vào consumer, khẳng định tác dụng phụ (một email, một hàng) chỉ xảy ra một lần.

  7. Distributed tracing xuyên hai service — chụp một trace đi từ HTTP request qua queue tới service mới và quay lại.

    Lời giải và cách kiểm tra

    Truyền traceparent qua payload như GĐ19 mục 9. Nghiệm thu: trong Jaeger, một traceId chứa span HTTP của API, span publish, và span consumer của service mới.

  8. Contract test giữa hai service (Pact hoặc một schema test đơn giản). Chứng minh nó bắt được lỗi bằng cách cố tình đổi response và cho thấy CI đỏ.

    Lời giải và cách kiểm tra

    Dùng schema test hoặc Pact ở mục 7 (đều đã chạy; với Pact, lệnh Verifier đỏ khi provider đổi tên field). Bằng chứng: commit đổi tên một field trong response/event làm CI của provider đỏ, rồi commit hoàn lại làm CI xanh.

  9. Đo trước/sau và ghi vào README: thời gian build, thời gian test, p95 latency của luồng liên quan, số bước để deploy, thời gian dựng môi trường dev.

    Lời giải và cách kiểm tra

    Đo cả hai lần theo cùng cách và ghi cách đo:

    Chỉ sốTrước táchSau táchCách đo
    Thời gian buildtime hoặc thời lượng job CI, lấy trung vị 5 lần
    Thời gian testnhư trên
    p95 luồng liên quank6 hoặc log, cùng tải
    Số bước deployđếm bước trong runbook
    Dựng môi trường devtừ clone đến chạy được, tính bằng phút

    Mong đợi (suy ra từ mục 2, không phải số đo): số bước deploy và thời gian dựng môi trường dev tăng; p95 luồng bất đồng bộ có thể không đổi vì việc đã ra khỏi đường request.

  10. Viết ADR (docs/decisions/): "Vì sao chúng tôi tách service X mà không tách Y" — kèm số liệu từ mục 9.

    Lời giải và cách kiểm tra

    Khung docs/decisions/00N-tach-service.md:

    textReady
    # Tách mailer-service, không tách orderingBối cảnh: <nỗi đau thật, ví dụ spike gửi email làm chậm API>.Quyết định: tách mailer vì qua cả 4 phép thử; giữ ordering trong monolith vì phép thử transaction với billing.Số liệu: <bảng yêu cầu 9>.Hậu quả: thêm 1 pipeline CI/CD, 1 dashboard; mất transaction với mailer (chấp nhận, nhờ outbox).Điều kiện xem lại: <ví dụ khi hai đội cùng chặn nhau trên ordering>.
Khung và mã dùng chung

Bài này là phân tích cộng một lần tách nhỏ. Chưa chạy: bài làm trên DA3 của người học, không có trong repo này. Các số đo ở yêu cầu 9 do bạn tự đo; bảng dưới chỉ có khung, không có số mẫu, vì số bịa ra sẽ dẫn đi sai.

Sơ đồ đích.

textReady
 Trước:  api (1 deployment)  ──────────────► Postgres (1 schema public) Sau:    api (modular monolith)                       Postgres         ┌───────────────────────────┐                ├─ schema ordering         │ modules/ordering          │──────────────► ├─ schema billing         │ modules/billing           │                ├─ schema identity         │ modules/identity          │                └─ schema outbox         └──────────┬────────────────┘                    │ outbox -> queue (bất đồng bộ)                    ▼         mailer-service (MỘT service, DB/state riêng, consumer idempotent)

Lỗi hay gặp. Lint chỉ chặn import theo alias nên import tương đối lọt (mục 3). FK xuyên schema còn sót vì ORM tạo quan hệ. Chọn service tách có phép thử transaction "Có" rồi phải làm saga. Consumer quên khử trùng lặp. Không ghi số đo "trước" nên không có gì để so.

Mục 9 là mục giá trị nhất và hầu như không ai làm. Con số thật về chi phí của một lần tách service là thứ khiến bạn — và người phỏng vấn bạn — nhìn kiến trúc bằng dữ liệu thay vì bằng niềm tin.

11. Envelope và inbox: consumer khử trùng lặp chạy được#

Mục 5 liệt kê quy tắc event, mục 6 đưa SQL khử trùng lặp. Mục này ghép hai thứ thành kiểu TypeScript cho envelope và consumer dùng bảng inbox, chạy thật trên node:sqlite (SQLite có sẵn trong Node, không cần cài gì).

Envelope là phần bọc chung cho mọi event: metadata để định tuyến, khử trùng lặp, tiến hoá phiên bản và nối trace; data là thân nghiệp vụ. Inbox là bảng phía consumer ghi event_id đã xử lý, trong cùng transaction với tác dụng phụ. Hai việc này tách khỏi nhau (event đã ghi inbox mà tác dụng phụ chưa xong, hoặc ngược lại) thì mất đúng điều bảng inbox sinh ra để bảo đảm.

Code và kết quả đã chạy: envelope, inbox, giao trùng, chết giữa chừng, version lạ
typescriptReady
import { DatabaseSync } from 'node:sqlite'// Envelope: phần bọc chung cho mọi event; `data` mới là thân nghiệp vụ.export type Envelope<T> = {  eventId: string        // khoá khử trùng lặp, sinh ở producer (UUID/ULID)  type: string           // thì quá khứ: 'order.placed'  version: number        // tăng khi đổi hình dạng của `data`  occurredAt: string     // ISO 8601, UTC  traceparent?: string   // nối trace qua queue (GĐ19 mục 9)  data: T}type OrderPlaced = { orderId: string; totalMinor: number; currency: string }const db = new DatabaseSync(':memory:')db.exec(`  CREATE TABLE inbox (event_id TEXT PRIMARY KEY, handled_at TEXT NOT NULL);  CREATE TABLE invoices (id INTEGER PRIMARY KEY, order_id TEXT NOT NULL, total_minor INTEGER NOT NULL);`)type Result = 'handled' | 'duplicate' | 'rejected-version'// Inbox: ghi event_id VÀ tác dụng phụ trong CÙNG transaction của consumer.function handle(env: Envelope<OrderPlaced>, failAfterInbox = false): Result {  if (env.type !== 'order.placed') throw new Error(`type lạ: ${env.type}`)  if (env.version !== 1) return 'rejected-version'      // version lớn hơn: không đoán, đưa vào DLQ  db.exec('BEGIN')  try {    const r = db.prepare('INSERT OR IGNORE INTO inbox VALUES (?, ?)').run(env.eventId, new Date().toISOString())    if (r.changes === 0) { db.exec('ROLLBACK'); return 'duplicate' }    if (failAfterInbox) throw new Error('chết giữa chừng')    db.prepare('INSERT INTO invoices (order_id, total_minor) VALUES (?, ?)').run(env.data.orderId, env.data.totalMinor)    db.exec('COMMIT')    return 'handled'  } catch (e) {    db.exec('ROLLBACK')    throw e  }}const ev = (eventId: string, version = 1): Envelope<OrderPlaced> => ({  eventId, type: 'order.placed', version, occurredAt: '2026-10-05T00:00:00Z',  data: { orderId: 'o1', totalMinor: 250000, currency: 'VND' },})const count = (t: string) => (db.prepare(`SELECT count(*) AS n FROM ${t}`).get() as { n: number }).nconsole.log('lần 1         :', handle(ev('e1')))console.log('lần 2 (trùng) :', handle(ev('e1')))console.log('hàng invoices :', count('invoices'), ' hàng inbox:', count('inbox'))try { handle(ev('e2'), true) } catch { console.log('e2 chết giữa chừng; inbox còn:', count('inbox'), ' invoices:', count('invoices')) }console.log('e2 giao lại   :', handle(ev('e2')), '-> invoices:', count('invoices'))console.log('version 2     :', handle(ev('e3', 2)))

Đã chạy (Node 24.21.0, node:sqlite, tsc --strict sạch):

Tình huốngKết quả
Giao e1 lần 1 rồi lần 2handled, rồi duplicate; invoices có 1 hàng, inbox có 1 hàng
e2 chết sau khi ghi inbox, trước khi ghi hoá đơnROLLBACK xoá luôn dòng inbox: inbox vẫn 1 hàng, invoices vẫn 1 hàng
Giao lại e2handled, invoices lên 2 hàng (không bị nuốt mất)
Event version: 2rejected-version, không ghi gì

Hai quyết định thiết kế. Version lạ: consumer không đoán, chuyển sang hàng đợi lỗi (DLQ) cho người xem, vì field mới có thể đổi nghĩa; field lạ thêm vào trong cùng version thì bỏ qua (quy tắc Postel ở mục 7). Bảng inbox lớn dần: cần job dọn các dòng cũ hơn thời hạn lớn nhất mà broker có thể giao lại, vì xoá sớm quá thì event giao lại bị xử lý hai lần. Chưa chạy trên PostgreSQL: cú pháp INSERT OR IGNORE của SQLite tương đương INSERT ... ON CONFLICT DO NOTHING của mục 6.


12. Saga bằng bảng trạng thái: chạy tiếp sau khi orchestrator sập#

GĐ19 mục 8 chạy saga trong bộ nhớ, nên orchestrator sập là mất sạch trạng thái. Orchestrator thật phải lưu trạng thái trước và sau mỗi bước để một tiến trình khác chạy tiếp từ chỗ dở. Hai bảng là đủ: saga (trạng thái tổng: RUNNING, COMPENSATING, COMPLETED, COMPENSATED) và saga_step (mỗi bước DONE hay COMPENSATED). Câu hỏi "đơn #123 kẹt ở đâu" trở thành một câu SELECT.

Một điểm tinh tế: có khoảng hở giữa tác dụng phụ đã xảy ra và dòng DONE đã ghi. Sập đúng ở đó thì lần chạy tiếp thấy bước chưa DONE và chạy lại nó. Vì vậy mỗi bước phải idempotent theo khoá (bước:sagaId) ở phía service nhận, như đã nói ở GĐ19.

Code và kết quả đã chạy: sập giữa chừng, khởi động lại, chạy tiếp
typescriptReady
import { DatabaseSync } from 'node:sqlite'import { rmSync } from 'node:fs'const FILE = './saga-demo.db'rmSync(FILE, { force: true })class Crash extends Error {}          // mô phỏng tiến trình orchestrator bị giếtclass OutOfStock extends Error {}     // lỗi nghiệp vụ: bước giữ kho thất bạifunction open(): DatabaseSync {  const db = new DatabaseSync(FILE)  db.exec(`    CREATE TABLE IF NOT EXISTS saga (id TEXT PRIMARY KEY, state TEXT NOT NULL);    CREATE TABLE IF NOT EXISTS saga_step (      saga_id TEXT NOT NULL, step TEXT NOT NULL, status TEXT NOT NULL,      PRIMARY KEY (saga_id, step));    CREATE TABLE IF NOT EXISTS effects (key TEXT PRIMARY KEY);  -- tác dụng phụ ở "service" ngoài  `)  return db}const attempts = new Map<string, number>()type Faults = { crashAfter?: string; crashAfterUndo?: string; outOfStock?: boolean }function advance(db: DatabaseSync, id: string, faults: Faults): string {  const names = ['tao-don', 'tru-tien', 'giu-kho', 'xep-giao']  const q = <T>(sql: string, ...p: string[]) => db.prepare(sql).get(...p) as T | undefined  const state = () => q<{ state: string }>('SELECT state FROM saga WHERE id = ?', id)!.state  const setState = (s: string) => db.prepare('UPDATE saga SET state = ? WHERE id = ?').run(s, id)  const status = (n: string) =>    q<{ status: string }>('SELECT status FROM saga_step WHERE saga_id = ? AND step = ?', id, n)?.status  // Thế giới ngoài chống trùng bằng khoá: chạy lại cùng khoá không tạo thêm tác dụng phụ.  const effect = (key: string) => db.prepare('INSERT OR IGNORE INTO effects (key) VALUES (?)').run(key)  db.prepare('INSERT OR IGNORE INTO saga (id, state) VALUES (?, ?)').run(id, 'RUNNING')  if (state() === 'RUNNING') {    for (const n of names) {      if (status(n)) continue                         // đã DONE ở lần chạy trước (kể cả trước khi sập)      attempts.set(`${n}:${id}`, (attempts.get(`${n}:${id}`) ?? 0) + 1)      try {        if (n === 'giu-kho' && faults.outOfStock) throw new OutOfStock()        effect(`+${n}:${id}`)        if (faults.crashAfter === n) { faults.crashAfter = undefined; throw new Crash() }        db.prepare('INSERT INTO saga_step VALUES (?, ?, ?)').run(id, n, 'DONE')      } catch (e) {        if (!(e instanceof OutOfStock)) throw e        setState('COMPENSATING')        break      }    }    if (state() === 'RUNNING') setState('COMPLETED')  }  if (state() === 'COMPENSATING') {    for (const n of [...names].reverse()) {      if (status(n) !== 'DONE') continue      effect(`-${n}:${id}`)                           // bồi hoàn là giao dịch MỚI, không phải rollback      if (faults.crashAfterUndo === n) { faults.crashAfterUndo = undefined; throw new Crash() }      db.prepare('UPDATE saga_step SET status = ? WHERE saga_id = ? AND step = ?').run('COMPENSATED', id, n)    }    setState('COMPENSATED')  }  return state()}function show(db: DatabaseSync, id: string, label: string) {  const s = db.prepare('SELECT state FROM saga WHERE id = ?').get(id) as { state: string }  const steps = db.prepare('SELECT step, status FROM saga_step WHERE saga_id = ? ORDER BY rowid').all(id)  const fx = db.prepare('SELECT key FROM effects WHERE key LIKE ? ORDER BY rowid').all(`%:${id}`)  console.log(label, s.state, JSON.stringify(steps.map((r) => `${r.step}=${r.status}`)),    'effects:', fx.map((r) => r.key).join(' '))}// 1) Chạy trơnlet db = open()advance(db, 's1', {}); show(db, 's1', 's1 chạy trơn      ')// 2) Sập sau khi trừ tiền xong nhưng TRƯỚC khi ghi DONEconst f2: Faults = { crashAfter: 'tru-tien' }try { advance(db, 's2', f2) } catch (e) { if (!(e instanceof Crash)) throw e }show(db, 's2', 's2 vừa sập         ')db.close(); db = open()                              // "khởi động lại": mở lại fileadvance(db, 's2', f2); show(db, 's2', 's2 sau khi chạy tiếp')console.log('s2 số lần thử tru-tien:', attempts.get('tru-tien:s2'))// 3) Hết kho, rồi sập giữa lúc bồi hoànconst f3: Faults = { outOfStock: true, crashAfterUndo: 'tru-tien' }try { advance(db, 's3', f3) } catch (e) { if (!(e instanceof Crash)) throw e }show(db, 's3', 's3 vừa sập         ')db.close(); db = open()advance(db, 's3', f3); show(db, 's3', 's3 sau khi chạy tiếp')db.close()

Đã chạy (Node 24.21.0, node:sqlite ghi ra một file thật rồi đóng và mở lại file để mô phỏng khởi động lại; tsc --strict sạch). Chưa chạy trên Postgres:

SagaTình huốngKết quả
s1Chạy trơnCOMPLETED, đủ 4 bước DONE, mỗi tác dụng phụ đúng một lần
s2Sập sau khi trừ tiền, trước khi ghi DONENgay sau khi sập: RUNNING, chỉ tao-don=DONE nhưng bảng tác dụng phụ đã có +tru-tien. Sau khi mở lại và chạy tiếp: COMPLETED, tru-tien được thử 2 lần nhưng chỉ một +tru-tien:s2 nhờ khoá
s3Hết kho (giu-kho lỗi), rồi sập giữa lúc hoàn tiềnNgay sau khi sập: COMPENSATING, đã có -tru-tien nhưng chưa ghi COMPENSATED. Sau khi chạy tiếp: COMPENSATED, đủ -tru-tien và -tao-don, mỗi cái một lần

Bản này đủ để hiểu cơ chế, nhưng còn thiếu: thử lại có giới hạn và backoff cho lỗi tạm, hạn chót (timeout) cho bước treo, và khoá để hai orchestrator không cùng chạy một saga (dùng SELECT ... FOR UPDATE SKIP LOCKED hoặc lease trên dòng saga; chưa chạy).

Khi nào tự viết, khi nào dùng công cụ. Bảng trạng thái tự viết đủ cho vài saga ngắn. Khi quy trình dài, có chờ con người, hẹn giờ hoặc nhiều nhánh, hãy dùng engine workflow bền vững thay vì tự mở rộng: Temporal, AWS Step Functions (cùng nhóm dịch vụ với GĐ30) hoặc Cloudflare Workflows (GĐ17). Chúng làm sẵn đúng phần bảng này làm: lưu trạng thái từng bước, chạy tiếp sau sập, thử lại có backoff; đổi lại bạn học thêm một mô hình lập trình và chịu ràng buộc của nó (ví dụ phần mã điều phối phải tất định). Chưa chạy: cả ba engine.


13. Service gọi service: xác thực và phân quyền nội bộ#

Gateway (mục 8 ở trên) xác thực người dùng ngoài. Bên trong, câu hỏi khác: khi ordering gọi billing, billing biết ai đang gọi (xác thực, authN) và được phép làm gì (phân quyền, authZ) bằng cách nào? "Mạng nội bộ nên đáng tin" là ngộ nhận "mạng an toàn" trong tám ngộ nhận của GĐ19: một service bị chiếm là kẻ tấn công đi ngang mọi service khác.

CáchCho biết gìChi phí
mTLS (hai bên cùng trình chứng chỉ)Danh tính của tiến trình/workload ở tầng kết nốiCấp và xoay chứng chỉ; thường để service mesh hoặc hạ tầng làm (mục 8 ở trên)
Token cấp cho service (JWT ngắn hạn có sub = tên service, aud = service đích)Danh tính ở tầng ứng dụng, mang được qua queue và qua proxyCần nơi ký token và phân phối khoá công khai
Chia sẻ một API key chungGần như không gì (ai có key cũng giống nhau)Rẻ nhưng không dùng được cho phân quyền theo caller

Hai cách đầu thường dùng kết hợp: mTLS cho kênh, token cho danh tính và phân quyền theo từng cặp caller-route. Điểm then chốt của token là audience: token cấp cho billing mà bị catalog nhận được thì catalog không được dùng nó để gọi billing thay mặt ai, và billing từ chối token ghi audience khác.

Danh tính người dùng gốc. Khi ordering gọi billing thay mặt người dùng u42, billing cần biết cả hai: caller (svc:ordering, dùng để phân quyền service) và người dùng gốc (để kiểm tra dữ liệu của u42). Không gói chung làm một: service chỉ nên được làm điều cả hai cùng cho phép, tránh ordering tự nhận mình là u42 mà không có bằng chứng (cách làm chuẩn là chuyển tiếp token người dùng hoặc dùng token exchange, chưa chạy ở đây).

Code và kết quả đã chạy: token service ngắn hạn, kiểm tra audience và caller
typescriptReady
import { SignJWT, jwtVerify, generateKeyPair } from 'jose'// Mỗi service có cặp khoá riêng; service nhận chỉ cần khoá công khai của issuer.const { publicKey, privateKey } = await generateKeyPair('ES256')const ISSUER = 'urn:auth.internal'async function mintServiceToken(caller: string, audience: string, user?: string): Promise<string> {  const jwt = new SignJWT(user ? { act_for: user } : {})   // danh tính người dùng gốc, nếu có    .setProtectedHeader({ alg: 'ES256' })    .setIssuer(ISSUER).setSubject(caller).setAudience(audience)    .setIssuedAt().setExpirationTime('60s')                 // ngắn: lộ ra cũng hết hạn nhanh  return jwt.sign(privateKey)}// Phía billing: xác thực (token hợp lệ, đúng audience) rồi phân quyền (caller nào được làm gì).const ALLOWED: Record<string, string[]> = { 'POST /invoices': ['svc:ordering'] }async function billingAuthorize(route: string, token: string) {  const { payload } = await jwtVerify(token, publicKey, { issuer: ISSUER, audience: 'svc:billing' })  if (!ALLOWED[route]?.includes(payload.sub ?? '')) throw new Error(`403 ${payload.sub} không được ${route}`)  return payload}const attempt = async (label: string, route: string, token: string) => {  try {    const p = await billingAuthorize(route, token)    console.log(label, '-> 200, caller =', p.sub, ', người dùng =', p.act_for ?? '(không)')  } catch (e) { console.log(label, '->', (e as Error).message) }}await attempt('ordering gọi billing           ', 'POST /invoices', await mintServiceToken('svc:ordering', 'svc:billing', 'u42'))await attempt('mailer gọi billing             ', 'POST /invoices', await mintServiceToken('svc:mailer', 'svc:billing'))await attempt('token cấp cho catalog bị dùng lại', 'POST /invoices', await mintServiceToken('svc:ordering', 'svc:catalog'))const expired = await new SignJWT({}).setProtectedHeader({ alg: 'ES256' }).setIssuer(ISSUER)  .setSubject('svc:ordering').setAudience('svc:billing').setIssuedAt(Math.floor(Date.now() / 1000) - 120)  .setExpirationTime(Math.floor(Date.now() / 1000) - 60).sign(privateKey)await attempt('token đã hết hạn               ', 'POST /invoices', expired)

Đã chạy (Node 24.21.0, jose 6.2.12, tsc --strict sạch; khoá sinh tại chỗ để demo, thực tế khoá riêng nằm ở nơi cấp token và khoá công khai phân phối qua JWKS):

Cuộc gọiKết quả
ordering gọi billing200, caller svc:ordering, người dùng gốc u42
mailer gọi billing (token hợp lệ, nhưng caller không nằm trong danh sách)403 svc:mailer không được POST /invoices (authZ)
Token cấp cho catalog bị đem gọi billingunexpected "aud" claim value (authN: sai audience)
Token hết hạn"exp" claim timestamp check failed

Còn thiếu so với production: xoay khoá và lấy khoá qua JWKS (createRemoteJWKSet), chống phát lại token trong 60 giây (thêm jti nếu thao tác không idempotent), và mTLS ở tầng mạng; chưa chạy.


Done khi#

  • Định nghĩa microservices và giải thích vì sao triển khai độc lập là điều kiện cốt lõi

    Đáp án

    Nhiều service nhỏ, mỗi service sở hữu dữ liệu, giao tiếp qua mạng, deploy riêng. Nếu phải deploy năm service cùng lúc mới chạy, bạn không có độc lập, chỉ có chi phí mạng. Sai thường gặp: định nghĩa bằng "service nhỏ". Xem GĐ20 mục 1.

  • Nhận ra monolith phân tán và vì sao nó tệ hơn cả hai lựa chọn

    Đáp án

    Tách ra nhiều process nhưng vẫn thay đổi và deploy đồng bộ (thêm một trường phải sửa 4 service). Tệ hơn monolith (đủ chi phí mạng, saga, tracing) và tệ hơn microservices đúng nghĩa (không có độc lập). Dấu hiệu: một tính năng chạm nhiều repo. Xem GĐ20 mục 4.

  • Phát biểu Luật Conway và hệ quả cho việc chọn ranh giới

    Đáp án

    Hệ thống sao chép cấu trúc giao tiếp của tổ chức tạo ra nó. Hệ quả: chọn ranh giới khớp ranh giới đội; ranh giới không có đội sở hữu thì không ai chăm sóc. Cách làm ngược: tổ chức đội theo kiến trúc mong muốn. Xem GĐ20 mục 1.

  • Liệt kê được ít nhất 5 chi phí thật, trong đó có mất transaction và debug xuyên service

    Đáp án

    Mất transaction (saga), debug xuyên service (cần tracing), test (dựng cả hệ hay mock), versioning API, N pipeline và N on-call; thêm cascading failure. Hai cái bắt buộc có trong câu trả lời: mất transaction và debug xuyên service. Xem bảng ở GĐ20 mục 2.

  • Kể được 6 thứ hạ tầng bắt buộc phải có trước service thứ hai

    Đáp án

    CI/CD tự động hoàn toàn, distributed tracing, log tập trung, service discovery, registry cho API contract, dựng toàn bộ hệ cục bộ bằng một lệnh. Thiếu thì tách làm chậm hơn. Xem GĐ20 mục 2.

  • Xây được modular monolith với ranh giới thực thi bằng lint, không bằng kỷ luật

    Đáp án

    Chỉ index.ts công khai, lint cấm import xuyên module và import vòng, test cấm truy vấn bảng của module khác. Cách tự kiểm: một commit cố ý vi phạm làm CI đỏ. Đã chạy cấu hình mục 3: import alias xuyên module và vòng giữa hai module bị chặn, import tương đối lọt nên cần quy ước alias; thiếu preset resolver thì no-cycle im lặng. Xem GĐ20 mục 3.

  • Giải thích vì sao tách theo tầng kỹ thuật là sai và theo năng lực nghiệp vụ là đúng

    Đáp án

    Tầng (user-service, database-service): mọi tính năng chạm nhiều service. Năng lực (ordering, billing): mỗi service giữ trọn dữ liệu, quy tắc và một đội. Xem GĐ20 mục 4.

  • Dùng Bounded Context để tìm ranh giới; cho được ví dụ một danh từ đa nghĩa

    Đáp án

    Cùng một từ nghĩa khác nhau theo ngữ cảnh, và ranh giới nghĩa là ranh giới service. Customer: người đặt hàng (ordering), bên trả tiền có hạn mức (billing), người nhận có địa chỉ (shipping). Xem bảng ở GĐ20 mục 4.

  • Áp dụng được 4 phép thử ranh giới, đặc biệt là phép thử transaction

    Đáp án

    Theo thứ tự: transaction (cần nguyên tử thì cùng service), thay đổi (cùng PR thì cùng service), đội (một đội sở hữu được không), dữ liệu (có phải hỏi service khác mới trả lời không). Phép thử transaction mạnh nhất: một ô "Có" là dừng. Xem bảng khung ở lời giải bài tập mục 10 của GĐ20.

  • Nhận ra phản mẫu nano-service

    Đáp án

    Service vài endpoint, không sở hữu dữ liệu đáng kể, chỉ thêm một round-trip. Phép thử nhanh: nó có DB riêng không; không thì có lẽ không nên là service. Xem GĐ20 mục 4.

  • Chọn đúng giữa đồng bộ và bất đồng bộ; giải thích vì sao availability nhân nhau trong chuỗi gọi

    Đáp án

    Mặc định bất đồng bộ, đồng bộ khi cần câu trả lời để tiếp tục request. Chuỗi 4 service 99,9%: 0,999⁴ = 99,60%, thời gian chết 35,0 giờ/năm so với 8,76 giờ (gấp 4); thêm 4 × 50 ms = 200 ms. Tính ở GĐ20 mục 5.

  • Phân biệt event notification vs event-carried state transfer

    Đáp án

    Notification mỏng (id, người nhận hỏi lại): ghép lỏng nhất nhưng gây N+1 qua mạng. Event-carried: mang đủ trường ổn định, giảm gọi lại nhưng dữ liệu nhân bản và có thể cũ. Thực dụng: mang trường ổn định, hỏi lại trường hay đổi. Xem GĐ20 mục 5.

  • Đặt tên event ở thì quá khứ, có eventId, version; consumer idempotent

    Đáp án

    order.placed (quá khứ, sự thật đã xảy ra, không phải mệnh lệnh), eventId để khử trùng lặp, version để đổi hình dạng mà không phá consumer cũ. Consumer idempotent vì giao hàng là at-least-once. Xem GĐ20 mục 5 và GĐ10 mục 3.

  • Giải thích vì sao không service nào đọc bảng của service khác

    Đáp án

    Chia sẻ DB là đường nhanh về monolith phân tán: bạn không đổi được schema mà không phá service khác, mất lý do tách. Truy cập chỉ qua API hoặc event. Xem GĐ20 mục 6.

  • Nêu 3 cách thay thế JOIN xuyên service và đánh đổi của mỗi cách

    Đáp án

    API composition (đơn giản, N+1 qua mạng), sao chép read model qua event (nhanh, có thể cũ và phải xử lý drift), CQRS read model riêng (mạnh, đắt). Xem GĐ20 mục 6.

  • Dùng outbox để phát event đáng tin cậy

    Đáp án

    Ghi event vào bảng outbox cùng transaction với thay đổi dữ liệu, relay đẩy sang broker sau, nên không có trường hợp "đã commit mà event mất". Vẫn at-least-once nên consumer khử trùng lặp bằng UNIQUE trên event_id. Sơ đồ và SQL ở GĐ20 mục 6.

  • Giải thích contract testing và vì sao nó bắt lỗi trước khi deploy

    Đáp án

    Consumer mô tả đúng phần nó dùng, provider chạy mọi contract trong CI, nên đổi tên field làm CI của provider đỏ ngay lúc build, không cần dựng cả hệ. Ví dụ tối giản bằng zod và bản Pact chạy được (consumer sinh pact, provider xác minh, đổi tên field thì đỏ) ở GĐ20 mục 7.

  • Phân loại được thay đổi API nào an toàn, nào phá vỡ; biết quy trình gỡ field 3 bước

    Đáp án

    An toàn: thêm field tuỳ chọn. Phá vỡ: đổi tên/xoá field, đổi kiểu, siết validation (enum mới: chỉ khi consumer chịu được giá trị lạ). Gỡ field: thêm mới và ghi cả hai, đo xem ai còn đọc field cũ, xoá khi số đo bằng 0 đủ lâu. Xem GĐ20 mục 7.

  • Biết cái gì thuộc gateway, cái gì không; nhận ra bẫy gateway phình to

    Đáp án

    Thuộc gateway: TLS termination, xác thực, rate limit toàn cục, định tuyến, request ID. Thuộc service: logic nghiệp vụ và phân quyền chi tiết. Gateway chứa nghiệp vụ trở thành điểm nghẽn tổ chức nên giữ nó "ngu". Xem GĐ20 mục 8.

  • Mô tả được Strangler Fig và 7 bước tách service

    Đáp án

    Proxy chuyển dần traffic từ monolith sang service mới. Bảy bước: làm module sạch, tách schema trước, đưa proxy vào, tách deployment (chung DB tạm), tách DB thật, chuyển traffic 1% → 10% → 50% → 100%, xoá code cũ. Sơ đồ có sẵn ở GĐ20 mục 9.

  • Biết dấu hiệu tách quá sớm và rằng cách sửa thường là gộp lại

    Đáp án

    Mọi tính năng phải sửa hơn một service. Đó là ranh giới sai, và cách sửa thường là gộp lại thành một module (rồi tách lại theo ranh giới đúng nếu cần). Xem GĐ20 mục 9.

  • Viết được consumer test Pact và chạy provider verification, và chỉ ra thứ gì làm nó đỏ

    Đáp án

    Consumer test dùng PactV3 cho client thật của mình, mock server sinh pacts/<consumer>-<provider>.json; provider chạy Verifier với pactUrls và stateHandlers vào server thật. Provider đổi totalMinor thành total thì verification báo missing the following keys: totalMinor; thêm field lạ thì vẫn xanh. Sai thường gặp: tưởng matcher kiểm đúng giá trị (nó kiểm hình dạng), hoặc test consumer gọi fetch trần thay vì client thật. Xem GĐ20 mục 7.

  • Viết được envelope của event và consumer dùng inbox trong cùng transaction với tác dụng phụ

    Đáp án

    Envelope có eventId, type (quá khứ), version, occurredAt, tuỳ chọn traceparent, và data. Consumer chèn event_id vào inbox và ghi tác dụng phụ trong một transaction: giao trùng thì duplicate; chết giữa chừng thì rollback cả hai nên giao lại vẫn xử lý được; version lạ thì chuyển DLQ chứ không đoán. Sai thường gặp: ghi inbox ngoài transaction, khiến event bị "xử lý" mà tác dụng phụ chưa xảy ra. Xem GĐ20 mục 11.

  • Mô tả và chạy được saga lưu bảng trạng thái để orchestrator sập vẫn chạy tiếp được

    Đáp án

    Bảng saga (trạng thái tổng) và saga_step (bước nào DONE hay COMPENSATED) được ghi sau mỗi bước; tiến trình mới đọc bảng rồi làm tiếp. Sập giữa tác dụng phụ và dòng DONE thì bước bị chạy lại, nên mỗi bước phải idempotent theo khoá. Cách tự kiểm: giết giữa chừng ở bước hoàn tiền rồi chạy lại, tác dụng phụ mỗi loại chỉ một lần. Biết khi nào dùng Temporal, Step Functions hay Cloudflare Workflows thay vì tự viết. Xem GĐ20 mục 12.

  • Phân biệt xác thực và phân quyền giữa service với service; giải thích vai trò của aud

    Đáp án

    Xác thực: token hợp lệ, chưa hết hạn, đúng issuer và đúng audience. Phân quyền: caller (sub) nào được làm route nào. Token cấp cho catalog bị billing từ chối vì sai audience; mailer có token hợp lệ vẫn bị 403 nếu không nằm trong danh sách. mTLS cho danh tính ở tầng kết nối, token cho danh tính ở tầng ứng dụng; thường dùng cả hai. Sai thường gặp: coi mạng nội bộ là đáng tin, hoặc dùng một API key chung. Xem GĐ20 mục 13.

  • Trả lời được câu phỏng vấn: "khi nào anh tách microservice?" — bắt đầu bằng "hầu hết trường hợp là không", kèm điều kiện cụ thể

    Đáp án

    Khung trả lời mẫu: "Hầu hết trường hợp là không. Tôi tách khi (1) nhiều đội giẫm chân nhau khi deploy, (2) một phần cần scale hoặc công nghệ khác hẳn, (3) cần cách ly lỗi vùng nghiêm ngặt như thanh toán, và chỉ sau khi đã có CI/CD, tracing, log tập trung. Trước đó tôi dùng modular monolith, và tôi qua phép thử transaction trước khi vẽ ranh giới." Sai thường gặp: trả lời bằng "để scale" hoặc "cho hiện đại". Xem GĐ20 mục 1 và 9.


Câu hỏi mở / chưa giải quyết#

  • Ngưỡng đội bao nhiêu thì nên tách? Không có con số cứng. Tín hiệu thường được nhắc: một đội (~8 người, "two-pizza team") nên sở hữu trọn vẹn một service; khi hai đội thường xuyên chặn nhau ở cùng một vùng code và cùng một lịch deploy, đó là tín hiệu thật.

    Hướng trả lời hiện tại

    Chưa phải kết luận. Dùng tín hiệu thay vì con số: tách khi hai đội thường xuyên chặn nhau trên cùng vùng code và lịch deploy, và các phép thử ranh giới đều qua.

  • Serverless như một mức tách khác (Lambda/Cloud Run per function) đổi bài toán vận hành lấy cold start, giới hạn thời gian chạy, và mô hình chi phí rất khác. Ngoài lộ trình này.

  • Backend for Frontend (BFF) — một service riêng cho mỗi loại client (web/mobile) — thường là bước tách đầu tiên hợp lý và ít rủi ro nhất, vì nó không sở hữu dữ liệu. Đáng cân nhắc trước bất kỳ lần tách nào khác.

  • Câu hỏi chưa có lời giải đẹp: test tích hợp trong hệ nhiều service. Contract testing giải quyết được contract đôi một, nhưng không bắt được lỗi ở mức luồng nghiệp vụ xuyên 4 service. Thực tế phần lớn công ty chấp nhận một môi trường staging đầy đủ + test khói, với mọi hạn chế của nó.

    Hướng trả lời hiện tại

    Chưa phải kết luận. Hướng hợp lý là kim tự tháp: nhiều contract test đôi một, ít test luồng nghiệp vụ trên staging cố định, cộng synthetic check ở production cho các luồng sống còn (đặt hàng, thanh toán). Cách này không bắt hết lỗi xuyên 4 service; phần còn lại dựa vào tracing và khả năng rollback nhanh.