Bài 16 vừa dựng xong bộ đo. Bài này dùng nó để nhìn một hiện tượng mà nếu chỉ nghe kể thì rất khó tin: hệ thống tự đánh sập chính mình bằng lưu lượng do chính nó sinh ra, sau khi nguyên nhân gốc đã qua từ lâu.
Bốn phép đo của lab. Thứ nhất, một chuỗi ba tầng mà mỗi tầng thử lại ba lần biến 30 request người dùng thành 810 lần đập vào service tận cùng — đúng 27 lần, và không tầng nào làm gì sai cả. Thứ hai, backoff không jitter làm lưu lượng dồn cục gấp 19,6 lần mức trung bình; thêm jitter kéo xuống 5,3 lần. Thứ ba, circuit breaker cắt số lời gọi vào một dependency đang ốm từ 17 xuống 7 và biến 5.291 ms ngồi chờ thành 2.173 ms. Thứ tư, timeout đặt sai chỗ khiến server làm trọn 50.000 ms công toi — 100 phản hồi hoàn chỉnh mà không ai nhận.
node:22-alpine (app1/app2/app3) đóng vai ba tầng service, mỗi container 1 CPU và 256 MB;
phần retry/breaker/deadline nằm ở worker/resilience.js chạy trong container thứ tư, không
dependency.Circuit breaker được đặt ở phía client — đúng chỗ nó thuộc về trong thực tế: người gọi mới là người biết khi nào nên thôi gọi. Phép đo breaker và deadline dùng một client tuần tự, nên các con số throughput trong đó không phải số đo năng lực; thứ cần đọc là số lời gọi đi xuống dependency và thời gian ngồi chờ.
17.1 Cascade failure: khi hệ thống tự đánh sập mình
Cơ chế lan truyền luôn giống nhau và không phụ thuộc công nghệ. Một dependency chậm đi — chưa chết, chỉ chậm. Người gọi nó giữ kết nối và luồng để chờ. Kết nối bị giữ lâu hơn nghĩa là pool cạn nhanh hơn. Pool cạn thì chính người gọi cũng bắt đầu chậm, và người gọi nó cũng bắt đầu giữ kết nối lâu hơn. Trong vài chục giây, một sự cố nhỏ ở tầng dưới cùng đã lan ngược lên tới người dùng.
Đặc điểm nguy hiểm nhất của cascade là nó tự duy trì: sau khi nguyên nhân gốc đã được khắc phục, hệ thống vẫn tiếp tục sập, vì bây giờ thứ giữ nó sập là chính lưu lượng retry đang dồn ứ. Và nguồn khuếch đại lớn nhất của lưu lượng đó là một thứ trông vô hại trong code review.
Hệ quả rất phản trực giác: cách thoát nhanh nhất thường là chủ động giảm tải xuống gần bằng không — chặn hẳn lưu lượng vào, để hàng đợi rút cạn, rồi mở lại từ từ. Nghe như đang tự làm hại mình giữa lúc sự cố, nhưng nếu không làm thì hệ thống không bao giờ có đủ khoảng trống để hồi phục. Đây cũng là lý do load shedding ở Bài 13 là một công cụ cứu hộ, không chỉ là công cụ phòng ngừa.
// This code shows up at EVERY layer, and at every layer it looks reasonable:
// "if the call fails, retry a few times, the network might just be flaky".
for (let attempt = 1; attempt <= 3; attempt++) {
const r = await callDownstream();
if (r.ok) return r;
}
throw new Error('downstream failed');
Không ai nhìn thấy vấn đề khi review đoạn đó, vì vấn đề không nằm trong đoạn đó — nó nằm ở tích của cả chuỗi. Lab dựng ba tầng thật, mỗi tầng chạy đúng đoạn code trên, rồi đếm số lần service tận cùng bị gọi:
$ ./tools/resilience-test.sh amplify 30 0
30 request nguoi dung · chuoi 3 tang · moi tang thu lai 3 lan · budget=0
app1 leafHits=810 retry=60 budgetDenied=0
app2 leafHits=0 retry=540 budgetDenied=0
app3 leafHits=0 retry=180 budgetDenied=0
(30 x 27 = 810 lan dap vao service tan cung)
810 lần cho 30 request. Hệ số 27 = 3 × 3 × 3, và nó xuất hiện đúng vào lúc service tận cùng đang yếu nhất — vì chính sự yếu đó mới sinh ra retry. Đây là vòng phản hồi dương thuần tuý: càng yếu thì càng bị gọi nhiều, càng bị gọi nhiều thì càng yếu.
Vấn đề là không ai cố ý vi phạm quy tắc này — nó bị vi phạm vì các tầng do các đội khác nhau viết, và mỗi đội chỉ nhìn thấy tầng của mình. Tệ hơn, retry thường nằm sẵn trong thư viện HTTP client với cấu hình mặc định đã bật, nên nó không xuất hiện trong bất kỳ dòng code nào để mà review. Cách kiểm tra duy nhất đáng tin: đếm số lần service tận cùng bị gọi cho một request, đúng như phép đo trên.
17.2 Retry cho đúng: điều kiện, backoff, và jitter
Ba điều kiện phải thoả đồng thời thì mới được retry. Thiếu một là hỏng:
| Điều kiện | Vì sao | Hỏng thế nào nếu bỏ qua |
|---|---|---|
| Lỗi phải là tạm thời | Timeout, 503, đứt kết nối — thứ có thể khác đi ở lần sau | Retry một lỗi 400 hay 404 chỉ đốt tài nguyên; lần sau vẫn sai y hệt |
| Thao tác phải idempotent | Retry nghĩa là thao tác có thể chạy hai lần (Bài 11) | Trừ tiền hai lần, gửi email hai lần — và bạn không biết chuyện đó đã xảy ra |
| Phải có backoff kèm jitter | Thử lại ngay là dồn thêm tải vào đúng lúc hệ đang yếu | Sóng retry đồng bộ đánh sập lại server vừa hồi phục |
Điều kiện thứ ba là điều kiện hay bị làm nửa vời nhất: nhiều hệ có backoff nhưng không có jitter. Hậu quả rất cụ thể — khi một service sập rồi sống lại, tất cả client đã cùng gặp lỗi tại một thời điểm, nên chúng cũng cùng chờ đúng 100 ms, rồi cùng chờ đúng 200 ms, rồi 400 ms. Mỗi mốc đó là một đợt sóng đồng bộ.
Lab cho 200 client cùng hỏng một lúc rồi thử lại 4 lần, hai chế độ dùng cùng công thức backoff:
| Chế độ | Đỉnh mỗi 20 ms | Trung bình mỗi 20 ms | Hệ số dồn cục | Kéo dài |
|---|---|---|---|---|
| Backoff không jitter | 185 | 9,4 | 19,6× | 1.694 ms |
| Backoff có jitter | 65 | 12,3 | 5,3× | 1.302 ms |
Cột đáng nhìn là hệ số dồn cục — đỉnh cao gấp bao nhiêu lần mức trung bình. Không jitter cho 19,6 lần: server vừa hồi phục phải hứng một cú gấp gần 20 lần tải trung bình, và cú đó thường đủ để giết nó lần nữa, sinh ra vòng lặp sập–hồi–sập. Thêm jitter kéo hệ số xuống 5,3 và đỉnh xuống còn một phần ba.
Lý do: "full jitter" chờ một khoảng ngẫu nhiên trong
[0, backoff] thay vì đúng
backoff, nên thời gian chờ kỳ vọng chỉ bằng một nửa. Bạn được cả hai thứ cùng lúc: tải trải
đều hơn và độ trễ trung bình thấp hơn. Đây là một trong số rất ít chỗ trong series này mà một
thay đổi cải thiện cả hai chiều của đánh đổi.
17.3 Timeout và deadline: đừng làm việc cho một request đã chết
Mọi lời gọi mạng phải có timeout. Câu này nghe hiển nhiên tới mức dễ bỏ qua, nhưng mặc định của rất nhiều thư viện HTTP là vô hạn — nghĩa là mặc định của bạn cũng đang là vô hạn, trừ khi bạn đã chủ động đặt. Một lời gọi không timeout giữ kết nối vô thời hạn, và đó chính là bước đầu tiên của cơ chế cascade ở mục 17.1.
Nhưng có timeout thôi chưa đủ; timeout phải giảm dần theo độ sâu. Nếu tầng ngoài chờ 1 giây còn tầng trong chờ 3 giây, thì tầng ngoài bỏ cuộc trước trong khi cả chuỗi phía sau vẫn cày tiếp cho một request không còn ai chờ. Lab đo đúng tình huống đó: client bỏ cuộc sau 100 ms, server làm việc 500 ms.
{
"role": "deadline",
"soRequest": 100,
"timeoutPhiaClient": 100, // client gives up, all 100 requests
"serverVanLamViec": 100, // server STILL does all 100 x 500ms of work
"phanHoiKhongAiNhan": 100, // 100 complete responses, nobody receives them
"congToiMs": 50000 // 50 seconds of CPU and I/O for nobody
}
Điểm đáng sợ nhất của con số này là nó vô hình trên dashboard. Nhìn từ phía server, 100 request đó hoàn tất bình thường, không lỗi, thời gian xử lý đúng như thiết kế. Nhìn từ phía người dùng, cả 100 đều thất bại. Hai bên nhìn cùng một hệ thống và thấy hai sự thật trái ngược — và bên có dashboard là bên đang nhìn sai.
Cách sửa gọi là truyền deadline: thay vì mỗi tầng tự đặt timeout riêng, tầng đầu tính ra một mốc thời gian tuyệt đối mà request phải xong trước đó, rồi truyền mốc ấy xuống dưới. Mỗi tầng kiểm tra mốc đó trước khi làm việc, và bỏ ngay nếu đã quá hạn. Kết quả: không ai làm việc cho một request đã chết.
Kiểm tra rẻ nhất mà hiếm ai làm: vẽ ra timeout của từng chặng trên một dòng thời gian. Chúng phải giảm dần từ ngoài vào trong, và tổng timeout của các chặng con phải nhỏ hơn timeout của chặng cha. Nếu chưa từng vẽ, khả năng cao là bạn đang có ít nhất một chỗ ngược.
17.4 Circuit breaker và bulkhead
Retry giả định lỗi là tạm thời. Khi một dependency đã hỏng thật sự, giả định đó sai — và mỗi lần thử lại chỉ thêm một khoảng chờ nữa trước khi nhận cùng một lỗi. Circuit breaker là cơ chế nhận ra điều đó và ngừng gọi hẳn trong một khoảng thời gian, cho dependency chỗ thở và cho chính mình khỏi phải ngồi chờ.
Lab cho một dependency chết trong 5 giây đầu (mỗi lời gọi treo 300 ms rồi trả 503) rồi sống lại:
| Gọi xuống dependency đang ốm | Thời gian ngồi chờ lãng phí | Từ chối tức thì | Hồi phục sau | |
|---|---|---|---|---|
| Không breaker | 17 | 5.291 ms | 0 | 299 ms |
| Có breaker | 7 | 2.173 ms | 407 | 188 ms |
Đọc bảng theo hai chiều. Với dependency: số lời gọi đập vào nó giảm 59% đúng lúc nó cần yên tĩnh nhất. Với chính người gọi: 5,3 giây ngồi chờ biến thành 2,2 giây, và 407 request được trả lời ngay lập tức thay vì treo — nghĩa là luồng và kết nối được giải phóng, tức là cascade bị chặn ngay tại đây.
Bulkhead giải một vấn đề anh em: ngay cả khi có breaker, nếu mọi dependency dùng chung một connection pool thì một dependency chậm vẫn hút cạn pool trước khi breaker kịp mở. Cách vá là chia pool riêng cho từng dependency — đúng như khoang kín trên tàu thuỷ mà cái tên gợi ra: một khoang ngập nước không làm chìm cả tàu.
Giờ dependency chậm đi thành 2 giây (chưa chết, chỉ chậm). Cùng lưu lượng đó cần 500 × 2 = 1.000 kết nối — gấp 20 lần sức chứa. Pool cạn trong chưa tới một trăm mili giây, và từ giây đó mọi request khác, kể cả request không liên quan gì tới dependency chậm, đều xếp hàng chờ một kết nối.
Đó là toàn bộ lý lẽ của bulkhead: nếu dependency chậm chỉ được dùng 10 kết nối trong pool riêng của nó, thì nó cạn pool của nó và dừng lại ở đó — 40 kết nối còn lại vẫn phục vụ mọi thứ khác bình thường. Giới hạn không phải để bảo vệ dependency, mà để khoanh vùng thiệt hại khi nó hỏng.
Ở đầu kia, half-open thả quá nhiều request thăm dò cùng lúc là cách chắc chắn để giết lại dependency vừa ngóc đầu dậy — nó vừa hồi phục với năng lực 10%, và bạn ném vào 100% tải. Lab dùng đúng 2 request thăm dò. Con số nhỏ là chủ ý: mục tiêu của half-open là hỏi một câu, không phải nối lại dịch vụ.
17.5 Suy giảm có kiểm soát và chaos engineering
Ba mục trên đều nói về chuyện không làm hệ thống tệ thêm. Mục này nói về chuyện làm nó vẫn còn dùng được khi một phần đã hỏng. Nguyên tắc: một kết quả suy giảm gần như luôn tốt hơn một trang lỗi.
- Trang sản phẩm mất phần gợi ý cá nhân hoá — vẫn mua được hàng.
- Dữ liệu cache cũ 10 phút thay vì lỗi — với đa số màn hình, không ai nhận ra.
- Ẩn số lượt xem khi service thống kê chết — không ai vào vì con số đó.
- Giá trị mặc định an toàn thay vì gọi service cấu hình — miễn là mặc định đó được kiểm thử.
Điều kiện tiên quyết giống hệt điều kiện của load shedding có ưu tiên ở Bài 13: bạn phải biết trước đường đi nào phải sống bằng mọi giá, và biết trước phần nào có thể tắt. Đó là quyết định sản phẩm, không phải quyết định kỹ thuật, và nó phải có trước sự cố.
Fallback phải phụ thuộc vào ít thứ hơn đường chính, không phải phụ thuộc vào những thứ khác. Giá trị hằng số nhúng trong code là fallback tốt nhất vì nó không phụ thuộc vào gì cả.
Người anh em của cạm bẫy này: cơ chế failover chưa bao giờ được diễn tập. Một đường dự phòng không chạy suốt sáu tháng thì đến lúc cần nó cũng hỏng — quyền đã hết hạn, cấu hình đã lệch, hoặc đơn giản là không ai còn nhớ cách bật. Đó chính là lý do chaos engineering tồn tại: chủ động tiêm lỗi vào giờ hành chính, khi mọi người còn tỉnh táo, thay vì để lỗi tự tìm đến lúc 3 giờ sáng.
Tự làm lại các phép đo
cd blog/sysdesign/sysdesign-lab
# --- Retry amplification: 3-tier chain, each tier retries 3 times ---
./tools/resilience-test.sh amplify 30 0 # 810 hits on the leaf service (27x)
./tools/resilience-test.sh amplify 30 1 # 69 hits with a 10% retry budget enabled (11.7x fewer)
# --- Retry storm: with and without jitter ---
./tools/resilience-test.sh jitter
# no jitter: peak 185/20ms · mean 9.4 · burst factor 19.6x · lasts 1,694ms
# jitter : peak 65/20ms · mean 12.3 · burst factor 5.3x · lasts 1,302ms
# --- Circuit breaker: dependency dies for 5s then recovers ---
./tools/resilience-test.sh breaker
# no breaker: 17 calls downstream · 5,291ms wasted waiting · 0 immediate rejections
# breaker : 7 calls downstream · 2,173ms wasted waiting · 407 immediate rejections
# --- Timeout placed in the wrong spot ---
./tools/resilience-test.sh deadline
# 100 client-side timeouts · server STILL does all 100 · 50,000ms wasted work
Tóm lại
Cascade failure không phải một loại lỗi mà là một chế độ hoạt động mà hệ thống rơi vào, và nó tự duy trì sau khi nguyên nhân gốc đã qua. Nguồn năng lượng nuôi nó là retry: ba tầng, mỗi tầng ba lần, biến 30 request thành 810 lần đập vào service tận cùng — đúng lúc service đó yếu nhất. Không tầng nào làm gì sai; hệ số 27 chỉ tồn tại ở mức toàn hệ thống, nên cách duy nhất phát hiện là đếm số lần service tận cùng bị gọi cho một request.
Ba cơ chế vá, mỗi cái giải một phần khác nhau. Retry budget chặn chính sự khuếch đại: 810 xuống 69, giảm 11,7 lần, vì nó giới hạn theo tỉ lệ chứ không theo số lần. Jitter chặn sóng đồng bộ: hệ số dồn cục 19,6 lần xuống 5,3 lần, và còn xong sớm hơn. Circuit breaker chặn việc ngồi chờ: 5,3 giây chờ thành 2,2 giây, 407 request được trả lời ngay thay vì treo — và đó chính là chỗ luồng, kết nối được giải phóng.
Về timeout, con số đáng nhớ nhất của bài là 50.000 ms công toi: 100 phản hồi hoàn chỉnh mà không ai nhận, và không có gì trên dashboard của server báo hiệu điều đó — từ phía nó, mọi request đều hoàn tất bình thường. Timeout phải giảm dần từ ngoài vào trong, và cách đúng là truyền deadline tuyệt đối chứ không phải để mỗi tầng tự đặt số của mình.
Cuối cùng, một bài học đến từ chính quá trình viết lab này: bản circuit breaker đầu tiên thiếu nhánh "thăm dò thất bại → mở lại ngay", và nó kẹt vĩnh viễn ở half-open — từ chối mọi request mãi mãi kể cả sau khi dependency đã khoẻ, mà không ném ra một lỗi nào. Cơ chế chống chịu cũng là code, và code cũng có bug; khác biệt là bug ở đây chỉ lộ ra đúng lúc bạn cần nó nhất. Đó là toàn bộ lý lẽ của chaos engineering.
Bài 18 là bài cuối: ghép tất cả lại thành một hệ thống thật, chạy nó, đo nó, tìm nút cổ chai, vá, rồi đo lại — với số liệu trước và sau cho từng bước, để thấy rõ bước nào đáng công và bước nào chỉ thêm phức tạp.
📖 Tài liệu tham khảo
- Google SRE Book — Addressing Cascading Failures: cơ chế lan truyền và vì sao giảm tải chủ động lại là cách thoát, nguồn cho mục 17.1
- AWS Architecture Blog — Exponential Backoff and Jitter: nguồn gốc "full jitter" và lý do nó vừa trải đều tải vừa xong sớm hơn, nguồn cho mục 17.2
- gRPC — Deadlines: mô hình truyền một mốc thời gian tuyệt đối qua từng chặng thay vì mỗi tầng tự đặt timeout riêng, nguồn cho mục 17.3
- Martin Fowler — CircuitBreaker: máy trạng thái closed/open/half-open, nguồn cho mục 17.4
- Microsoft Azure Architecture Center — Bulkhead pattern: cô lập pool tài nguyên theo dependency, nguồn cho mục 17.4
- Principles of Chaos Engineering: định nghĩa và nguyên tắc chủ động tiêm lỗi để kiểm chứng cơ chế chống chịu, nguồn cho mục 17.5
Tải mã nguồn thực hành
Bộ cơ chế chống chịu dùng trong lab: circuit breaker ba trạng thái, backoff có/không jitter, deadline truyền xuyên tầng — mọi con số trong bài đến từ file này:
Tải về resilience.js (circuit breaker, jitter, deadline — 0 dependency)
Bình luận