Câu hỏi "nên tách thành microservices không" thường được trả lời bằng cảm tính, vì cái giá của việc tách rất khó hình dung khi chưa từng trả nó. Đến bài này thì ta đã trả rồi: Bài 4 cho thấy mỗi hop cộng thêm latency, Bài 9 cho thấy nhất quán cuối là gì, Bài 10 cho thấy lock phân tán khó ra sao, Bài 11 cho thấy vì sao mọi thứ phải idempotent. Đó là lý do bài này nằm ở vị trí 15 chứ không phải vị trí 3.

Lab đo cái giá đó bằng con số. Cùng một use case bốn bước, cùng một lượng công việc mỗi bước: chạy trong một tiến trình cho p99 2,06 ms và 18.887 req/s; tách thành các lời gọi HTTP cho p99 8,25 ms và 3.744 req/s — chậm hơn 4 lần ở đuôi và ít hơn 5 lần về throughput. Với mỗi service hỏng 1%, chuỗi 8 hop đo được tỉ lệ thành công 92,18%, khớp với lý thuyết 92,27%. Và một saga thiếu hành động bù để lại 200 đơn hàng đã thu tiền nhưng không bao giờ được giao, không một lỗi nào được ghi.

ℹ️ Cấu hình máy đo
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Ba container node:22-alpine (app1/app2/app3), mỗi container giới hạn 1 CPU và 256 MB, gọi nhau qua mạng nội bộ Docker bằng HTTP keep-alive. Bộ đo tải là loadgen.js tự viết, 20 kết nối, closed-loop, warm-up 3 giây.

Điểm quan trọng nhất về phương pháp: mỗi bước ở cả hai kiến trúc làm đúng cùng một lượng việc — cùng hàm doUnitOfWork(w) — nên chênh lệch đo được chỉ đến từ cách gọi. Và công việc đó là phép tính tốn CPU chứ không phải sleep: nếu dùng sleep, chi phí hop sẽ chìm mất trong khoảng chờ và phép đo sẽ có lợi cho microservices một cách giả tạo.

Ba container nằm cùng một máy, nên hop mạng ở đây rẻ hơn thực tế (không qua switch, không qua vùng sẵn sàng khác). Con số thật của bạn sẽ tệ hơn, không tốt hơn.

15.1 Microservices giải bài toán tổ chức, không phải bài toán kỹ thuật

Điều đầu tiên cần nói rõ, vì nó quyết định mọi thứ còn lại: chia nhỏ hệ thống không làm nó nhanh hơn. Phần 15.2 sẽ chứng minh điều ngược lại bằng số đo. Cái mà microservices thật sự giải quyết là những vấn đề của con người:

Vấn đề Microservices giúp thế nào Monolith module hoá có giúp được không?
Hai đội chặn nhau khi deploy Mỗi service deploy riêng, không cần đợi ai Không — vẫn một pipeline, một lần release
Ranh giới trách nhiệm mờ Biên service là biên cứng, không ai lỡ gọi xuyên qua được Có, nếu kỷ luật module được thực thi bằng công cụ
Một phần nóng cần scale riêng Nhân bản đúng service đó, không nhân cả hệ thống Không — nhân bản monolith là nhân tất cả
Một phần cần ngôn ngữ/runtime khác Mỗi service tự chọn công nghệ của mình Không
Code rối, khó hiểu, sửa chỗ này vỡ chỗ kia Không giúp — code rối chia nhỏ ra vẫn rối, cộng thêm mạng Có — đây đúng là việc của module hoá

Dòng cuối là dòng bị hiểu nhầm nhiều nhất. Rất nhiều lần quyết định "chuyển sang microservices" thực chất là để giải quyết một codebase rối, và nó không bao giờ giải quyết được — vì ranh giới bạn vẽ ra sẽ đúng bằng mức bạn hiểu về nghiệp vụ tại thời điểm đó, mà nếu code đang rối thì mức hiểu đó thường chưa đủ. Kết quả là code rối bị phân tán qua mạng: vẫn rối, giờ thêm phần khó debug.

Đây cũng là chỗ luật Conway xuất hiện: cấu trúc hệ thống có xu hướng phản chiếu cấu trúc giao tiếp của tổ chức làm ra nó. Hệ quả thực dụng: nếu tổ chức bạn có ba đội với ba trách nhiệm rõ ràng thì ba service là chuyện tự nhiên; nếu tổ chức bạn có một đội năm người thì tám service chỉ là tám thư mục nằm ở tám nơi khác nhau — cộng thêm tám pipeline, tám dashboard và tám ca trực.

⚠️ Cạm bẫy: chọn microservices vì nó "hiện đại"
Với một đội năm người, chi phí vận hành của microservices vượt xa mọi lợi ích: mỗi service cần CI/CD riêng, giám sát riêng, alert riêng, quy trình on-call riêng, và một cách để truy vết một request đi xuyên qua chúng (Bài 16). Nhân từng ấy thứ với số service — đó là công việc thật, chiếm thời gian thật, trong khi số người vẫn là năm.

Câu hỏi kiểm tra rẻ nhất: lần gần nhất một lần deploy bị chặn vì đội khác là khi nào? Nếu không nhớ nổi, thì vấn đề bạn định giải bằng microservices có thể chưa tồn tại.

15.2 Cái giá đo được

Lab chạy cùng một use case bốn bước theo hai cách. mode=mono: bốn bước là bốn lời gọi hàm trong một tiến trình. mode=micro: bốn bước là bốn request HTTP tới các instance khác. Lượng việc mỗi bước giống hệt nhau.

Kiến trúc Throughput p50 p95 p99
Monolith — 4 lời gọi hàm 18.887 req/s 0,97 ms 1,89 ms 2,06 ms
Microservices — 4 hop HTTP 3.744 req/s 5,18 ms 6,09 ms 8,25 ms
Tỉ lệ ít hơn 5,0 lần chậm hơn 5,3 lần chậm hơn 3,2 lần chậm hơn 4,0 lần

Và hãy nhớ điều kiện đo: ba container trên cùng một máy, không qua switch, không qua vùng sẵn sàng khác. Với Redis ở Bài 13 ta đã thấy một vòng mạng nội bộ Docker tốn khoảng 0,07 ms trong khi một vòng mạng thật trong trung tâm dữ liệu tốn 0,5–2 ms. Áp cùng bậc độ lớn đó vào đây thì bốn hop thật sẽ cộng thêm hàng mili giây nữa, không phải phần trăm mili giây.

Nhưng độ trễ không phải phần đắt nhất. Phần đắt nhất là độ khả dụng nhân dồn: một chuỗi chỉ thành công khi mọi mắt xích thành công, nên với $n$ service mỗi cái sẵn sàng $p$, chuỗi chỉ còn $p^n$.

$$P_{\text{chain}} = p^{\,n}$$

Lab bơm lỗi 1% vào mỗi bước rồi đếm tỉ lệ thành công thật của toàn chuỗi:

Số hop Thành công (đo) Lý thuyết $0{,}99^n$ p99
1 98,95% 99,00% 3,08 ms
2 98,08% 98,01% 4,50 ms
4 96,01% 96,06% 9,04 ms
8 92,18% 92,27% 16,52 ms

Sai lệch giữa đo và lý thuyết dưới 0,1 điểm phần trăm ở cả bốn mức — công thức không phải ước lượng thô, nó mô tả đúng cái đang xảy ra. Dịch sang ngôn ngữ vận hành: năm service mỗi cái đạt 99,9% cho ra chuỗi $0{,}999^5 \approx 99{,}5\%$, tức thời gian ngừng hoạt động tăng từ khoảng 43 phút/tháng lên khoảng 3,6 giờ/tháng — mà không service nào trong đó vi phạm SLO của riêng nó.

Cùng bốn bước việc — khác nhau ở chỗ chúng được gọi thế nào MONOLITH — 4 lời gọi hàm trong 1 tiến trình 1 MIỀN LỖI step 1 step 2 step 3 step 4 p99 = 2,06 ms · 18.887 req/s Chi phí gọi ≈ 0. Không serialize, không parse, không timeout, không retry, không đứt kết nối. Một transaction database bao trọn cả bốn bước — ACID vẫn còn nguyên. MICROSERVICES — 4 hop HTTP svc 1 svc 2 svc 3 svc 4 p99 = 8,25 ms · 3.744 req/s 4 MIỀN LỖI. Mỗi mũi tên đỏ là một chỗ có thể timeout, đứt kết nối, hoặc trả 500 — và mỗi chỗ đó cần timeout riêng, retry riêng, circuit breaker riêng (Bài 17), và phải idempotent (Bài 11). Mỗi service hỏng 1% → chuỗi 4 hop thành công 96,01% (đo được). Chuỗi 8 hop: 92,18%. Không còn transaction nào bao trọn bốn bước — đó là nội dung mục 15.3.
Số đo thật, ba container cùng máy. Trên hạ tầng thật, cột bên phải còn tệ hơn.
⚠️ Cạm bẫy: chỉ nhìn latency trung bình khi tách service
Ở phép đo trên, mean của micro là 5,34 ms trong khi p99 là 8,25 ms — nhìn mean thì "chỉ chậm hơn vài mili giây", nghe chấp nhận được. Nhưng Bài 1 đã chỉ ra vì sao đó là con số sai để nhìn: khi một request phải đi qua $n$ service, xác suất nó gặp ít nhất một service đang chậm là $1-(1-p)^n$ — nghĩa là p99 của từng service trở thành đuôi của người dùng.

Hai con số phải nhìn cùng nhau khi tách service là p99xác suất lỗi cộng dồn. Cả hai đều xấu đi theo số hop, và cả hai đều vô hình trên biểu đồ trung bình.

15.3 Vượt qua ranh giới service là mất ACID

Trong monolith, "tạo đơn — trừ tiền — giữ hàng" nằm trong một transaction: hoặc cả ba cùng xảy ra, hoặc không gì xảy ra, và database lo phần đó. Khi ba việc đó thuộc ba service với ba database, transaction đó không còn tồn tại. Không có gì thay thế nó ở tầng hạ tầng — bạn phải tự viết phần thay thế, và phần đó gọi là saga: một chuỗi giao dịch cục bộ, mỗi bước kèm một hành động bù để gỡ lại nếu bước sau thất bại.

Lab dựng saga ba bước qua ba service thật, mỗi service giữ dữ liệu của riêng nó, rồi cho bước thứ ba thất bại 200 lần liên tiếp:

ket_qua_saga.txt
$ ./tools/micro-test.sh saga 200 1     # CO hanh dong bu
  chay 200 saga, hong o buoc 3 (inventory), compensate=1
  app1: {"order":0,"payment":0,"inventory":0} compensations=200
  app2: {"order":0,"payment":0,"inventory":0} compensations=200
  app3: {"order":0,"payment":0,"inventory":0} compensations=0

$ ./tools/micro-test.sh saga 200 0     # KHONG co hanh dong bu
  chay 200 saga, hong o buoc 3 (inventory), compensate=0
  app1: {"order":200,"payment":0,"inventory":0} compensations=0
  app2: {"order":0,"payment":200,"inventory":0} compensations=0
  app3: {"order":0,"payment":0,"inventory":0} compensations=0

Đọc kết quả thứ hai bằng ngôn ngữ nghiệp vụ: 200 đơn hàng đã được tạo, 200 lần đã thu tiền, và 0 lần hàng được giữ. Hai trăm khách hàng đã trả tiền cho thứ sẽ không bao giờ tới. Không có exception nào, không có dòng log lỗi nào — mỗi bước đều báo thành công đúng như nó đã thành công thật. Chỉ có tổng thể là sai, mà không service nào đứng ở vị trí nhìn thấy tổng thể.

⚠️ Cạm bẫy: làm saga nhưng quên viết hành động bù
Đây gần như luôn là kết quả của việc chỉ nghĩ tới đường thành công. Đường thất bại chỉ chạy khi có sự cố, mà sự cố thì hiếm — nên thiếu sót này không lộ ra trong kiểm thử, không lộ ra ở môi trường staging, và ở production thì nó âm thầm tích luỹ.

