Thứ Sáu, 16 giờ. Team vừa deploy xong phiên bản mới của order service. Mọi thứ xanh lè trên dashboard. 17 giờ, payment gateway của bên thứ ba bắt đầu chậm, response time từ 200ms nhảy lên 8 giây. Không phải sập hẳn, chỉ chậm. Order service gọi payment gateway, mỗi request giữ một connection trong pool chờ response. Connection pool 50 slot đầy trong vòng 1 phút. Request mới vào order service không lấy được connection, bắt đầu queue. Thread pool của order service cạn kiệt. Health check timeout, load balancer đánh dấu order service unhealthy. Inventory service gọi order service để verify stock reservation, cũng bắt đầu timeout. Notification service gọi order service để lấy order detail cho email xác nhận, timeout luôn. Trong vòng 8 phút, một payment gateway chậm đã kéo sập ba service không liên quan trực tiếp đến thanh toán.

Đây không phải kịch bản giả định. Cascading failure kiểu này xảy ra thường xuyên trong hệ thống microservices, và nguyên nhân gốc luôn giống nhau: một dependency chậm giữ resource của caller quá lâu, caller hết resource, trở thành dependency chậm cho caller tiếp theo, lan truyền ngược lên toàn bộ call chain. Giải pháp không phải “thêm server” hay “tăng timeout”, mà là thiết kế hệ thống để failure không lan truyền.

Ba pattern resilience cốt lõi, timeout, retry, circuit breaker, cùng với bulkhead để cô lập resource, tạo thành bộ công cụ chống cascading failure. Nhưng thêm library resilience4j hay Polly vào project không tự động làm hệ thống resilient. Pattern phải được thiết kế đúng chỗ, với đúng tham số, và quan trọng nhất: phải phối hợp với nhau thay vì chống nhau.


Cascading failure, vì sao một service chậm giết cả hệ thống

Để hiểu resilience pattern, trước hết cần hiểu cơ chế lan truyền lỗi. Trong hệ thống monolith, một function chậm ảnh hưởng thread đang chạy nó, nhưng các thread khác vẫn hoạt động bình thường, trừ khi function đó giữ lock chung. Trong microservices, mỗi lời gọi giữa service là một network call tiêu tốn resource: connection từ pool, thread từ thread pool, memory cho buffer, file descriptor cho socket.

Khi downstream service chậm nhưng không sập hẳn, caller giữ resource chờ response. Đây là trạng thái nguy hiểm nhất, tệ hơn cả downstream sập hoàn toàn. Nếu downstream sập, caller nhận error ngay lập tức (connection refused), giải phóng resource, trả lỗi cho client trong millisecond. Nhưng khi downstream chậm, caller phải chờ đến hết timeout, 30 giây mặc định của nhiều HTTP client, trước khi giải phóng resource. Mỗi request giữ resource 30 giây thay vì 200ms nghĩa là throughput giảm 150 lần.

flowchart TD subgraph "Cascading Failure" U[User Request] --> A[API Gateway] A --> B[Order Service] B --> C[Payment Gateway, chậm 8s] B -.->|connection pool cạn| D[Thread pool cạn] D -.->|timeout| E[Order Service unhealthy] E -.->|lan truyền| F[Inventory Service timeout] E -.->|lan truyền| G[Notification Service timeout] end ```text style C fill:#e74c3c,color:#fff style D fill:#e67e22,color:#fff style E fill:#e74c3c,color:#fff style F fill:#e74c3c,color:#fff style G fill:#e74c3c,color:#fff ```

Resource cạn kiệt là cơ chế chính của cascading failure. Connection pool có giới hạn, thường 10-100 connection tuỳ cấu hình. Thread pool có giới hạn, Tomcat mặc định 200 thread, Node.js thì single-threaded nhưng giới hạn bởi số concurrent request mà event loop xử lý được trước khi latency tăng quá cao. File descriptor có giới hạn, mỗi socket mở tiêu tốn một fd, Linux mặc định 1024 per process. Khi bất kỳ resource nào cạn, service không thể nhận request mới, kể cả request không liên quan đến dependency đang chậm.

