Circuit Breaker, Bulkhead, Load shedding, Fallback
Ok làm đi bạn: C chết hẳn 30 phút thì B có nên cứ retry mãi không?
Không. Đây là lúc cần Circuit Breaker. Bài này ở mức pattern; cách cấu hình bằng Resilience4j trong Spring xem Bài 4.6.6.
1. Bài toán
payment-service (B) đã có timeout, retry, backoff, jitter. C (bank/PSP) chập chờn vài giây thì retry có ích. Nhưng C DOWN 30 phút:
Request 1: B → C → timeout → retry → timeout → retry → FAILED (9s)
Request 2: B → C → timeout → retry → timeout → retry → FAILED (9s)
Request 3: ...
1000 req/s × 3 attempt = 3000 call/s đập vào một C đang chết
2. Vấn đề
- Đã biết C chết rồi, tại sao request thứ 1001 B vẫn phải thử?
- Mỗi request giữ thread 9s → B hết thread → A hết thread → cascading failure.
- C muốn hồi phục mà bị dội liên tục, khó đứng dậy.
- Thêm nữa: B còn gọi inventory, notification. C chậm ăn hết thread chung thì cả chức năng không liên quan C cũng chết.
3. Giải pháp
Circuit Breaker: giống cầu dao điện, nằm giữa B và C, quan sát kết quả các call.
- CLOSED: "C bình thường, cứ gọi." Request đi qua, đếm tỷ lệ lỗi/slow call.
- OPEN: "C đang lỗi, đừng gọi." Chặn trước khi gửi network call, fail ngay (fail fast).
- HALF-OPEN: "Thử vài request xem C sống lại chưa." Thành công → CLOSED, lỗi → OPEN tiếp.
Circuit Breaker không làm C sống lại. Nó giữ cho C chết thì không kéo B chết theo, và B không kéo A chết theo.
Bulkhead (vách ngăn tàu thủy): mỗi dependency một "khoang" tài nguyên riêng. Khoang C ngập thì khoang inventory vẫn khô.
- Thread-pool bulkhead: pool riêng cho call tới C (ví dụ 20 thread + queue nhỏ). Caller không bị block, có thể timeout độc lập. Tốn thread, đổi context.
- Semaphore bulkhead: chạy trên thread của caller, chỉ giới hạn số call đồng thời (ví dụ 20 permit). Nhẹ, nhưng caller vẫn bị block trong lúc call chạy. Hợp với virtual thread / reactive.
Load shedding: khi chính B quá tải, chủ động từ chối sớm (503 + Retry-After) phần request vượt sức, thay vì nhận hết rồi chậm cho tất cả. Ưu tiên giữ request quan trọng (checkout) và bỏ request phụ (recommendation, analytics).
Fallback: circuit OPEN rồi thì B trả gì cho A? Circuit Breaker chỉ nói "đừng gọi C", nó không quyết định nghiệp vụ. Đó là việc của fallback.
4. Code từng bước
Bước 1: Dependency: không cần thư viện để hiểu cơ chế. Production dùng Resilience4j.
Bước 2: Config
bank:
breaker:
window-size: 10 # xét 10 call gần nhất
failure-threshold: 0.5 # ≥ 50% lỗi → OPEN
open-duration: 30s
bulkhead:
max-concurrent: 20
Bước 3: Code Java: circuit breaker tối giản + semaphore bulkhead.
public class SimpleCircuitBreaker {
enum State { CLOSED, OPEN, HALF_OPEN }
private State state = State.CLOSED;
private final Deque<Boolean> window = new ArrayDeque<>();
private final int windowSize;
private final double threshold;
private final long openNanos;
private long openedAt;
public SimpleCircuitBreaker(int windowSize, double threshold, Duration open) {
this.windowSize = windowSize;
this.threshold = threshold;
this.openNanos = open.toNanos();
}
public synchronized boolean allow() {
if (state == State.OPEN && System.nanoTime() - openedAt >= openNanos) {
state = State.HALF_OPEN; // hết thời gian chờ → thử
}
return state != State.OPEN; // (bản thật giới hạn số probe ở HALF_OPEN)
}
public synchronized void record(boolean success) {
if (state == State.HALF_OPEN) {
if (success) { state = State.CLOSED; window.clear(); } else { trip(); }
return;
}
window.addLast(success);
if (window.size() > windowSize) window.removeFirst();
long failures = window.stream().filter(ok -> !ok).count();
if (window.size() == windowSize && failures >= threshold * windowSize) trip();
}
private void trip() { state = State.OPEN; openedAt = System.nanoTime(); }
}
@Service
public class BankGateway {
private final BankCaller caller; // timeout + retry ở bài 13.2
private final SimpleCircuitBreaker breaker =
new SimpleCircuitBreaker(10, 0.5, Duration.ofSeconds(30));
private final Semaphore bulkhead = new Semaphore(20); // tối đa 20 call tới C cùng lúc
public BankGateway(BankCaller caller) { this.caller = caller; }
public ChargeOutcome charge(String paymentId, BigDecimal amount, Instant deadline) {
if (!breaker.allow()) {
return ChargeOutcome.unavailable("Bank circuit open"); // fail fast, 0 network
}
if (!bulkhead.tryAcquire()) {
return ChargeOutcome.unavailable("Bank bulkhead full"); // không chờ, từ chối luôn
}
try {
BankResult r = caller.charge(paymentId, amount, deadline);
breaker.record(true);
return ChargeOutcome.of(r);
} catch (RuntimeException e) {
breaker.record(false);
return ChargeOutcome.unknown(paymentId); // timeout ≠ failed
} finally {
bulkhead.release();
}
}
}
Bước 4: Test bằng curl (tắt stub của C):
for i in $(seq 1 12); do
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" -X POST localhost:8082/payments \
-H 'Content-Type: application/json' -d "{\"orderId\":\"ORD-$i\",\"amount\":100}"
done
# 10 call đầu: 503 ~1–3s (đang thử C)
# call 11, 12: 503 ~0.005s ← circuit OPEN, fail fast
5. Cách hoạt động
failure threshold reached
┌──────────┐ ─────────────────────────────► ┌──────────┐
│ CLOSED │ │ OPEN │
└──────────┘ ◄──────┐ └────┬─────┘
success│ │ wait 30s
┌────┴──────┐ │
│ HALF-OPEN │ ◄───────────────────┘
└────┬──────┘
│ failure
└──────────────► OPEN
Không Circuit Breaker Có Circuit Breaker
1000 req → B → C 💀 1000 req → B → [OPEN] X C 💀
mọi request chờ timeout fail ngay, thread trả về
B hết thread → A hết thread B còn tài nguyên cho việc không cần C
Bulkhead trong payment-service:
┌────────── Tomcat 200 threads ───────────┐
│ [bank: 20 permit ██████████ FULL] │ → request thứ 21 tới bank: từ chối ngay
│ [inventory: 30 permit ███ ] │ → vẫn chạy bình thường
│ [notification: 10 permit █ ] │
└─────────────────────────────────────────┘
6. So sánh
| Fallback | Ví dụ trong shop-app | Trade-off |
|---|---|---|
| Trả lỗi rõ ràng | Bank chết → 503 "Thanh toán tạm gián đoạn" | An toàn, hợp với tiền bạc |
| Default value | Recommendation chết → []; shipping estimate → "chưa có" | Mất tính năng phụ |
| Cache | Price service chết → giá cache 5 phút trước | Stale data; không dùng cho balance, tồn kho, payment status |
| Service khác | PSP chính chết → PSP dự phòng | PSP chính đã charge chưa? Nguy cơ double charge, cần idempotency + reconciliation |
| Chuyển sang async | Email chết → event nằm trong Kafka, gửi sau | Eventual; cần idempotent consumer |
| Thread-pool bulkhead | Semaphore bulkhead | |
|---|---|---|
| Chạy trên | Thread riêng | Thread của caller |
| Caller bị block? | Không, có thể timeout riêng | Có |
| Chi phí | Cao hơn (thread, context switch) | Rất thấp |
| Hợp với | Blocking client, cần cô lập mạnh | Virtual thread, reactive, call nhanh |
7. Khi nào nên dùng / không nên dùng
- Nên có circuit breaker + bulkhead cho mọi dependency qua mạng, nhất là bên thứ ba.
- Nên phân loại critical (payment, inventory) và non-critical (email, analytics, recommendation). Non-critical chuyển sang async hoặc có fallback.
- Không nên dùng fallback "giả vờ thành công" với thao tác tiền bạc. Đôi khi fail rõ ràng và an toàn tốt hơn tự chế ra kết quả.
- Không nên coi circuit breaker thay timeout: call chậm mà trả 200 vẫn tính là success nếu không đếm slow call.
Vậy checkout cần payment, inventory, email, analytics, recommendation. Mình gọi lần lượt hết rồi mới trả SUCCESS thì sai ở đâu?
Đúng là sai ở chỗ coi mọi bước đều quan trọng như nhau. Junior hay thiết kế:
Checkout → Payment → Inventory → Email → Analytics → Recommendation → SUCCESS
Analytics ❌ ⇒ CHECKOUT FAILED 💀
Câu hỏi System Design: cái nào critical, cái nào non-critical?
Critical: Payment, Inventory → đồng bộ, fail thì checkout fail
Non-critical: Email, Analytics, Reco → không chặn response
Checkout
┌───────┴───────┐
▼ ▼
Payment Inventory
└───────┬───────┘
SUCCESS ──► trả cho user
│
▼
Kafka (ORDER_CREATED)
┌──────┼──────┐
▼ ▼ ▼
Email Analytics Other
Email chết thì message vẫn nằm trong Kafka, service sống lại thì consume tiếp. Đây là graceful degradation: một dependency chết thì một số tính năng giảm, core vẫn chạy.
Nhưng async sinh vấn đề mới: consumer gửi email xong rồi crash trước khi commit offset → chạy lại → user nhận 2 email. Lại cần idempotent consumer. Và critical path vẫn còn bài toán khó: tiền đã trừ mà inventory fail thì làm sao? Đó là Saga, ở bài sau.
Circuit Breaker OPEN thì B trả lỗi ngay. Vậy so với load shedding khác gì nhau, cả hai đều là từ chối request mà?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: hai cái bảo vệ hai phía khác nhau.
| Circuit Breaker | Load shedding | |
|---|---|---|
| Nằm ở | Phía caller (B khi gọi C) | Phía server (cửa vào của B) |
| Dấu hiệu | Dependency C lỗi/chậm nhiều | Chính B quá tải (queue dài, CPU cao, latency vượt ngưỡng) |
| Bảo vệ | B khỏi C, và cho C thời gian hồi phục | B khỏi traffic vượt sức |
| Ví dụ | Bank chết → không gọi bank 30s | Flash sale → nhận 2000 req/s, phần dư trả 503 ngay |
load shedding circuit breaker
Client ───────►│ B (server side) │───────►│ B → C (client side) │──► C
Ý chung: từ chối nhanh một phần tốt hơn nhận tất cả rồi chậm cho tất cả. Rate limiting là anh em gần: giới hạn theo client/API key, còn load shedding dựa trên sức chịu hiện tại của server.
Phỏng vấn hỏi về circuit breaker, bulkhead và fallback ở mức thiết kế hệ thống thì trả lời sao?
Q1. Vì sao "fail fast" lại tốt cho hệ thống?
🇻🇳 Tiếng Việt:
Khi dependency đã chết, chờ timeout và retry chỉ giữ thread, làm caller cạn tài nguyên và lan lỗi lên trên. Fail fast trả lỗi ngay, giải phóng tài nguyên cho các chức năng không phụ thuộc dependency đó, và giảm tải để dependency có thời gian hồi phục. Mục tiêu không phải sửa C mà là không để C kéo B và A chết theo.
🇬🇧 English:
When a dependency is already down, waiting for timeouts and retrying only holds threads, exhausts the caller's resources and spreads the failure upstream. Failing fast returns an error immediately, frees resources for features that don't depend on it, and reduces load so the dependency can recover. The goal is not to fix C but to stop C from taking B and A down with it.
Q2. Bulkhead là gì? Thread-pool và semaphore khác nhau thế nào?
🇻🇳 Tiếng Việt:
Bulkhead cô lập tài nguyên theo từng dependency để một dependency chậm không chiếm hết tài nguyên chung. Thread-pool bulkhead chạy call trên pool riêng, caller không bị block và có thể timeout độc lập nhưng tốn thread. Semaphore bulkhead chỉ giới hạn số call đồng thời trên thread của caller, nhẹ hơn nhưng caller vẫn bị block trong lúc chờ.
🇬🇧 English:
A bulkhead isolates resources per dependency so one slow dependency cannot consume the shared pool. A thread-pool bulkhead runs calls on a dedicated pool, so the caller isn't blocked and can time out independently, at the cost of extra threads. A semaphore bulkhead just limits concurrent calls on the caller's thread; it's cheaper, but the caller still blocks during the call.
Q3. Graceful degradation là gì? Cho ví dụ fallback.
🇻🇳 Tiếng Việt:
Graceful degradation là giữ chức năng cốt lõi hoạt động khi một số dependency hoặc tính năng phụ hỏng. Ví dụ recommendation chết thì trả danh sách rỗng, price service chết thì dùng giá cache, email chết thì đẩy event vào queue gửi sau. Riêng thanh toán thì thường trả lỗi rõ ràng hoặc trạng thái pending, không tự chế kết quả thành công.
🇬🇧 English:
Graceful degradation means keeping the core functionality working when some dependencies or non-critical features fail. For example, return an empty list if recommendations are down, serve a cached price if the price service fails, or queue emails to send later. For payments we usually return a clear error or a pending status rather than inventing a success.
Cách nhớ ngắn gọn
- CLOSED "cứ gọi" → OPEN "đừng gọi" → HALF-OPEN "thử vài cái".
- Circuit breaker bảo vệ caller khỏi dependency; load shedding bảo vệ server khỏi traffic.
- Bulkhead = khoang riêng; thread-pool cô lập mạnh, semaphore nhẹ.
- Critical đồng bộ, non-critical async; fallback tiền bạc = lỗi rõ ràng hoặc pending.
Tự kiểm tra
Vì sao cần trạng thái HALF-OPEN?
Nếu OPEN mãi thì không bao giờ biết C đã hồi phục. HALF-OPEN cho vài request thử để quyết định CLOSED hay OPEN lại.
Circuit Breaker có làm C hồi phục không?
Không trực tiếp. Nó ngăn C kéo B/A chết theo, và giảm tải để C có cơ hội hồi phục.
Fallback nào nguy hiểm với thanh toán, vì sao?
Trả "thành công" giả, hoặc chuyển sang PSP dự phòng khi chưa biết PSP chính đã charge chưa: có thể double charge.
Bulkhead giúp gì khi bank chậm mà inventory vẫn khỏe?
Chỉ khoang của bank bị đầy; call tới inventory vẫn còn tài nguyên riêng để chạy.
Load shedding khác circuit breaker ở đâu?
Load shedding ở phía server, từ chối khi chính nó quá tải; circuit breaker ở phía caller, ngừng gọi dependency đang lỗi.