Circuit Breaker, Bulkhead, Load shedding, Fallback

4 câu hỏi~30 phútTừ khóa: fail fast, CLOSED/OPEN/HALF-OPEN, bulkhead thread-pool vs semaphore, load shedding, fallback, graceful degradation, critical vs non-critical

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 đề

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.

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

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

FallbackVí dụ trong shop-appTrade-off
Trả lỗi rõ ràngBank chết → 503 "Thanh toán tạm gián đoạn"An toàn, hợp với tiền bạc
Default valueRecommendation chết → []; shipping estimate → "chưa có"Mất tính năng phụ
CachePrice service chết → giá cache 5 phút trướcStale data; không dùng cho balance, tồn kho, payment status
Service khácPSP chính chết → PSP dự phòngPSP chính đã charge chưa? Nguy cơ double charge, cần idempotency + reconciliation
Chuyển sang asyncEmail chết → event nằm trong Kafka, gửi sauEventual; cần idempotent consumer
Thread-pool bulkheadSemaphore bulkhead
Chạy trênThread riêngThread của caller
Caller bị block?Không, có thể timeout riêngCó
Chi phíCao hơn (thread, context switch)Rất thấp
Hợp vớiBlocking client, cần cô lập mạnhVirtual thread, reactive, call nhanh

7. Khi nào nên dùng / không nên dùng

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 BreakerLoad shedding
Nằm ởPhía caller (B khi gọi C)Phía server (cửa vào của B)
Dấu hiệuDependency C lỗi/chậm nhiềuChí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ụcB khỏi traffic vượt sức
Ví dụBank chết → không gọi bank 30sFlash 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


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.