Điểm mấu chốt: trong distributed system, chậm nguy hiểm hơn sập. Hệ thống cần được thiết kế để phản ứng nhanh với cả hai trường hợp, và “phản ứng nhanh” nghĩa là giải phóng resource sớm nhất có thể khi phát hiện dependency không healthy.


Timeout, pattern cơ bản nhất nhưng hay sai nhất

Hiểu nôm na thì timeout giống như gọi điện cho đối tác, nếu sau 5 tiếng đổ chuông không ai bắt máy, bạn cúp máy và làm việc khác, không đứng đó ôm điện thoại đến khi hết pin.

Timeout là tuyến phòng thủ đầu tiên chống cascading failure. Ý tưởng đơn giản: nếu downstream không trả lời trong khoảng thời gian cho phép, huỷ request, giải phóng resource, trả lỗi cho caller. Nhưng “đơn giản” không có nghĩa “dễ làm đúng”.

Connect timeout và read timeout

Hai loại timeout này phục vụ mục đích khác nhau và cần giá trị khác nhau. Connect timeout là thời gian chờ thiết lập TCP connection, bao gồm DNS lookup, TCP handshake, TLS handshake. Nếu connect timeout, downstream hoặc đã sập hoặc network có vấn đề. Giá trị hợp lý thường 1-3 giây, nếu không connect được trong 3 giây thì rất ít khả năng connect được ở giây thứ 10.

Read timeout (hay socket timeout) là thời gian chờ response sau khi đã thiết lập connection. Giá trị này phụ thuộc vào đặc thù của downstream service, endpoint trả về trong 50ms trung bình cần read timeout khác với endpoint chạy report mất 10 giây. Đặt read timeout quá ngắn sẽ cắt request hợp lệ đang xử lý, quá dài sẽ giữ resource quá lâu khi downstream chậm.

Sai lầm phổ biến nhất: dùng timeout mặc định của HTTP client. axios mặc định không có timeout, request treo vĩnh viễn nếu server không trả lời. HttpClient của Java mặc định cũng vô hạn. Go http.Client mặc định không timeout. Hầu hết HTTP client ra đời trong thời monolith, khi “server không trả lời” là tình huống hiếm gặp. Trong microservices, đó là tình huống hàng ngày.

Quy tắc: mọi network call phải có explicit timeout. Không bao giờ dùng giá trị mặc định. Đặt connect timeout 1-3 giây, read timeout dựa trên P99 latency của downstream nhân 2-3 lần. Endpoint có P99 200ms thì read timeout 500ms-1s là hợp lý.

Timeout budget xuyên call chain

Đây là khái niệm ít được nói đến nhưng cực kỳ quan trọng trong hệ thống có nhiều hop. Khi user gọi API gateway, gateway gọi service A, A gọi service B, B gọi service C, mỗi service set timeout riêng. Nếu gateway timeout 10 giây, A timeout 10 giây khi gọi B, B timeout 10 giây khi gọi C, thì tổng worst case là 30 giây, trong khi user chỉ sẵn sàng chờ 10 giây.

Timeout budget (hay deadline propagation) giải quyết vấn đề này: gateway set deadline “request này phải hoàn thành trước thời điểm T” rồi truyền deadline xuống. Service A nhận deadline, trừ đi thời gian đã dùng, truyền deadline còn lại cho B. Nếu A mất 3 giây xử lý, B chỉ còn 7 giây. Nếu B mất 5 giây, C chỉ còn 2 giây. Nếu deadline đã hết trước khi gọi downstream, service trả lỗi ngay, không gọi downstream vô nghĩa.

gRPC hỗ trợ deadline propagation native qua metadata. HTTP thì phải implement thủ công, thường qua custom header X-Request-Deadline hoặc X-Timeout-Budget-Ms. Không phải framework nào cũng hỗ trợ, nhưng với hệ thống có 3+ hop thì đầu tư implement deadline propagation rất đáng, nó ngăn request zombie đi sâu vào hệ thống khi đã quá hạn.


Retry, đơn giản nhưng nguy hiểm nếu sai

Retry là pattern tự nhiên nhất: request fail thì thử lại. Nhiều lỗi trong distributed system là transient, network glitch, downstream đang restart, connection bị drop giữa chừng. Retry giúp tự phục hồi mà không cần human intervention. Nhưng retry sai cách là cách nhanh nhất để biến một service chậm thành một service sập.

Từ simple retry đến exponential backoff với jitter

Simple retry, fail thì thử lại ngay lập tức, tối đa N lần, hoạt động cho lỗi cực kỳ transient (packet loss ngẫu nhiên). Nhưng nếu downstream đang overload, retry ngay lập tức gửi thêm load vào hệ thống đang quá tải, đổ thêm dầu vào lửa.

Exponential backoff cải thiện bằng cách tăng khoảng cách giữa các lần retry: 100ms, 200ms, 400ms, 800ms, 1600ms. Downstream có thời gian phục hồi giữa các lần retry. Nhưng nếu 1000 client cùng fail tại thời điểm T, tất cả đều retry sau 100ms, tại T+100ms downstream nhận 1000 request cùng lúc. Rồi tất cả fail, retry sau 200ms, tại T+300ms lại 1000 request. Đây là thundering herd, các client đồng bộ vô tình tạo spike tuần hoàn.

Jitter phá vỡ đồng bộ này bằng cách thêm thành phần ngẫu nhiên vào delay. Thay vì tất cả retry sau đúng 200ms, mỗi client retry sau 200ms + random(0, 200ms), request phân tán đều trong khoảng 200-400ms thay vì spike tại 200ms. Full jitter (delay = random(0, backoff_max)) hoặc decorrelated jitter cho phân bố đều hơn nữa.

// Exponential backoff with full jitter
delay = random(0, min(cap, base * 2^attempt))

// Ví dụ: base=100ms, cap=30s
// Attempt 0: random(0, 100ms)
// Attempt 1: random(0, 200ms)
// Attempt 2: random(0, 400ms)
// Attempt 3: random(0, 800ms)
// ...cho đến cap 30s

AWS có bài phân tích nổi tiếng về jitter strategy, kết luận là full jitter cho throughput tốt nhất trong hầu hết trường hợp vì nó phân tán retry đều nhất theo thời gian.

Retry amplification, bài toán nhân lên theo tầng

Đây là rủi ro lớn nhất của retry mà nhiều engineer bỏ qua. Hình dung call chain: gateway → A → B → C. Nếu mỗi layer retry 3 lần:

Khi C fail, B retry 3 lần, C nhận 3 request. B trả lỗi cho A, A retry 3 lần, mỗi lần A retry, B lại retry 3 lần gọi C. C nhận 3 × 3 = 9 request. A trả lỗi cho gateway, gateway retry 3 lần, mỗi lần gateway retry, chuỗi A→B→C lặp lại. C nhận 3 × 3 × 3 = 27 request từ một request ban đầu của user.

Downstream đang chịu tải 1x bình thường, retry amplification biến thành 27x, gần như chắc chắn sập nếu chưa sập. Và khi sập thì mọi retry tiếp theo đều fail, tạo vòng lặp: retry → thêm tải → thêm fail → thêm retry.

Retry budget giải quyết vấn đề này: mỗi service có ngân sách retry, ví dụ “không quá 10% tổng request là retry”. Nếu trong 10 giây qua, service đã gửi 100 request đến downstream mà 10 request là retry, dừng retry cho đến khi tỷ lệ giảm. Cách implement đơn giản: đếm số request gốc và số retry trong sliding window, từ chối retry khi tỷ lệ vượt ngưỡng.

Quy tắc thực tế: chỉ retry ở một layer, thường là layer gần user nhất (gateway hoặc BFF). Các layer giữa không retry mà trả lỗi nhanh cho caller xử lý. Nếu buộc phải retry ở nhiều layer, giảm số lần retry ở layer sâu (C retry 1 lần, B retry 1 lần, A retry 2 lần) và dùng retry budget.

Retry chỉ an toàn cho idempotent operation

Retry một request POST tạo order có thể tạo hai order. Retry request chuyển tiền có thể chuyển hai lần. Đây không phải lỗi lý thuyết, đây là incident thật mà nhiều hệ thống payment đã gặp.

Chỉ retry khi operation là idempotent, gọi nhiều lần cho cùng kết quả. GET, DELETE (theo spec) thường idempotent. POST, PATCH thường không. Nếu cần retry non-idempotent operation, dùng idempotency key: client gửi kèm key duy nhất, server lưu key và trả kết quả cũ nếu thấy key đã xử lý.