Thứ phát hiện được nó không phải log lỗi mà là một job đối chiếu chạy định kỳ: đếm số đơn ở trạng thái "đã thanh toán" và so với số đơn "đã giữ hàng", rồi báo động khi hai số lệch nhau. Nếu bạn có saga mà không có job đối chiếu, bạn không có cách nào biết mình đang lệch — và câu hỏi duy nhất là bao lâu nữa thì có người phát hiện, thường là khách hàng.
🔬 Vì sao không kéo 2PC vào để "giữ ACID"
Two-phase commit (2PC) hứa hẹn đúng thứ ta vừa mất: một transaction nguyên tử xuyên nhiều database. Cái giá của nó là khoá tài nguyên trong suốt cả hai pha. Nghĩa là một service chậm sẽ giữ khoá ở tất cả các service còn lại, và một service chết sau khi đã bỏ phiếu "sẵn sàng" để lại một transaction treo mà không ai được phép gỡ.

Nhìn qua lăng kính Bài 9: 2PC chọn C và hy sinh A rất mạnh — nó biến độ khả dụng của toàn hệ thành độ khả dụng của mắt xích yếu nhất, đúng lúc bạn đang cố tránh điều đó. Saga chọn hướng ngược lại: chấp nhận có một khoảng thời gian dữ liệu chưa nhất quán, đổi lấy việc không service nào phải chờ service nào.

Điều kiện tiên quyết của saga: mọi bước và mọi hành động bù phải idempotent (Bài 11), vì cả hai đều sẽ được thử lại. Hành động bù chạy hai lần mà trừ kho hai lần thì bản vá còn tệ hơn lỗi.

15.4 Chia theo đâu

Nếu đã quyết định chia, câu hỏi kế tiếp quyết định thành bại: cắt theo đường nào. Có đúng một nguyên tắc đáng nhớ — cắt theo bounded context nghiệp vụ, tức theo việc mà phần đó phụ trách, chứ không theo tầng kỹ thuật. Và mỗi service sở hữu dữ liệu của riêng nó: không service nào đọc thẳng bảng của service khác.

Hai đường cắt — một đường tạo ra distributed monolith SAI — cắt theo tầng kỹ thuật api-service (mọi endpoint của mọi tính năng) logic-service (mọi luật nghiệp vụ của mọi tính năng) data-service (mọi bảng của mọi tính năng) Thêm một trường vào form đặt hàng = sửa CẢ BA service và deploy chúng ĐÚNG THỨ TỰ, trong cùng một lần release. Đây là DISTRIBUTED MONOLITH: gánh đủ chi phí phân tán (hop mạng, độ khả dụng nhân dồn, saga, tracing) mà KHÔNG được lợi ích nào — deploy vẫn phải đồng bộ như cũ. ĐÚNG — cắt theo bounded context Đơn hàng api luật DB riêng Thanh toán api luật DB riêng Kho api luật DB riêng Thêm một trường vào form đặt hàng = sửa MỘT service, deploy một mình, không hẹn ai. Cái giá phải chấp nhận: dữ liệu TRÙNG LẶP có chủ đích. Service Kho giữ bản sao tên sản phẩm thay vì JOIN sang Đơn hàng. Trùng lặp ở đây là tính năng, không phải lỗi.
Phép thử một câu: thêm một tính năng nhỏ thì phải sửa mấy service? Nếu câu trả lời luôn là "tất cả", đường cắt đã sai.
⚠️ Cạm bẫy: nhiều service dùng chung một database
Đây là bước "chia nhỏ" phổ biến nhất vì nó dễ nhất — tách code ra nhiều tiến trình nhưng vẫn trỏ về một database. Kết quả là tệ nhất của cả hai thế giới: coupling vẫn nguyên (đổi một cột là phải sửa mọi service đọc cột đó, và bạn không biết chắc có bao nhiêu service như vậy), nhưng transaction thì mất (mỗi service có kết nối riêng, không còn một phạm vi giao dịch chung nào).

Phép thử thẳng thắn: bạn có xoá được một bảng mà không phải hỏi đội khác không? Nếu không, đó chưa phải service — đó là một module đã bị đẩy qua mạng.

15.5 Đường đi thực tế, và khi nào không nên chia

Đường đi ít rủi ro nhất gần như luôn là: bắt đầu bằng monolith module hoá — một tiến trình, nhưng ranh giới module rõ và được thực thi bằng công cụ (kiểm tra phụ thuộc trong CI, cấm import chéo). Ranh giới đó cho bạn phần lớn lợi ích về tổ chức code mà không phải trả một đồng nào cho hop mạng, saga hay tracing. Rồi chỉ tách ra khỏi tiến trình khi có tín hiệu cụ thể:

  • Một phần có nhu cầu scale khác hẳn phần còn lại (ví dụ xử lý ảnh so với CRUD).
  • Hai đội thật sự chặn nhau khi deploy — đo được bằng số lần release bị hoãn, không phải bằng cảm giác.
  • Một phần cần runtime khác hoặc yêu cầu tuân thủ khác (dữ liệu thẻ, dữ liệu y tế).
  • Một phần có hồ sơ rủi ro khác: bạn muốn nó sập riêng chứ không kéo theo phần còn lại.

Khi tách, dùng strangler fig: đặt một lớp định tuyến ở trước (chính là API gateway của Bài 4), chuyển từng đường một sang service mới, giữ nguyên phần còn lại trong monolith. Cách này cho phép quay lui từng bước và không có ngày "big bang". Viết lại toàn bộ từ đầu thì ngược lại: bạn phải đoán đúng mọi ranh giới ngay lần đầu, trong khi chính điều bạn thiếu là hiểu biết về những ranh giới đó.

Strangler fig — chuyển từng đường một, quay lui được ở mọi bước Tháng 1 gateway monolith 100% lưu lượng Tháng 3 gateway /payments → mới monolith (phần còn lại) Tháng 9 gateway /payments /inventory monolith (nhỏ dần) Ở mọi thời điểm, hệ thống đang chạy và quay lui chỉ là đổi một dòng định tuyến Ranh giới nào vẽ sai sẽ lộ ra sau vài tuần chạy thật — lúc đó sửa còn rẻ, vì phần còn lại chưa đụng tới. Viết lại từ đầu: phải đoán đúng MỌI ranh giới ngay lần đầu Nhưng chính hiểu biết về ranh giới mới là thứ bạn đang thiếu — nếu có nó rồi thì đã không cần viết lại.
Mỗi đường chuyển sang là một thí nghiệm nhỏ có thể quay lui, thay vì một canh bạc lớn không có đường lùi.
💡 Nhân chi phí vận hành với số service trước khi quyết định
Mỗi service cần: một pipeline CI/CD, một bộ dashboard, một bộ alert, một quy trình on-call, một chiến lược phiên bản API, và một chỗ đứng trong hệ thống tracing (Bài 16). Với một service thì đó là công việc bạn đã làm. Với tám service thì đó là tám lần — và trừ khi số người cũng nhân lên, thời gian đó bị trừ thẳng vào thời gian làm tính năng.

Một cách ước lượng thô nhưng hữu ích: nếu bạn chưa tự động hoá được việc tạo mới một service (kho code, pipeline, giám sát, alert) trong vòng một giờ, thì bạn chưa sẵn sàng cho service thứ tư.
🚨 Đường cắt sai đắt hơn tách muộn rất nhiều
Tách muộn thì bạn mất một khoảng thời gian sống chung với một monolith hơi lớn. Cắt sai thì bạn có hai service phải gọi chéo nhau liên tục cho mọi thao tác — và sửa chuyện đó nghĩa là gộp lại hoặc di trú dữ liệu giữa hai database đang chạy, tức là một dự án riêng, có downtime, có rủi ro mất dữ liệu.

