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.
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â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ó.
Hai con số phải nhìn cùng nhau khi tách service là p99 và xá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:
$ ./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ể.
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.
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.
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 đó.
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ư.
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
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
- Martin Fowler & James Lewis — Microservices: bài viết định hình thuật ngữ, nguồn cho mục 15.1 về sự khác biệt tổ chức so với module hoá
- Wikipedia — Conway's law: nguồn cho luận điểm cấu trúc hệ thống phản chiếu cấu trúc tổ chức ở mục 15.1
- Microsoft Azure Architecture Center — Saga pattern: mô tả chính thức về hành động bù, nguồn cho mục 15.3
- Wikipedia — Two-phase commit protocol: cơ chế và cái giá của việc khoá tài nguyên xuyên suốt cả hai pha, nguồn cho callout "Vì sao không kéo 2PC vào" ở mục 15.3
- Martin Fowler — Bounded Context: khái niệm DDD dùng làm nguyên tắc cắt service ở mục 15.4
- Martin Fowler — StranglerFigApplication: mẫu di trú dần từ monolith sang service mới, nguồn cho mục 15.5
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=mono và mode=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:
Bình luận