Retry cũng nên phân biệt lỗi retryable và non-retryable. HTTP 503 (Service Unavailable) là retryable, downstream tạm thời quá tải. HTTP 400 (Bad Request) là non-retryable, request sai thì retry bao nhiêu lần cũng sai. HTTP 500 thì tuỳ context, nếu là bug thì retry vô ích, nếu là race condition tạm thời thì retry có thể thành công.


Circuit breaker, fail-fast thay vì chờ timeout

Retry giúp phục hồi từ lỗi transient. Nhưng khi downstream không phải lỗi tạm, đang sập thật sự hoặc chậm nghiêm trọng, retry chỉ tốn thêm resource và kéo dài thời gian failure. Circuit breaker phát hiện downstream đang unhealthy và ngắt mạch: trả lỗi ngay lập tức cho caller mà không gọi downstream, giải phóng resource, cho downstream thời gian phục hồi.

State machine: closed, open, half-open

Circuit breaker hoạt động như cầu dao điện trong nhà, khi phát hiện quá tải thì ngắt mạch để bảo vệ hệ thống.

stateDiagram-v2 [*] --> Closed Closed --> Open: Failure count >= threshold\n(vd: 5 lỗi trong 10 giây) Open --> HalfOpen: Sau reset timeout\n(vd: 30 giây) HalfOpen --> Closed: Success count >= threshold\n(vd: 3 request thành công liên tiếp) HalfOpen --> Open: Bất kỳ request nào fail ```text note right of Closed Bình thường: request đi qua downstream. Đếm lỗi liên tục. end note note right of Open Fail-fast: trả lỗi ngay, không gọi downstream. Chờ reset timeout. end note note right of HalfOpen Thăm dò: cho vài request qua để kiểm tra downstream đã hồi phục chưa. end note ```

Closed là trạng thái bình thường, mọi request đi qua downstream. Circuit breaker đếm số lỗi trong sliding window. Khi số lỗi vượt threshold (ví dụ 5 lỗi trong 10 giây, hoặc 50% request fail trong 60 giây), chuyển sang Open.

Open là trạng thái bảo vệ, circuit breaker chặn mọi request, trả lỗi ngay lập tức (thường là HTTP 503 hoặc custom error) mà không gọi downstream. Caller nhận lỗi trong millisecond thay vì chờ timeout 10-30 giây. Resource được giải phóng ngay. Downstream không bị thêm tải, có thời gian phục hồi.

Sau reset timeout (ví dụ 30 giây), circuit breaker chuyển sang Half-Open, cho một số lượng nhỏ request đi qua để thăm dò. Nếu request thăm dò thành công (đủ success threshold), downstream đã phục hồi, chuyển về Closed. Nếu bất kỳ request thăm dò nào fail, quay lại Open và chờ thêm reset timeout.

Chọn threshold đúng

Threshold quá thấp (1-2 lỗi) làm circuit mở quá sớm, một lỗi transient ngẫu nhiên đã ngắt mạch, user thấy lỗi không cần thiết. Threshold quá cao (50 lỗi) làm circuit mở quá muộn, downstream đã chết 30 giây mà circuit vẫn gửi request, resource đã cạn.

Cách tiếp cận thực tế: dùng failure rate trong sliding window thay vì failure count tuyệt đối. “50% request fail trong 60 giây với minimum 10 request” tốt hơn “5 request fail”, vì nó tự điều chỉnh theo traffic volume. Service có QPS 1000 cần threshold khác service có QPS 10.

Reset timeout cũng cần cân nhắc: quá ngắn thì half-open check quá sớm khi downstream chưa kịp phục hồi, quay lại open rồi thử lại, tạo vòng lặp. Quá dài thì downstream đã phục hồi nhưng caller vẫn trả lỗi thêm vài phút vô ích. 15-60 giây là khoảng phổ biến, tuỳ thời gian phục hồi trung bình của downstream.

Success threshold trong half-open: bao nhiêu request thành công liên tiếp trước khi close circuit? 1 thì quá aggressive, một request may mắn chưa chứng minh downstream stable. 3-5 là con số mà nhiều team dùng, đủ confidence mà không giữ half-open quá lâu.

Khi circuit open, fail-fast và fallback

