Node.js Production Engineering 18 — Từ Modular Monolith đến Microservices
Tách service có kiểm soát: bounded context, data ownership, gRPC/Protobuf, deadline propagation, retry budget, outbox, saga, mTLS, observability và chiến lược migration.
Một team tách user-service, order-service và inventory-service vì muốn deploy độc lập. Sáu tháng sau, một thay đổi checkout cần ba pull request, bốn lần deploy theo đúng thứ tự và một migration trên database dùng chung. Nếu user-service chậm, cả checkout chậm theo. Họ có nhiều repository, nhưng vẫn là một monolith phân tán.
Microservices không phải đích trưởng thành mặc định. Chúng là cách đổi coupling trong code thành contract, network và ownership boundary — chỉ đáng giá khi ranh giới mới giải quyết một constraint tổ chức hoặc vận hành có thật.
Sau bài này, bạn sẽ có thể:
- nhận biết lúc nên giữ modular monolith và tín hiệu đủ mạnh để tách;
- đặt service boundary theo business capability và data ownership;
- chọn gRPC đồng bộ hay event bất đồng bộ từ yêu cầu consistency/latency;
- thiết kế Protobuf contract có thể tiến hóa;
- áp deadline, cancellation, retry budget, circuit breaker và bulkhead;
- migration một capability bằng strangler pattern với đường rollback rõ.
1. Câu hỏi đầu tiên không phải “tách thế nào?”
Hãy hỏi: constraint nào không thể giải kinh tế hơn trong một deployable unit?
Tín hiệu tách service có giá trị:
- hai capability có nhịp thay đổi và owner độc lập thật;
- một workload cần scale, isolation hoặc compliance khác hẳn phần còn lại;
- release coordination của bounded context đang là bottleneck đã đo;
- boundary domain đã ổn định qua thời gian trong modular monolith;
- team có năng lực on-call, observability, platform và incident response phân tán.
Tín hiệu chưa nên tách:
- boundary vẫn đổi mỗi sprint;
- nhiều service cần join chung bảng hoặc deploy cùng nhau;
- team nhỏ nhưng phải vận hành nhiều pipeline, secret và dashboard;
- động lực chính là “microservices scale hơn” mà chưa có bottleneck;
- chưa có contract test, trace và ownership rõ.
Modular monolith tốt
┌──────────────────── one deployable ────────────────────┐
│ identity module │ ordering module │ inventory module│
│ public API/port │ public API/port │ public API/port │
│ private tables │ private tables │ private tables │
└─────────────────────────────────────────────────────────┘
Boundary đã có trước; network là một quyết định migration về sau.
Modular monolith là nơi rẻ nhất để kiểm chứng boundary: module không import internals của nhau, giao tiếp qua port/event, table ownership rõ và architecture test ngăn dependency sai. Nếu không giữ được kỷ luật này trong một repo, network không tự sửa nó.
2. Service boundary là business + data + team boundary
Một service production cần quyền quyết định business invariant và sở hữu lifecycle dữ liệu của capability đó. Tách controller thành service nhưng vẫn cho tất cả truy cập chung schema chỉ thêm network mà không có autonomy.
Order Service Inventory Service
┌────────────────────┐ ┌────────────────────┐
│ order invariant │ │ stock invariant │
│ order database │── contract ──▶│ inventory database │
│ order owner/on-call│◀── events ────│ inventory owner │
└────────────────────┘ └────────────────────┘
“Database per service” nói về ownership, không nhất thiết mỗi service phải có một cụm Postgres vật lý. Hai schema trên cùng cluster vẫn có thể được quản trị riêng nếu credential và migration boundary nghiêm. Điều cấm là service A đọc thẳng table riêng của B rồi biến schema nội bộ thành API không version.
Khi cần dữ liệu chéo boundary, chọn một trong ba:
- gọi owner đồng bộ khi cần freshness ngay;
- subscribe event và giữ local projection khi chấp nhận eventual consistency;
- compose ở gateway/BFF cho một use case đọc.
Không có lựa chọn miễn phí: synchronous call tăng temporal coupling; projection tăng độ trễ và logic reconcile; composition tăng fan-out latency.
3. Đồng bộ hay bất đồng bộ là quyết định về coupling
| Câu hỏi | RPC đồng bộ | Event bất đồng bộ |
|---|---|---|
| Caller cần kết quả trước khi tiếp tục? | phù hợp | không tự nhiên |
| Hai service phải cùng online? | có | không tại thời điểm publish/consume |
| Consistency nhìn thấy ngay? | dễ hơn, không phải transaction phân tán | thường eventual |
| Failure hiện ở đâu? | ngay trong request path | backlog/retry/reconciliation |
| Debug flow | call chain/trace | event lineage + message state |
“Ưu tiên event” không phải luật. Order Service hỏi giá hiện tại trước khi confirm có thể cần RPC. Sau khi order đã confirm, phát OrderConfirmed cho email/analytics là event tự nhiên.
Một workflow không nên biến thành chuỗi đồng bộ dài:
gateway → order → user → inventory → pricing → promotion → payment
Mỗi hop cộng latency và xác suất lỗi. Nếu mỗi dependency có availability 99,9% và tất cả bắt buộc, năm hop độc lập cho upper bound gần 0.999⁵ ≈ 99,5% trước khi tính chính service gọi. Số này chỉ minh họa phép nhân failure domain, không phải dự báo SLO thực.
4. gRPC và Protobuf: contract trước implementation
gRPC phù hợp cho internal RPC đa ngôn ngữ, cần IDL chặt, HTTP/2 và streaming. REST/JSON vẫn dễ quan sát, gọi thử và tích hợp ở public edge. Chọn theo ecosystem và contract, không chỉ benchmark serialization.
syntax = "proto3";
package identity.v1;
service IdentityService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
string user_id = 1;
}
message GetUserResponse {
User user = 1;
}
message User {
string id = 1;
string display_name = 2;
UserStatus status = 3;
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0;
USER_STATUS_ACTIVE = 1;
USER_STATUS_SUSPENDED = 2;
}
Quy tắc tiến hóa Protobuf
- không đổi nghĩa hoặc type của field number đã phát hành;
- xóa field thì
reservednumber và name, không tái sử dụng; - thêm field theo hướng reader cũ có thể bỏ qua;
- enum phải có giá trị
0mang nghĩa unspecified/unknown; - package/service version như
identity.v1khi cần contract generation mới; - phân biệt “vắng mặt” và default bằng presence/
optionalkhi business cần.
message User {
reserved 4;
reserved "legacy_username";
// field mới dùng number chưa từng phát hành
optional string avatar_url = 5;
}
Node @grpc/grpc-js hỗ trợ load .proto động, nhưng cast as any xóa lợi ích type-safety. Với codebase production, sinh client/server type trong CI bằng toolchain đã chọn, pin version generator và kiểm breaking change của .proto.
5. Deadline là một phần của contract
Mặc định chờ vô hạn biến dependency chậm thành resource leak. Mỗi RPC cần deadline dựa trên budget còn lại của request, không phải một timeout cố định độc lập ở mỗi hop.
Client budget 1000 ms
├─ gateway work 80 ms
├─ order service 120 ms
└─ downstream budget ≤ 800 ms, còn phải chừa thời gian serialize + response
const deadline = new Date(Date.now() + 500);
client.getUser({ userId }, { deadline }, (error, response) => {
if (error?.code === grpc.status.DEADLINE_EXCEEDED) {
metrics.userRpcTimeouts.add(1);
}
callback(error, response);
});
Promise.race với timer chỉ làm caller ngừng chờ; nó không hủy RPC đang chạy. Dùng deadline/cancel API của client và truyền cancellation xuống query/upstream nếu thư viện hỗ trợ. Server cũng cần kiểm call đã bị cancel trước khi tiếp tục công việc tốn kém.
Deadline phải xuất hiện trong trace và error mapping. Nếu gateway trả 504, trace cần cho thấy budget hết ở hop nào, không chỉ “internal error”.
6. Retry budget: ít, có điều kiện và ở đúng tầng
Retry làm tăng xác suất thành công với lỗi tạm thời, đồng thời nhân tải đúng lúc dependency yếu nhất. Chỉ retry khi:
- operation idempotent hoặc có idempotency key;
- status code thật sự retryable (
UNAVAILABLE, đôi khiRESOURCE_EXHAUSTEDtheo hợp đồng); - còn deadline và retry budget;
- backoff có jitter;
- một tầng chịu trách nhiệm retry, tránh gateway và service cùng nhân số lần gọi.
Gateway retry 3 × Order retry 3 × DB proxy retry 3 = tối đa 27 attempts
Không retry INVALID_ARGUMENT, PERMISSION_DENIED hoặc lỗi invariant. Với DEADLINE_EXCEEDED, retry chỉ có ý nghĩa khi còn đủ budget và biết operation chưa tạo side effect hoặc có idempotency.
7. Circuit breaker, bulkhead và load shedding
Ba pattern xử lý ba failure mode khác nhau:
- circuit breaker: dừng gọi dependency đang lỗi liên tục, cho nó thời gian hồi phục;
- bulkhead: giới hạn concurrency/connection theo dependency để một nơi chậm không chiếm hết tài nguyên;
- load shedding: từ chối sớm phần tải vượt capacity thay vì để toàn hệ thống timeout muộn.
Circuit breaker không phải thuốc chữa mọi lỗi. Threshold sai có thể mở circuit vì một traffic spike; shared breaker có thể tạo đồng bộ retry. Dashboard cần state transition, rejected count, dependency latency và half-open probes.
Fallback chỉ hợp khi semantics cho phép. Trả giá cache cũ có thể chấp nhận; giả vờ payment thành công thì không.
8. Event delivery: outbox, idempotency và ordering
Khi service vừa commit business state vừa publish event, dùng transactional outbox như Phần 16 để không mất event. Consumer giả định at-least-once và deduplicate theo event id/business key.
{
"eventId": "01J...",
"eventType": "OrderConfirmed.v1",
"occurredAt": "2026-07-11T08:00:00Z",
"aggregateId": "ord_123",
"aggregateVersion": 7,
"data": { "orderId": "ord_123", "userId": "usr_9" }
}
Ordering thường chỉ đảm bảo trong một partition/key, không trên toàn hệ thống. aggregateVersion giúp consumer phát hiện duplicate, out-of-order hoặc gap. Consumer không nên dựa vào “event sẽ tới đúng một lần và đúng thứ tự toàn cục”.
Schema event là contract lâu sống. Thêm field tương thích, giữ consumer tolerant, có owner và retention/replay policy. Đừng phát nguyên ORM entity rồi vô tình công bố mọi cột nội bộ/PII.
9. Workflow nhiều service: Saga và compensation
Một order flow có các transaction cục bộ:
Create order → Reserve inventory → Authorize payment → Confirm order
│ lỗi │ lỗi
▼ ▼
release stock void authorization
Saga không rollback thời gian; nó chạy compensating action có semantics nghiệp vụ riêng. Refund không giống “transaction chưa từng xảy ra”: nó có phí, audit và có thể thất bại.
Hai kiểu coordination:
- orchestration: một workflow coordinator biết state machine, dễ thấy tiến trình nhưng tập trung coupling;
- choreography: service phản ứng event, autonomy cao nhưng flow dễ trở nên vô hình.
Workflow dài, nhiều timer/compensation thường đáng dùng durable workflow engine thay vì tự ghép hàng chục queue handler.
10. API Gateway và BFF: edge không sở hữu mọi policy
Gateway xử lý concern ở edge: TLS termination, routing, coarse rate limit, authentication, request size và correlation id. Service vẫn phải authorize hành động trên resource nó sở hữu. Nếu chỉ gateway kiểm role, internal path hoặc message consumer có thể bypass policy.
Frontend thường được lợi từ BFF (Backend for Frontend): web/mobile có contract tổng hợp phù hợp UI, trong khi internal service contract giữ theo domain. BFF không được trở thành nơi chứa toàn bộ business logic; nó orchestrate/present, domain invariant vẫn ở owner service.
11. Security giữa service
“Ở trong VPC” không phải trust model. Một baseline:
- workload identity + mTLS cho authentication kênh;
- authorization theo service principal và action;
- credential ngắn hạn, rotation tự động;
- network policy/egress allowlist;
- validate message và giới hạn kích thước ở mọi boundary;
- encrypt dữ liệu nhạy cảm, redact telemetry;
- tenant context được ký/xác minh, không tin header tùy ý từ caller.
External user token không nên được forward vô hạn qua mọi hop. Có thể exchange thành internal identity/claims tối thiểu; mỗi service kiểm audience và authorization của riêng nó.
12. Observability của một hệ phân tán
Mỗi RPC/event cần đủ context để trả lời “ai gọi ai, vì operation nào, mất bao lâu, retry mấy lần”. Baseline:
- trace context chuẩn qua gRPC metadata và message envelope;
- metrics RED theo service + dependency;
- deadline exceeded, cancellation, retry, circuit/bulkhead rejection;
- queue age và consumer lag cho event path;
- structured log có
traceId,eventId,aggregateId, không log payload nhạy cảm; - service map và SLO theo user journey, không chỉ từng container.
High-cardinality id không đưa vào metric label. Chúng phù hợp với trace/log có sampling và retention kiểm soát.
13. Migration bằng strangler, không “big bang split”
Một extraction an toàn theo lát dọc:
- Đo coupling, traffic, SLO và dependency hiện tại.
- Khóa boundary trong monolith bằng port + architecture test.
- Chuyển ownership table/API; backfill bằng job có checkpoint.
- Chạy shadow read hoặc compare result nếu dữ liệu cho phép.
- Route một phần traffic (canary), theo dõi mismatch/latency/error.
- Chuyển write authority một nơi; tránh dual write không outbox.
- Có feature flag/route rollback trong cửa sổ migration.
- Xóa đường cũ và quyền truy cập table sau khi ổn định.
Dual read/write kéo dài là trạng thái nguy hiểm, không phải kiến trúc đích. ADR phải ghi owner, success metric, rollback trigger và ngày dọn compatibility layer.
14. Decision record mẫu
ADR-018: Extract Notification capability
Context:
- traffic burst khác core API 20×
- deploy notification đang khóa release ordering
- consistency có thể trễ 60 giây
Decision:
- event OrderConfirmed.v1 qua durable broker
- Notification sở hữu template + delivery database
- outbox ở Order, idempotent inbox ở Notification
Consequences:
- thêm broker/on-call/dashboard
- email có thể trễ; UI đọc delivery status eventual
- rollback: route consumer về module cũ, giữ event contract
Revisit when:
- queue age p99 > 60 giây trong 7 ngày hoặc ownership thay đổi
Kiến trúc tốt không chỉ có diagram; nó lưu lý do, constraint, hậu quả và điều kiện xem xét lại.
15. Checklist trước khi tách service
- Có constraint và success metric cụ thể, không chỉ mong muốn “scale”.
- Boundary đã được kiểm chứng trong modular monolith.
- Service sở hữu business invariant, data, migration và on-call.
- Contract có version/evolution rule và contract test.
- Mọi RPC có deadline, cancellation và retry budget.
- Mọi consumer chịu duplicate/out-of-order theo semantics đã ghi.
- Không có shared-table backdoor.
- Authn/authz, mTLS và tenant propagation được threat-model.
- Trace, metrics, log và runbook tồn tại trước cutover.
- Migration có canary, rollback trigger và kế hoạch xóa đường cũ.
16. Bài thực hành: tách Notification capability
Đừng bắt đầu bằng Order/Payment — chọn capability ít invariant xuyên ranh giới hơn:
- Giữ app hiện tại là modular monolith, định nghĩa
NotificationPort. - Trong transaction tạo order, ghi
OrderConfirmed.v1vào outbox. - Service Notification riêng consume event, lưu inbox/event id và enqueue email.
- Công bố gRPC
GetDeliveryStatuscó deadline 300 ms cho trang admin. - Kill relay sau publish, gửi duplicate và đảo thứ tự event; chứng minh outcome không lặp.
- Load test khi Notification tắt: checkout vẫn thành công, queue age tăng và alert chạy.
- Canary 10% event sang service mới, có dashboard compare và route rollback.
Tiêu chí hoàn thành là giảm coupling mà vẫn nhìn thấy và phục hồi được failure, không phải số container tăng lên.
Tài liệu chính thức
- gRPC: Core concepts
- gRPC: Deadlines
- gRPC: Retry
- gRPC for Node.js
- Protocol Buffers: Updating a message type
Phần tiếp theo
Microservices làm tăng số kết nối dài, event và trạng thái tạm thời ở client. Phần 19 thiết kế realtime từ nhu cầu sản phẩm: khi nào polling, SSE hay WebSocket đúng; cách xử lý delivery, reconnect, presence, backpressure và scale-out mà không đánh mất tính đúng đắn.