Skip to content

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

GĐ17 — 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Đ16. 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Đ16 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/DA4.

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

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).
jsonc
// .eslintrc — biến quy ước thành lỗi build
{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": [{
        "group": ["**/modules/*/domain/**", "**/modules/*/infrastructure/**"],
        "message": "Chỉ import module khác qua index.ts công khai"
      }]
    }],
    "import/no-cycle": "error"
  }
}

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.

❌ 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).

✅ 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:

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ý.

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

ts
// 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).
  • 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Đ9).

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Đ10).
  • 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Đ16 mục 8).

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Đ9 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.


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:

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 broker
3. CI của Provider (B) chạy MỌI hợp đồng của MỌI consumer
4. 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.

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Đ13):

  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.

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Đ18) 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".

Giai đoạn 1:  Client → Monolith
Giai đ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.
  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.
  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).
  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.
  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ộ.
  6. Phát event qua outbox; consumer idempotent; có test chứng minh xử lý hai lần vẫn đúng.
  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.
  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 đỏ.
  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.
  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.

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.


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
  • [ ] Nhận ra monolith phân tán và vì sao nó tệ hơn cả hai lựa chọn
  • [ ] Phát biểu Luật Conway và hệ quả cho việc chọn ranh giới
  • [ ] Liệt kê được ít nhất 5 chi phí thật, trong đó có mất transactiondebug xuyên service
  • [ ] Kể được 6 thứ hạ tầng bắt buộc phải có trước service thứ hai
  • [ ] Xây được modular monolith với ranh giới thực thi bằng lint, không bằng kỷ luật
  • [ ] 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
  • [ ] Dùng Bounded Context để tìm ranh giới; cho được ví dụ một danh từ đa nghĩa
  • [ ] Áp dụng được 4 phép thử ranh giới, đặc biệt là phép thử transaction
  • [ ] Nhận ra phản mẫu nano-service
  • [ ] 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
  • [ ] Phân biệt event notification vs event-carried state transfer
  • [ ] Đặt tên event ở thì quá khứ, có eventId, version; consumer idempotent
  • [ ] Giải thích vì sao không service nào đọc bảng của service khác
  • [ ] Nêu 3 cách thay thế JOIN xuyên service và đánh đổi của mỗi cách
  • [ ] Dùng outbox để phát event đáng tin cậy
  • [ ] Giải thích contract testing và vì sao nó bắt lỗi trước khi deploy
  • [ ] 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
  • [ ] Biết cái gì thuộc gateway, cái gì không; nhận ra bẫy gateway phình to
  • [ ] Mô tả được Strangler Fig và 7 bước tách service
  • [ ] 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
  • [ ] 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ể

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.
  • 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ọc bằng cách build. Chứng minh, đừng tin.