Giá trị cốt lõi của circuit breaker là fail-fast: thay vì caller chờ 10 giây timeout rồi mới biết downstream chết, circuit breaker trả lỗi trong dưới 1 millisecond. Caller giải phóng resource ngay, có thể xử lý lỗi nhanh, trả fallback cho user, degrade feature, hoặc báo lỗi rõ ràng.

Nhưng “trả lỗi 503” không phải lúc nào cũng là trải nghiệm tốt nhất cho user. Đây là lúc cần fallback strategy, sẽ bàn chi tiết ở phần sau.


Bulkhead, cô lập để không chìm cả tàu

Tên gọi “bulkhead” lấy từ thiết kế tàu thuỷ: thân tàu được chia thành nhiều khoang kín bởi các vách ngăn (bulkhead). Khi một khoang bị thủng và ngập nước, nước không tràn sang khoang khác, tàu vẫn nổi. Không có bulkhead, một lỗ thủng nhỏ ngập toàn bộ thân tàu.

Trong phần mềm, bulkhead cô lập resource theo dependency: mỗi downstream service được phân bổ resource riêng (thread pool, connection pool, semaphore) thay vì chia sẻ chung. Khi payment gateway chậm và cạn hết connection pool của nó, pool đó không ảnh hưởng đến connection pool gọi inventory service hay notification service. Các flow không liên quan đến payment vẫn hoạt động bình thường.

flowchart TD subgraph "Không có Bulkhead" A1[Order Service] --> P1[Shared Pool: 50 conn] P1 --> D1[Payment Gateway, chậm] P1 --> D2[Inventory Service] P1 --> D3[Notification Service] P1 -.->|"50/50 conn bị Payment giữ"| X1[Pool cạn, tất cả fail] end ```text subgraph "Có Bulkhead" A2[Order Service] --> P2[Payment Pool: 20 conn] A2 --> P3[Inventory Pool: 15 conn] A2 --> P4[Notification Pool: 15 conn] P2 --> D4[Payment Gateway, chậm] P3 --> D5[Inventory Service, vẫn OK] P4 --> D6[Notification Service, vẫn OK] P2 -.->|"20/20 conn bị giữ"| X2[Chỉ Payment fail] end style D1 fill:#e74c3c,color:#fff style X1 fill:#e74c3c,color:#fff style D4 fill:#e74c3c,color:#fff style X2 fill:#e67e22,color:#fff style D5 fill:#27ae60,color:#fff style D6 fill:#27ae60,color:#fff ```

Các kiểu bulkhead

Thread pool bulkhead, phổ biến nhất trong Java/JVM. Mỗi downstream dependency được gán một thread pool riêng. Hystrix (Netflix, đã deprecated) dùng pattern này. resilience4j cũng hỗ trợ. Khi pool cạn, request mới bị reject ngay (fail-fast) thay vì chờ. Nhược điểm: tạo nhiều thread pool tốn memory, và context switch giữa thread có overhead.

Semaphore bulkhead, nhẹ hơn thread pool. Dùng semaphore (bộ đếm concurrent) giới hạn số request đồng thời tới mỗi dependency. Không tạo thread mới, request vẫn chạy trên thread hiện tại, chỉ bị gate bởi semaphore. Phù hợp cho async/reactive framework (Node.js, Go goroutine, Kotlin coroutine) nơi thread pool không phải abstraction tự nhiên.

Connection pool bulkhead, mỗi downstream có connection pool riêng với max size riêng. Đây là bulkhead tự nhiên nhất vì hầu hết HTTP client đều support pool per host. Cấu hình maxConnectionsPerHost thay vì dùng global pool. OkHttp, HttpClient (.NET), Go http.Transport đều cho phép pool riêng cho mỗi target.

Chọn kiểu nào tuỳ runtime và framework. Trong JVM dùng thread pool hoặc semaphore. Trong Node.js/Go dùng semaphore hoặc connection pool. Quan trọng là mỗi dependency có resource riêng, không chia sẻ, đó là nguyên tắc cốt lõi của bulkhead.

Sizing bulkhead

Mỗi bulkhead cần được sizing phù hợp. Pool quá nhỏ thì request hợp lệ bị reject khi traffic bình thường. Pool quá lớn thì mất ý nghĩa isolation, pool lớn cho dependency nguy hiểm vẫn ăn hết resource khi dependency đó chậm.