Vì thế bất đối xứng rất rõ: chi phí của việc chờ thêm là tuyến tính, chi phí của việc cắt sai là một bậc thang. Khi chưa chắc về ranh giới nghiệp vụ, chờ là lựa chọn có kỳ vọng tốt hơn — và trong lúc chờ, hãy dựng ranh giới đó dưới dạng module để kiểm chứng xem nó có đứng vững không.

Tự làm lại các phép đo

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

# --- Cost of the network hop: same 4 steps, same amount of work per step ---
./tools/micro-test.sh cost
#   mono   p99 2.06ms · 18,887 req/s
#   micro  p99 8.25ms ·  3,744 req/s     (4.0x slower at the tail · throughput /5.0)

# --- Cumulative availability: each hop fails 1% of the time ---
./tools/micro-test.sh availability
#   1 hop  98.95%  (theory 99.00%)   p99  3.08ms
#   2 hop  98.08%  (theory 98.01%)   p99  4.50ms
#   4 hop  96.01%  (theory 96.06%)   p99  9.04ms
#   8 hop  92.18%  (theory 92.27%)   p99 16.52ms

# --- Saga: 200 failures at step 3 (reserve inventory) ---
./tools/micro-test.sh saga 200 1   # with compensation    -> order=0,   payment=0,   inventory=0
./tools/micro-test.sh saga 200 0   # without compensation -> order=200, payment=200, inventory=0
#   Read the last line in business terms: 200 orders charged, 0 orders had inventory reserved.

Tóm lại

Microservices giải bài toán tổ chức — deploy độc lập, ranh giới trách nhiệm, scale riêng phần nóng — chứ không giải bài toán hiệu năng. Phép đo nói ngược lại: cùng một use case bốn bước, tách ra làm p99 xấu đi 4 lần và throughput giảm 5 lần, trên ba container nằm cùng một máy nên hop mạng còn rẻ hơn thực tế.

Cái giá đắt hơn latency là độ khả dụng nhân dồn, và nó có công thức chính xác chứ không phải ước lượng: tỉ lệ thành công đo được (98,95% / 98,08% / 96,01% / 92,18% cho 1/2/4/8 hop) khớp với $0{,}99^n$ trong vòng 0,1 điểm phần trăm. Năm service mỗi cái 99,9% cho ra 99,5% — từ 43 phút lên 3,6 giờ ngừng hoạt động mỗi tháng, mà không service nào vi phạm SLO của riêng nó.

Vượt qua ranh giới service là mất ACID, và thứ thay thế nó — saga — chỉ đúng khi bạn thật sự viết hành động bù. Không viết thì hệ thống không báo lỗi gì cả: lab để lại 200 đơn đã thu tiền và 0 lần giữ hàng, mọi bước đều báo thành công. Thứ phát hiện được chuyện đó là một job đối chiếu, không phải log lỗi.

Còn về đường cắt: cắt theo bounded context nghiệp vụ, mỗi service sở hữu dữ liệu của mình, chấp nhận dữ liệu trùng lặp có chủ đích. Cắt theo tầng kỹ thuật hoặc dùng chung một database cho ra distributed monolith — trả đủ chi phí phân tán mà không nhận được lợi ích nào. Và vì chi phí của việc chờ là tuyến tính còn chi phí của việc cắt sai là một bậc thang, khi chưa chắc thì monolith module hoá là lựa chọn có kỳ vọng tốt hơn.

Bài 16 giải quyết vấn đề mà chính bài này vừa tạo ra: khi một request đi qua bốn service, làm sao biết hop nào ăn hết thời gian và ở đâu nó hỏng. Không đo được thì không phát hiện được cascade failure — và đó là lý do observability phải đến trước Bài 17.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Ứng dụng lab dùng trong bài: cùng một use case bốn bước chạy được ở cả hai chế độ mode=monomode=micro, cộng endpoint /saga có và không có hành động bù — mọi con số trong bài đến từ file này:

Tải về app.js (endpoint /chain và /saga — 0 dependency)

Bài viết liên quan trong series

Bài 14: Event Sourcing & CQRS Bài 16: Observability — Metrics, Logs & Tracing Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận