Circuit breaker và bulkhead: provider chậm không được kéo sập cả API Trung cấp
Thời gian đọc ước tính: 8 phút
Vấn đề
Phần tiêu đề “Vấn đề”API bán hàng có hai endpoint: POST /checkout gọi payment provider, GET /health không gọi gì. Mỗi request mượn một trong 16 worker (như thread pool của Tomcat). Provider bình thường trả lời trong 200 ms ở p99.
Một chiều, p99 của provider lên 10 s. Mỗi checkout giữ một worker trong lúc chờ; ở 20 req/s, cả 16 worker bị chiếm trong chưa tới một giây.
/health xếp hàng sau chúng, probe thất bại, pod bị coi là hỏng. Một dependency chậm làm sập cả API: cascading failure.
Câu hỏi: làm sao để lỗi của provider chỉ ảnh hưởng checkout, và checkout báo lỗi nhanh thay vì chờ?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Mọi call ra ngoài | Timeout | Luôn có; theo p99/p99.9 đo được và deadline còn lại |
| Lỗi thoáng qua vài trăm ms | Retry có jitter | Lỗi retry được, thao tác idempotent (retry, jitter) |
| Một dependency có thể chiếm hết worker hoặc connection pool chung | Bulkhead | Giới hạn concurrency riêng mỗi dependency |
| Dependency chậm hoặc lỗi kéo dài | Circuit breaker | Có fallback; đủ traffic để tính tỉ lệ lỗi |
Bốn cơ chế bổ sung nhau, không thay nhau.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”- Bulkhead: giới hạn số call đồng thời tới một dependency, hết chỗ thì từ chối.
- Circuit breaker: đo tỉ lệ lỗi, khi vượt ngưỡng thì fail fast, không gửi request.
- Fallback: phản hồi thay thế khi call bị từ chối.
Hai pattern do Nygard phổ biến trong Release It!; bulkhead lấy tên từ vách ngăn khoang tàu.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Breaker là máy trạng thái bọc quanh lời gọi tới một dependency. Hình dưới theo breaker payment trong demo.
payBreaker payment → Payment provider200 msCLOSED: mọi call đi qua, breaker ghi kết quả vào cửa sổ 10 call gần nhất.
| Trạng thái | Call tới provider | Rời trạng thái khi |
|---|---|---|
CLOSED |
Đi qua, kết quả vào cửa sổ trượt | Đủ minimumNumberOfCalls và tỉ lệ lỗi hoặc tỉ lệ call chậm ≥ ngưỡng → OPEN |
OPEN |
Bị từ chối ngay (CallNotPermittedException) |
Hết waitDurationInOpenState → HALF_OPEN |
HALF_OPEN |
Chỉ vài call thử | Call thử dưới ngưỡng → CLOSED, ngược lại → OPEN |
Call chậm cũng được đếm: provider vẫn trả 200, chỉ là sau 10 s.
Tham số và mặc định
Phần tiêu đề “Tham số và mặc định”| Tham số Resilience4j 2.x | Mặc định | Demo |
|---|---|---|
slidingWindowType / slidingWindowSize |
COUNT_BASED / 100 |
count, 10 call |
minimumNumberOfCalls |
100 | 10 |
failureRateThreshold |
50% | 50% |
slowCallRateThreshold / slowCallDurationThreshold |
100% / 60 000 ms | 50% / 400 ms |
waitDurationInOpenState |
60 000 ms | 1 000 ms |
permittedNumberOfCallsInHalfOpenState |
10 | 3 |
minimumNumberOfCallschặn breaker mở vì vài lỗi đầu: với giá trị 10, chín call lỗi cả chín vẫn chưa mở.- Ngưỡng chậm mặc định 60 s gần như không bao giờ chạm; phải tự đặt theo SLO của provider.
- Polly v8:
FailureRatio0,1,MinimumThroughput100,BreakDuration5 s.
Bulkhead: semaphore và hàng đợi có giới hạn
Phần tiêu đề “Bulkhead: semaphore và hàng đợi có giới hạn”Bulkhead đếm số call đang chạy tới một dependency. Còn chỗ thì cho đi; hết chỗ thì chờ trong hàng đợi có giới hạn, quá hạn chờ thì từ chối.
Đang tải diagram…
Payment giữ tối đa 4 worker, 12 còn lại dành cho /health. Resilience4j có SemaphoreBulkhead (maxConcurrentCalls mặc định 25, maxWaitDuration 0) và FixedThreadPoolBulkhead (queueCapacity 100).
Cỡ bulkhead theo định luật Little: số call đồng thời ≈ tốc độ × độ trễ. 20 req/s × 0,2 s = 4 slot. Ở 40 req/s, 4 slot thiếu ngay cả khi provider khỏe: demo có 4/18 checkout bị 503 trong pha ổn định.
Thứ tự với retry và timeout
Phần tiêu đề “Thứ tự với retry và timeout”| Nguồn | Thứ tự (ngoài → trong) |
|---|---|
| Resilience4j, Spring Boot aspect | Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(fn))))) |
| .NET standard resilience handler (Polly v8) | Rate limiter → total timeout → retry → circuit breaker → attempt timeout |
Cả hai đặt retry ngoài, breaker giữa, timeout từng attempt trong cùng. Breaker thấy từng attempt nên tỉ lệ lỗi đúng; khi mở, attempt fail trong vài ms.
Đừng retry ngay lỗi “breaker đang mở”: Polly gợi ý đưa BreakDuration vào context để retry chờ đúng chừng đó.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”Fallback là việc của ứng dụng. Fowler nêu ba lựa chọn:
- Lỗi nhanh:
503kèmRetry-After(giây còn lại củaOPEN), body theo Problem Details. - Dữ liệu cũ từ cache, ghi rõ thời điểm.
- Xử lý sau: lưu đơn
pending_payment, worker thu tiền khi provider hồi phục, dùng idempotency key.
Phạm vi: mỗi dependency (hoặc mỗi endpoint của nó) một breaker và một bulkhead.
Lỗi nào đếm: Resilience4j mặc định tính mọi exception là lỗi; dùng ignoreExceptions cho 4xx do input sai. Demo bỏ qua lỗi bulkhead đầy vì đó là giới hạn của chính caller.
Quan sát: Fowler yêu cầu log mọi lần đổi trạng thái. Cần gauge trạng thái theo tên breaker, số call bị từ chối, slot bulkhead đang dùng; alert khi breaker mở.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo examples/src/circuit-breaker-bulkhead/ tự viết breaker và semaphore, provider ở port 7010, API (16 worker, ba mode) ở port 7011, tải bằng fetch song song.
npx --prefix examples ts-node examples/src/circuit-breaker-bulkhead/load.tsCHECKOUT_RPS=40 npx --prefix examples ts-node examples/src/circuit-breaker-bulkhead/load.ts timeoutKịch bản 6 s: checkout 20 req/s (client bỏ cuộc sau 3 s), /health mỗi 250 ms (bỏ cuộc sau 2 s); provider chậm 10 s từ giây 0,5 đến 3,5. Số đo thật trên Node 24.17, pha provider chậm:
| Mode | /health |
/checkout |
Provider in-flight max |
|---|---|---|---|
none (không timeout) |
8/12 timeout, p50 2 001 ms | 58/58 timeout | 16 |
timeout 500 ms |
p50 2 ms | 58/58 504 sau ~505 ms |
63 |
timeout 500 ms, 40 req/s |
p50 259 ms, p95 503 ms | 96/115 504 |
112 |
protected |
p50 2 ms, p95 5 ms | p50 3 ms; 43 503, 15 504 |
19 |
t=1.52s CLOSED -> OPEN (failure rate 50% trên 10 call)t=2.53s OPEN -> HALF_OPEN (chờ đủ 1000 ms)t=3.13s HALF_OPEN -> OPEN (half-open: failure rate 100% trên 3 call)t=4.18s OPEN -> HALF_OPEN (chờ đủ 1000 ms)t=4.49s HALF_OPEN -> CLOSED (half-open: 3/3 call thử đạt)- Không timeout: provider hồi phục ở giây 3,5 nhưng
/healthvẫn timeout 10/10 vì worker còn kẹt với call cũ. - Chỉ timeout đủ khi
req/s × timeout< pool (20 × 0,5 = 10 < 16); ở 40 req/s thì 20 > 16 và/healthchậm hơn 100 lần. - Timeout chỉ hủy phía caller; provider vẫn chạy request bị bỏ (in-flight 63, 112). Breaker mới giảm tải thật (19).
Số thay đổi mỗi lần chạy; load.ts loại kết quả nếu event loop đứng quá 500 ms. Demo thiếu cửa sổ theo thời gian, metric, retry và hàng đợi fallback.
Trade-offs
Phần tiêu đề “Trade-offs”Lợi ích
- Lỗi một dependency không lan sang endpoint khác
- Fail fast giải phóng worker
- Dependency đang hỏng không bị dội thêm tải
Chi phí và giới hạn
- Hành vi theo chế độ khó test; Brooker (AWS) lưu ý breaker có thể kéo dài thời gian hồi phục
- Tham số sai: mở vì traffic thấp, hoặc không bao giờ mở
- Bulkhead quá nhỏ từ chối cả khi dependency khỏe
Checklist production
Phần tiêu đề “Checklist production”- Timeout cho mọi outbound call theo p99/p99.9, trong deadline của request.
- Breaker và bulkhead riêng cho từng dependency, đặt tên theo dependency.
- Cỡ bulkhead =
req/s × p99 latencycộng dư địa; kiểm chứng bằng load test. - Ngưỡng call chậm theo SLO của dependency, không để mặc định 60 s.
minimumNumberOfCallshợp với traffic thật để vài lỗi lẻ không mở breaker.- Fallback cho từng endpoint:
503+Retry-After, dữ liệu cũ, hoặc hàng đợi có idempotency key. - Thứ tự retry → breaker → timeout từng attempt; không retry khi breaker từ chối.
- Metric trạng thái breaker, call bị từ chối, slot bulkhead; alert khi breaker mở.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi phổ biến nhất:
Một breaker cho mọi dependency
Hay gặp
const breaker = new CircuitBreaker('outbound', cfg, log);await breaker.exec(() => payment.charge(order));await breaker.exec(() => email.send(order)); // email lỗi -> checkout bị chặnNên viết
await paymentBreaker.exec(() => paymentBulkhead.run(() => payment.charge(order)));await emailBreaker.exec(() => email.send(order));Vì sao
Breaker đo sức khỏe một dependency. Gộp chung thì lỗi bên này chặn bên kia.
Sai lầm thứ hai:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Timeout đủ để cô lập lỗi | Ở 40 req/s, /health p50 259 ms |
Thêm bulkhead khi req/s × timeout gần cỡ pool |
| Chỉ đếm exception | Provider trả 200 sau 10 s, breaker không mở | Bật ngưỡng call chậm |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao ở mode
none,/healthvẫn timeout sau khi provider đã hồi phục? - 20 req/s, timeout 500 ms, pool 16 worker: có cần bulkhead không? Còn 40 req/s?
minimumNumberOfCalls = 100mà dependency chỉ nhận 30 call/phút: breaker làm gì trong phút đầu sự cố?- Vì sao Resilience4j đặt
RetryngoàiCircuitBreaker?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Release It! — Nygard: nguồn gốc hai pattern.
- CircuitBreaker — Fowler: half-open, giám sát, fallback.
- Resilience4j CircuitBreaker, Bulkhead, thứ tự aspect: tham số, mặc định.
- Microsoft Learn — HTTP resilience, Polly: thứ tự, mặc định Polly v8.
- AWS — Timeouts, retries, backoff: chọn timeout, nhược điểm breaker.