Công thức ước lượng đơn giản: pool_size = QPS_peak × latency_P99. Nếu downstream có QPS peak 100 request/giây và P99 latency 200ms, cần ít nhất 100 × 0.2 = 20 connection đồng thời. Cộng thêm headroom 20-50% cho spike: 25-30 connection. Đây là điểm khởi đầu, tune dựa trên monitoring thực tế.

Quan trọng: khi downstream chậm, latency tăng nhưng QPS không giảm ngay → concurrent connection tăng vọt. Bulkhead size phải đủ nhỏ để fail-fast khi vượt ngưỡng thay vì mở rộng vô hạn. Đó là mục đích của bulkhead, giới hạn blast radius, chấp nhận request tới dependency chậm bị reject để bảo vệ phần còn lại của hệ thống.


Fallback strategy, khi dependency không khả dụng

Circuit open, timeout, retry hết budget, caller nhận lỗi. Bước tiếp theo không nhất thiết là trả 503 cho user. Tuỳ business context, có nhiều cách degrade gracefully thay vì fail hoàn toàn.

Default value, trả giá trị mặc định hợp lý. Product recommendation service sập? Trả danh sách best-seller tĩnh thay vì recommendation cá nhân hoá. Không hoàn hảo nhưng user vẫn có gì đó để xem. Exchange rate service timeout? Dùng rate gần nhất đã cache, có thể lệch vài phút nhưng tốt hơn không hiển thị giá.

Cached result, trả kết quả cache từ lần gọi thành công gần nhất. Rất hiệu quả cho data ít thay đổi, profile user, danh mục sản phẩm, cấu hình hệ thống. Cache cần có TTL hợp lý và chấp nhận serve stale data khi downstream unavailable. Pattern “stale-while-error” hoặc “stale-if-error” trong cache layer phục vụ đúng mục đích này.

Degraded feature, tắt feature phụ, giữ flow chính. Checkout page không load được shipping estimate? Vẫn cho phép checkout, hiển thị “phí ship sẽ được tính sau” thay vì block toàn bộ checkout flow. Search service sập? Hiển thị danh mục tĩnh thay vì search results. User experience kém hơn nhưng business flow không gián đoạn.

Error với message rõ ràng, khi không có fallback hợp lý, trả lỗi với message giúp user hiểu và biết phải làm gì. “Payment service đang tạm thời gián đoạn. Vui lòng thử lại sau 5 phút” tốt hơn “Internal Server Error” hay “Something went wrong”. Nếu có ETA phục hồi (từ SLO hoặc incident status page), đưa vào message.

Chọn fallback strategy là quyết định product, không chỉ kỹ thuật. Cần discuss với PM: “khi payment gateway sập, checkout có nên bị block hoàn toàn hay cho phép tạo order pending rồi charge sau?” Câu trả lời khác nhau giữa platform bán hàng (có thể chấp nhận pending) và platform giao dịch tài chính (phải block).


Phối hợp các pattern, thứ tự và tương tác

Timeout, retry, circuit breaker, bulkhead không hoạt động độc lập, chúng phối hợp theo thứ tự cụ thể trong mỗi request. Hiểu thứ tự này quan trọng để tránh các pattern chống nhau.

Thứ tự áp dụng cho mỗi outgoing request thường là: Bulkhead → Circuit Breaker → Timeout → Retry (từ ngoài vào trong). Bulkhead gate request trước, nếu pool đã đầy, reject ngay, không cần check circuit. Circuit breaker check tiếp, nếu circuit open, fail-fast, không cần gọi downstream. Timeout wrap lời gọi thực tế, nếu downstream chậm quá, huỷ. Retry wrap timeout, nếu timeout hoặc lỗi transient, thử lại với backoff.

bulkhead {
    circuitBreaker {
        retry(maxAttempts=3, backoff=exponential+jitter) {
            timeout(500ms) {
                httpCall(downstream)
            }
        }
    }
}

Lưu ý tương tác: retry nằm bên trong circuit breaker. Mỗi retry fail đều được circuit breaker đếm. Nếu retry 3 lần đều fail, circuit breaker đếm 3 failure, có thể đủ để open circuit. Đây là hành vi mong muốn: retry failure liên tục là tín hiệu downstream đang unhealthy.

Timeout cũng cần phối hợp với retry: nếu timeout 500ms và retry 3 lần, worst case latency là 3 × 500ms = 1.5 giây (chưa tính backoff delay). Tổng thời gian này phải nằm trong timeout budget của caller. Nếu caller chỉ cho phép 2 giây, thì retry 3 lần với timeout 500ms + backoff delay có thể vượt budget. Cần tính toán: (retry_count × timeout) + tổng_backoff_delay ≤ caller_timeout_budget.


Health check và readiness, không route traffic đến instance bệnh

Resilience pattern ở application level chống failure từ downstream. Nhưng nếu chính instance đang chạy bị unhealthy (memory leak, disk full, connection pool exhausted), traffic vẫn được route đến nó, user vẫn gặp lỗi.

Liveness probe trả lời câu “process còn sống không?”, nếu fail, orchestrator (Kubernetes) restart container. Liveness check nên đơn giản: process có thể trả HTTP 200 là đủ. Không nên check dependency trong liveness, nếu database chậm mà liveness fail, Kubernetes restart container liên tục (CrashLoopBackOff) trong khi vấn đề nằm ở database.

Readiness probe trả lời câu “instance có sẵn sàng nhận traffic không?”, nếu fail, load balancer ngừng route traffic đến instance đó nhưng không restart. Readiness check nên bao gồm trạng thái critical dependency: database connection pool có healthy không, cache có reachable không. Instance đang warmup cache, chưa sẵn sàng xử lý request → readiness fail → không nhận traffic cho đến khi ready.

Kết hợp readiness probe với circuit breaker: khi circuit breaker của critical dependency open, readiness probe có thể trả fail, ngừng nhận traffic thay vì trả lỗi cho mọi request. Đây là quyết định tuỳ context: nếu service có fallback cho dependency đó, readiness vẫn pass. Nếu không có fallback, readiness fail là hợp lý.


Observability cho resilience

Pattern resilience không có observability đi kèm thì như airbag không có đèn cảnh báo, bạn không biết nó đã kích hoạt cho đến khi đã quá muộn. Mỗi pattern cần metric riêng để monitor và alert.

Circuit breaker metrics: trạng thái hiện tại (closed/open/half-open) per dependency, số lần chuyển trạng thái, failure rate trong sliding window. Alert khi circuit open, đây là tín hiệu downstream đang có vấn đề. Dashboard show circuit state cho mọi dependency giúp on-call nhìn nhanh “dependency nào đang unhealthy”.

Retry metrics: tổng số retry per dependency, retry rate (retry / total request), success rate sau retry. Retry rate tăng đột biến là early warning, downstream đang flaky trước khi sập hẳn. Nếu retry rate > 10% liên tục, cần investigate thay vì chỉ dựa vào retry tự phục hồi.

Timeout metrics: số request timeout per dependency, timeout rate, actual latency distribution. So sánh timeout rate với latency P99, nếu P99 gần sát timeout threshold, nhiều request đang “suýt” timeout, cần tăng timeout hoặc investigate tại sao downstream chậm.

Bulkhead metrics: số request bị reject vì pool đầy, pool utilization (connection đang dùng / pool size), queue depth nếu có queue. Pool utilization > 80% liên tục là tín hiệu cần tăng pool size hoặc downstream đang chậm.

Log mỗi sự kiện resilience với context: circuit open cho dependency nào, retry lần thứ mấy cho request nào (kèm trace_id), timeout sau bao lâu. Kết hợp với trace, mỗi retry là một span mới, circuit breaker rejection là span kết thúc ngay, giúp debug latency anomaly nhanh.


Library và framework

Không cần implement resilience pattern từ đầu, có library battle-tested cho hầu hết ngôn ngữ (thực tế là phần lớn team thêm resilience4j/Polly vào dependencies rồi để nguyên default config, và chỉ đọc doc kỹ sau incident đầu tiên). Nhưng dùng library không thay thế việc hiểu pattern, bạn vẫn cần chọn threshold, timeout, retry strategy phù hợp.

resilience4j (Java) là successor của Hystrix, thiết kế cho Java 8+ functional style. Hỗ trợ circuit breaker, retry, bulkhead (thread pool và semaphore), rate limiter, time limiter. Config qua code hoặc Spring Boot properties. Tích hợp Micrometer metrics sẵn.

Polly (.NET) là thư viện resilience phổ biến nhất cho .NET ecosystem. API fluent, composable, chain nhiều policy theo thứ tự. Polly v8 redesign lại hoàn toàn, đơn giản hơn v7. Hỗ trợ circuit breaker, retry, timeout, bulkhead, fallback, hedging.

cockatiel (Node.js/TypeScript), lightweight, TypeScript-native, hỗ trợ circuit breaker, retry, timeout, bulkhead. API clean, phù hợp cho Node.js async/await.

Go không có một library thống lĩnh. sony/gobreaker cho circuit breaker, cenkalti/backoff cho retry backoff, hashicorp/go-retryablehttp cho HTTP client có retry built-in. Nhiều team Go implement pattern đơn giản trực tiếp thay vì dùng library, Go khuyến khích code explicit hơn abstraction.

Dù dùng library nào, cần hiểu rõ config default và override cho phù hợp. resilience4j mặc định circuit breaker failure rate threshold 50% với sliding window 100 call, có thể không phù hợp cho service QPS thấp (cần 100 call mới đủ window). Polly mặc định retry không có backoff, cần thêm WaitAndRetry với jitter. Đọc doc kỹ, đừng dùng default mù quáng.


Anti-pattern phổ biến

Retry không có backoff. Request fail, retry ngay lập tức, fail, retry ngay, fail, downstream đang quá tải nhận thêm 3x traffic trong vài millisecond. Luôn có backoff, luôn có jitter.

Retry non-idempotent operation. Đã nói ở trên nhưng nhắc lại vì hậu quả nghiêm trọng: duplicate payment, duplicate order, duplicate email. Nếu operation không idempotent, không retry, hoặc thêm idempotency key.

Circuit breaker với threshold quá cao. Circuit breaker failure threshold 100 request trên service có QPS 5, cần 20 giây toàn fail mới open circuit. Trong 20 giây đó, mọi request đều timeout 10 giây, 20 × 5 = 100 request chờ 10 giây, resource cạn từ lâu. Threshold phải phù hợp với QPS: service QPS thấp cần failure count threshold thấp hoặc dùng failure rate với minimum request count nhỏ.

Timeout quá dài. “Set 60 giây cho chắc ăn”, khi downstream chậm, mỗi request giữ resource 60 giây. 50 concurrent request giữ hết 50 connection trong 60 giây. Timeout phải gần P99 latency bình thường, không phải “giá trị an toàn” vô nghĩa.

Bulkhead không có, dùng shared pool. Mọi outgoing call dùng chung một connection pool. Dependency A chậm ăn hết pool, dependency B và C lành mạnh cũng không gọi được. Đây chính là kịch bản mở đầu bài, và là anti-pattern phổ biến nhất vì nó là cấu hình mặc định của hầu hết HTTP client.

Không test failure mode. Mọi thứ config đẹp nhưng chưa bao giờ test circuit open hành xử thế nào, retry exhaustion trả gì cho user, bulkhead rejection message ra sao. Dùng chaos engineering ở mức đơn giản, inject latency vào downstream trong staging, verify rằng circuit mở đúng lúc, fallback hoạt động, metric ghi đúng. Không cần Chaos Monkey, chỉ cần một endpoint /debug/slow?delay=5000 trên staging của downstream là đủ test.

Không monitor resilience state. Circuit đã open 30 phút mà không ai biết vì không có alert. Retry rate 40% liên tục mà không ai investigate. Timeout rate 20% mà dashboard không ai nhìn. Resilience pattern tự phục hồi một phần, nhưng khi vấn đề kéo dài thì cần human intervention, và alert là cách duy nhất để human biết.


Cascading failure không chờ bạn ready, nó đến khi một dependency chậm và hệ thống không có cơ chế giải phóng resource sớm. Bulkhead, circuit breaker, retry với jitter, và timeout hợp lý không phải pattern xa xỉ, đó là những thứ phân biệt hệ thống chịu được sự cố với hệ thống biến sự cố thành outage lúc 2 giờ sáng.