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.

ℹ️ 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) đó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 dependencythờ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 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.

🚨 Cascade sống lâu hơn nguyên nhân sinh ra nó
Đây là điều làm cascade khác mọi loại sự cố khác, và là điều khiến nó khó xử lý nhất khi đang trực. Bạn tìm ra nguyên nhân gốc — một truy vấn chậm, một node chết — và bạn vá xong. Hệ thống vẫn sập. Vì thứ đang giữ nó sập không còn là nguyên nhân gốc nữa mà là lượng retry đang dồn ứ, và lượng đó tự nuôi chính nó.

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.
tang_nao_cung_the.js
// 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:

ket_qua_amplify.txt
$ ./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.

Mỗi tầng thử lại 3 lần — không tầng nào làm gì sai 1 request Tầng 1 (gateway) retry × 3 ×3 Tầng 2 (BFF) nhận 3 · retry × 3 ×3 Tầng 3 (service) nhận 9 · retry × 3 ×3 27 lần service tận cùng Lưu lượng tại mỗi tầng (số đo thật với 30 request người dùng): vào tầng 1: 30 vào tầng 2: 90 vào tầng 3: 270 vào service tận cùng: 810 Cùng kịch bản, bật retry budget 10% (số đo thật) 69 lần — thay vì 810. Giảm 11,7 lần. Budget giới hạn theo TỈ LỆ retry trên tổng lưu lượng, nên khi cả hệ đang hỏng thì cửa đóng lại — khác hẳn "tối đa 3 lần", vốn cho phép nhân lên đúng lúc không nên nhân.
Hệ số 27 không nằm trong code của tầng nào — nó chỉ tồn tại ở mức toàn hệ thống.
⚠️ Cạm bẫy: retry ở nhiều tầng cùng lúc
Quy tắc thực dụng đơn giản đến bất ngờ: chỉ một tầng được retry, và tầng đó nên là tầng gần người dùng nhất còn đủ ngữ cảnh để quyết định. Mọi tầng khác thất bại thì trả lỗi lên trên ngay.

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

💡 Jitter còn làm mọi thứ xong sớm hơn
Chi tiết dễ bỏ qua trong bảng trên: chế độ có jitter kết thúc sau 1.302 ms trong khi chế độ không jitter mất 1.694 ms. Nghe ngược trực giác — thêm ngẫu nhiên mà lại nhanh hơn?

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 độ 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.
200 client cùng hỏng một lúc — lưu lượng thử lại theo thời gian KHÔNG JITTER — đỉnh 185 / 20 ms · dồn cục 19,6× 100ms 300ms 700ms 1500ms 4 cột dựng đứng, ở giữa trống trơn CÓ JITTER — đỉnh 65 / 20 ms · dồn cục 5,3× trải đều, và xong sớm hơn (1.302 vs 1.694 ms) Cùng công thức backoff, cùng số lần thử. Khác biệt duy nhất: chờ đúng backoff, hay chờ ngẫu nhiên trong [0, backoff].
Hình dạng lưu lượng, không phải tổng lưu lượng, mới là thứ quyết định server có sống nổi hay không.

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.

ket_qua_deadline.json
{
  "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.

⚠️ Cạm bẫy: timeout tầng ngoài ngắn hơn tầng trong
Đây là cấu hình sai phổ biến nhất, và nó thường phát sinh một cách vô tình: mỗi đội đặt timeout hợp lý cho service của mình, không ai nhìn tổng thể chuỗi. Kết quả là toàn bộ tài nguyên phía sau bị đốt để tạo ra kết quả không ai nhận — và tệ hơn, nếu tầng ngoài lại retry sau khi timeout thì bạn có đồng thời cả hai vấn đề: công toi nhân với hệ số khuếch đại của mục 17.1.

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

Circuit breaker — ba trạng thái và điều kiện chuyển CLOSED cho qua · đếm lỗi OPEN từ chối NGAY · không gọi xuống HALF-OPEN thả đúng N request thăm dò đủ N lỗi liên tiếp hết thời gian chờ thăm dò THÀNH CÔNG → đóng lại thăm dò THẤT BẠI → mở lại NGAY Thiếu mũi tên "thăm dò thất bại → mở lại ngay" là một bug thật đã gặp khi viết lab này Breaker kẹt vĩnh viễn ở half-open sau khi cạn số thăm dò: từ chối MỌI request mãi mãi, kể cả sau khi dependency đã hồi phục — và im lặng hoàn toàn.
Bốn mũi tên, và mũi tên hay bị quên nhất lại là mũi tên khiến breaker không bao giờ tự phục hồi.

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.

🔬 Vì sao một dependency chậm hút cạn pool nhanh đến thế
Con số dễ tính và đáng giật mình. Giả sử service của bạn có pool 50 kết nối và phục vụ 500 request/giây. Bình thường mỗi lời gọi mất 10 ms, nên theo định luật Little số kết nối đang dùng là 500 × 0,01 = 5 — thoải mái trong 50.

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.
⚠️ Cạm bẫy: breaker đặt phạm vi quá rộng, hoặc half-open thả quá nhiều
Một breaker cho toàn bộ service nghĩa là một endpoint hỏng sẽ chặn luôn mọi endpoint khác — kể cả những endpoint hoàn toàn khoẻ mạnh. Phạm vi đúng thường là một breaker cho mỗi dependency, đôi khi mỗi endpoint quan trọ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ố.

⚠️ Cạm bẫy: đường fallback lại gọi chính thứ đang chết
Mẫu quen thuộc: "gọi service gợi ý; nếu lỗi thì đọc gợi ý mặc định từ database". Nghe rất hợp lý — cho tới khi bạn nhận ra service gợi ý chết database đang quá tải. Lúc đó đường fallback đổ thêm tải vào đúng thành phần đang hấp hối, và nó biến một sự cố cục bộ thành sự cố toàn hệ.

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

reproduce_measurements.sh
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

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ài viết liên quan trong series

Bài 16: Observability — Metrics, Logs & Tracing Bài 18: Capstone — Thiết Kế & Chạy Thật Một Hệ Thống Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận