Bỏ qua để đến nội dung
Knowledge Hub

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

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

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.

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

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.

Breaker payment: mở khi lỗi vượt ngưỡng, thử lại có giới hạn, đóng khi provider hồi phục
Breaker payment: mở khi lỗi vượt ngưỡng, thử lại có giới hạn, đóng khi provider hồi phụcBước 1/6: CLOSED: mọi call đi qua, breaker ghi kết quả vào cửa sổ 10 call gần nhất.Checkout APIPOST /checkoutBreaker paymentwindow 10 callCLOSEDPayment provider:7010p99 200 mspay200 ms
Checkout API → Breaker paymentpayBreaker payment → Payment provider200 ms
Bước 1/6

CLOSED: 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ố 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
  • minimumNumberOfCalls chặ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: FailureRatio 0,1, MinimumThroughput 100, BreakDuration 5 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.

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

Fallback là việc của ứng dụng. Fowler nêu ba lựa chọn:

  • Lỗi nhanh: 503 kèm Retry-After (giây còn lại của OPEN), 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ở.

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.

Terminal window
npx --prefix examples ts-node examples/src/circuit-breaker-bulkhead/load.ts
CHECKOUT_RPS=40 npx --prefix examples ts-node examples/src/circuit-breaker-bulkhead/load.ts timeout

Kị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)
  1. Không timeout: provider hồi phục ở giây 3,5 nhưng /health vẫn timeout 10/10 vì worker còn kẹt với call cũ.
  2. Chỉ timeout đủ khi req/s × timeout < pool (20 × 0,5 = 10 < 16); ở 40 req/s thì 20 > 16 và /health chậm hơn 100 lần.
  3. 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.

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
  1. Timeout cho mọi outbound call theo p99/p99.9, trong deadline của request.
  2. Breaker và bulkhead riêng cho từng dependency, đặt tên theo dependency.
  3. Cỡ bulkhead = req/s × p99 latency cộng dư địa; kiểm chứng bằng load test.
  4. Ngưỡng call chậm theo SLO của dependency, không để mặc định 60 s.
  5. minimumNumberOfCalls hợp với traffic thật để vài lỗi lẻ không mở breaker.
  6. Fallback cho từng endpoint: 503 + Retry-After, dữ liệu cũ, hoặc hàng đợi có idempotency key.
  7. Thứ tự retry → breaker → timeout từng attempt; không retry khi breaker từ chối.
  8. Metric trạng thái breaker, call bị từ chối, slot bulkhead; alert khi breaker mở.

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ặn

Nê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
  1. Vì sao ở mode none, /health vẫn timeout sau khi provider đã hồi phục?
  2. 20 req/s, timeout 500 ms, pool 16 worker: có cần bulkhead không? Còn 40 req/s?
  3. minimumNumberOfCalls = 100 mà dependency chỉ nhận 30 call/phút: breaker làm gì trong phút đầu sự cố?
  4. Vì sao Resilience4j đặt Retry ngoài CircuitBreaker?