# Circuit Breaker: chặn cascading failure trước khi nó kéo sập cả service

* * *

> Bài viết tham chiếu Resilience4j 2.2.0 và Spring Boot 3.x (spring-cloud-circuitbreaker-resilience4j). Tên property và hành vi sliding window kiểm chứng trên tài liệu official Resilience4j; thứ tự aspect mặc định phụ thuộc version, bài có ghi rõ chỗ liên quan.

## TL;DR

Circuit breaker theo dõi tỷ lệ lỗi và tỷ lệ slow call của một dependency trên một sliding window. Khi tỷ lệ vượt ngưỡng, nó chuyển sang trạng thái *open* và ngừng gọi dependency đó trong một khoảng thời gian, trả lỗi ngay thay vì để caller treo chờ. Mục đích kép: caller không tốn tài nguyên vào một dependency đang hỏng, và dependency có thời gian hồi phục. Ba sai lầm giết chết giá trị của circuit breaker: đếm lỗi theo số tuyệt đối thay vì tỷ lệ, không tính slow call, và không có fallback nên breaker mở rồi người dùng vẫn nhận `500`.

## Khi timeout và retry không đủ để cứu payment-service

`payment-service` nhận request thanh toán, mỗi request gọi sang một PSP (Payment Service Provider) bên ngoài để authorize thẻ. PSP nằm ngoài tầm kiểm soát: nó có thể chậm, có thể chết, và khi nó xuống cấp thì không ai báo trước.

Đội đã làm đúng phần cơ bản: đặt read timeout 3 giây cho lời gọi PSP, và retry 2 lần khi timeout. Bình thường PSP trả lời trong 200ms, mọi thứ ổn.

Rồi PSP gặp sự cố ở phía họ, thời gian trả lời nhảy lên đúng bằng ngưỡng timeout — 3 giây rồi timeout. Với retry 2 lần, mỗi request thanh toán giờ chiếm một thread trong 3 × 3 = 9 giây trước khi bỏ cuộc. `payment-service` chạy trên thread pool 200 thread. Ở 100 request thanh toán mỗi giây, chỉ cần chưa tới 2 giây là cả 200 thread đều đang treo chờ PSP.

Từ thời điểm đó, `payment-service` không xử lý được gì nữa — kể cả các endpoint không gọi PSP như truy vấn lịch sử giao dịch. Timeout đã chặn được việc *một thread* treo vô hạn, nhưng không chặn được việc *toàn bộ* thread cùng treo trong 9 giây. Retry còn làm mọi thứ tệ hơn: nó nhân số lời gọi tới một PSP đang ốm lên gấp ba, đúng lúc PSP cần được để yên để hồi phục.

Vấn đề cốt lõi: timeout và retry xử lý *từng lời gọi độc lập*. Chúng không có khái niệm "dependency này đang hỏng, đừng gọi nữa". Đó chính là khoảng trống mà circuit breaker lấp vào.

## Điều kiện làm vấn đề xuất hiện

Circuit breaker giải quyết một lớp vấn đề cụ thể, và chỉ có ý nghĩa khi các điều kiện sau đúng:

*   Lời gọi đi tới một **dependency có thể xuống cấp kéo dài** — không phải lỗi thoáng qua vài trăm ms, mà chết hoặc chậm trong nhiều giây tới nhiều phút.
    
*   Lời gọi **giữ tài nguyên trong lúc chờ** (thread, connection). Nếu không giữ tài nguyên thì việc dependency chậm ít nguy hiểm hơn nhiều.
    
*   Có **nhiều lời gọi** tới cùng dependency, đủ để tính được một tỷ lệ lỗi có ý nghĩa thống kê.
    

Nếu dependency gần như không bao giờ lỗi, hoặc mỗi phút chỉ có vài lời gọi, thì circuit breaker mang lại ít giá trị và có thể tạo lỗi giả — quay lại ở mục "Khi nào KHÔNG nên dùng".

## Cách làm không có circuit breaker và chỗ nó đau

Không có circuit breaker, cơ chế phòng thủ chỉ gồm timeout và retry:

```java
// Cách làm CHƯA đủ: chỉ timeout + retry, không có circuit breaker
ChargeResult charge(ChargeCommand cmd) {
    int attempts = 0;
    while (true) {
        try {
            // read timeout 3s đã cấu hình trong RestClient
            return pspRestClient.post()
                    .uri("/charges")
                    .body(cmd.toRequest())
                    .retrieve()
                    .body(ChargeResult.class);
        } catch (ResourceAccessException e) {   // timeout / lỗi mạng
            if (++attempts >= 3) throw e;
            // vẫn thử lại ngay cả khi PSP đã hỏng rõ ràng
        }
    }
}
```

Đoạn này đau ở ba chỗ khi tải cao và PSP xuống cấp:

1.  **Không có bộ nhớ về trạng thái dependency.** Request thứ 10.000 vẫn thử gọi PSP y như request đầu tiên, dù 9.999 request trước đều vừa timeout. Mỗi thread vẫn phải chờ hết 9 giây để tự phát hiện điều mà hệ thống đã biết từ lâu.
    
2.  **Retry khuếch đại tải lên dependency đang ốm.** Đúng lúc PSP cần giảm tải để hồi phục, `payment-service` lại đập vào nó gấp ba.
    
3.  **Tài nguyên của caller bị giữ hết.** Như phép tính ở trên, thread pool cạn trong vài giây, kéo theo cả các chức năng không liên quan.
    

## Circuit breaker giải quyết thế nào

Circuit breaker đặt một lớp trung gian giữa caller và dependency, lớp này *nhớ* kết quả các lời gọi gần đây. Khi tỷ lệ lỗi (hoặc tỷ lệ slow call) trên một cửa sổ gần đây vượt ngưỡng, nó **mở mạch**: các lời gọi tiếp theo bị từ chối ngay lập tức bằng một exception (`CallNotPermittedException`), không chạm tới dependency. Sau một khoảng chờ, nó cho một số ít lời gọi thử qua để kiểm tra dependency đã hồi phục chưa.

Điểm mấu chốt: khi mạch mở, mỗi lời gọi bị từ chối tốn gần như 0 tài nguyên và trả về trong micro giây. Thread không bị treo, tải lên dependency về 0, và caller có cơ hội trả một phản hồi giảm chức năng (fallback) thay vì để người dùng chờ 9 giây rồi nhận `500`.

## Ba trạng thái của circuit breaker

Circuit breaker là một máy trạng thái ba trạng thái:

![](https://cdn.hashnode.com/uploads/covers/66ba2c55f1ac6be9964fe79e/f12c2127-4f3e-47d0-9d75-86b0f10f2c50.png align="center")

*Hình 1 — Máy trạng thái của circuit breaker. Chuyển từ Closed sang Open khi tỷ lệ lỗi vượt ngưỡng; từ Open sang HalfOpen sau thời gian chờ; HalfOpen quyết định đóng lại hay mở tiếp dựa trên kết quả các call thử.*

*   **Closed** — trạng thái bình thường. Mọi lời gọi đi qua tới dependency. Breaker ghi lại kết quả (thành công / thất bại / slow) vào sliding window. Khi *và chỉ khi* số lời gọi trong window đạt `minimum-number-of-calls`, breaker mới bắt đầu đánh giá tỷ lệ. Nếu tỷ lệ lỗi hoặc tỷ lệ slow call vượt ngưỡng, nó chuyển sang Open.
    
*   **Open** — mạch hở. Mọi lời gọi bị từ chối ngay bằng `CallNotPermittedException`, không chạm dependency. Breaker ở đây trong `wait-duration-in-open-state`, sau đó chuyển sang HalfOpen.
    
*   **Half-Open** — trạng thái thăm dò. Breaker cho `permitted-number-of-calls-in-half-open-state` lời gọi đi qua. Nếu tỷ lệ lỗi của nhóm thử này dưới ngưỡng, breaker đóng lại (Closed). Nếu không, nó mở lại (Open) và chờ tiếp.
    

`minimum-number-of-calls` là tham số hay bị bỏ qua nhưng quan trọng: nó ngăn breaker mở dựa trên một mẫu quá nhỏ. Không có nó, 1 lỗi trên 1 lời gọi đã là tỷ lệ lỗi 100% và breaker mở oan.

## Cấu hình và code với Resilience4j

**Context:** `payment-service` gọi PSP bên ngoài. Cần một circuit breaker tính cả lỗi *và* slow call, có fallback khi mạch mở, và bọc ngoài một retry chỉ dành cho lỗi transient.

Cấu hình bằng `application.yml`:

```yaml
# Resilience4j 2.2.0 + Spring Boot 3.x
resilience4j:
  circuitbreaker:
    instances:
      psp:
        sliding-window-type: TIME_BASED        # đánh giá theo cửa sổ thời gian
        sliding-window-size: 60                 # 60 giây gần nhất
        minimum-number-of-calls: 20             # chưa đủ 20 call thì không đánh giá tỷ lệ
        failure-rate-threshold: 50              # >= 50% lỗi -> mở
        slow-call-rate-threshold: 80            # >= 80% slow -> mở
        slow-call-duration-threshold: 2s        # call > 2s bị coi là "slow"
        wait-duration-in-open-state: 10s        # ở Open 10s rồi sang Half-Open
        permitted-number-of-calls-in-half-open-state: 5
        # KHÔNG coi lỗi nghiệp vụ 4xx là "failure" của breaker:
        ignore-exceptions:
          - com.example.payment.psp.CardDeclinedException
  retry:
    instances:
      psp:
        max-attempts: 3
        wait-duration: 200ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        # chỉ retry lỗi transient, KHÔNG retry CardDeclinedException
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException
```

Điểm cần chú ý trong cấu hình: `slow-call-duration-threshold: 2s` thấp hơn read timeout 3s. Đây là chủ ý — ta muốn breaker phản ứng với call *chậm* (2-3s) trước khi nó kịp timeout, vì PSP chậm nguy hiểm cho tài nguyên caller không kém gì PSP chết.

Code client, tự compose để kiểm soát thứ tự decorator:

```java
@Component
class PspClient {

    private static final Logger log = LoggerFactory.getLogger(PspClient.class);

    private final RestClient restClient;      // đã cấu hình connect + read timeout
    private final CircuitBreaker circuitBreaker;
    private final Retry retry;

    PspClient(RestClient pspRestClient,
              CircuitBreakerRegistry cbRegistry,
              RetryRegistry retryRegistry) {
        this.restClient = pspRestClient;
        this.circuitBreaker = cbRegistry.circuitBreaker("psp");
        this.retry = retryRegistry.retry("psp");
    }

    ChargeResult charge(ChargeCommand cmd) {
        Supplier<ChargeResult> call = () -> restClient.post()
                .uri("/charges")
                .header("Idempotency-Key", cmd.idempotencyKey())  // giữ nguyên qua mọi lần retry
                .body(cmd.toRequest())
                .retrieve()
                .body(ChargeResult.class);

        // Thứ tự: CircuitBreaker BỌC NGOÀI Retry.
        // Hệ quả: cả cụm retry của một request gốc được coi là MỘT phép đo của breaker.
        // Nếu đảo lại (retry ngoài breaker) thì mỗi lần thử tính riêng vào tỷ lệ lỗi
        // -> breaker mở sớm hơn nhưng cũng chặn được retry ngay khi mạch hở.
        Supplier<ChargeResult> guarded =
                CircuitBreaker.decorateSupplier(circuitBreaker,
                        Retry.decorateSupplier(retry, call));

        try {
            return guarded.get();
        } catch (CallNotPermittedException e) {
            // Mạch đang OPEN: không gọi PSP. Trả trạng thái để caller quyết định (fallback).
            log.warn("PSP circuit open, key={}", cmd.idempotencyKey());
            return ChargeResult.deferred(cmd.idempotencyKey());
        } catch (ResourceAccessException e) {
            // Timeout/lỗi mạng sau khi retry cạn: trạng thái ở PSP KHÔNG xác định.
            // Không coi là "thất bại nghiệp vụ" -> đẩy sang reconcile.
            log.warn("PSP unknown state, key={}", cmd.idempotencyKey(), e);
            return ChargeResult.unknown(cmd.idempotencyKey());
        }
    }
}
```

**Explanation:** Điểm chính không nằm ở việc gọi được breaker, mà ở hai chỗ: (1) thứ tự `CircuitBreaker.decorateSupplier(cb, Retry.decorateSupplier(retry, call))` quyết định retry nằm *trong* breaker; (2) `CallNotPermittedException` được bắt riêng để trả một trạng thái `deferred` thay vì `500`. Không bắt riêng exception này thì breaker mở cũng vô nghĩa với người dùng.

**Production considerations:**

*   **Fallback là bắt buộc, không phải tùy chọn.** `deferred` / `unknown` ở trên là fallback tối thiểu. Xem [fallback và graceful degradation khi dependency chết](/design-patterns/fallback-pattern) cho các chiến lược đầy đủ.
    
*   **Một breaker cho một downstream.** Không dùng chung một breaker `psp` cho hai PSP khác nhau — một PSP hỏng sẽ mở mạch chặn luôn PSP còn khỏe.
    
*   **Metric bắt buộc:** trạng thái breaker (`resilience4j_circuitbreaker_state`), tỷ lệ lỗi, tỷ lệ slow call, số `CallNotPermittedException`. Alert khi breaker chuyển sang Open.
    
*   **Kết hợp bulkhead + timeout.** Circuit breaker giảm số lời gọi tới dependency hỏng, nhưng để cách ly tài nguyên triệt để cần thêm [bulkhead](/design-patterns/bulkhead-pattern). Thứ tự phối hợp đầy đủ: xem [bản đồ resiliency pattern](/design-patterns/resiliency-patterns-overview).
    

## Thứ tự retry và circuit breaker: một quyết định, không phải mặc định

Đây là chỗ gây nhầm lẫn nhiều nhất khi kết hợp hai pattern.

*   **Circuit breaker bọc ngoài retry** (như code trên): breaker chỉ "thấy" kết quả cuối cùng sau khi retry đã cạn. Mỗi request gốc là một phép đo. Ưu điểm: retry hoàn tất các lần thử của nó cho lỗi transient. Nhược điểm: dưới lỗi kéo dài, mỗi request gốc vẫn tốn đủ số lần retry trước khi breaker mở.
    
*   **Retry bọc ngoài circuit breaker**: mỗi lần thử tính là một lần gọi vào breaker; khi mạch mở, cả retry bị chặn ngay. Ưu điểm: cắt tải nhanh khi dependency hỏng. Nhược điểm: mỗi lần retry làm tăng số mẫu lỗi trên window, breaker có thể mở sớm hơn ý định.
    

> Cảnh báo: với annotation `@Retry` + `@CircuitBreaker` của Resilience4j trên Spring, thứ tự aspect **mặc định** đặt Retry *bọc ngoài* CircuitBreaker (cần kiểm tra lại theo version bạn dùng qua các property `*AspectOrder`). Nếu bạn tự compose bằng `decorateSupplier` như code trên thì thứ tự do bạn quyết định, độc lập với thứ tự aspect. Đừng giả định — kiểm chứng thứ tự thực tế trên version đang dùng.

## Đánh đổi khi thêm circuit breaker

*   **Thêm một lớp trạng thái phải hiểu và debug.** Khi request thất bại với `CallNotPermittedException`, nguyên nhân không nằm ở lời gọi hiện tại mà ở lịch sử lỗi gần đây. Người debug phải nhìn được trạng thái breaker theo thời gian, nếu không sẽ bối rối vì "PSP đang khỏe mà sao vẫn báo lỗi".
    
*   **Cần tuning theo từng dependency.** Ngưỡng, window size, wait duration phù hợp cho PSP có thể sai cho một internal service. Không có bộ số dùng chung.
    
*   **Có thể tạo lỗi giả.** Ngưỡng quá nhạy làm breaker mở vì một spike ngắn vô hại, biến sự cố nhỏ thành gián đoạn dịch vụ.
    
*   **Fail fast làm error rate hiển thị tăng.** Breaker mở trả lỗi ngay, nên số lỗi trên dashboard tăng — đổi lấy việc hệ thống không bị kéo sập. Cần giải thích điều này cho đội vận hành để họ không nhầm breaker mở là sự cố mới.
    

## Circuit breaker bị dùng sai như thế nào

Đây là phần khiến circuit breaker mất tác dụng dù đã được bật:

*   **Đếm lỗi theo số tuyệt đối thay vì tỷ lệ.** "Mở sau 10 lỗi" là sai: 10 lỗi trên 10.000 lời gọi là bình thường, 10 lỗi trên 12 lời gọi là dependency đang chết. Resilience4j dùng `failure-rate-threshold` theo phần trăm — đúng hướng — nhưng phải nhớ kèm `minimum-number-of-calls`.
    
*   **Không tính slow call.** Sai lầm nghiêm trọng nhất, đúng với sự cố mở đầu: PSP *chậm* chứ không *lỗi*. Breaker chỉ đếm exception sẽ không bao giờ mở, thread vẫn cạn. Bắt buộc cấu hình `slow-call-rate-threshold` và `slow-call-duration-threshold`.
    
*   **Không có fallback.** Breaker mở, người dùng vẫn nhận `500` hoặc `CallNotPermittedException` thô. Breaker khi đó chỉ đổi *kiểu* lỗi chứ không cải thiện trải nghiệm.
    
*   **Dùng chung một breaker cho nhiều downstream.** Một dependency hỏng mở mạch chặn luôn các dependency khỏe khác đi qua cùng breaker.
    
*   **Wait duration quá dài hoặc quá ngắn.** Quá ngắn: breaker liên tục thử vào một dependency chưa kịp hồi phục, dao động đóng-mở. Quá dài: dependency đã khỏe từ lâu mà breaker vẫn từ chối.
    

## Khi nào nên dùng

*   Lời gọi tới **dependency bên ngoài** hoặc **service khác qua mạng**, đặc biệt khi dependency đó nằm ngoài tầm kiểm soát (PSP, API đối tác, third-party).
    
*   Dependency có **lưu lượng đủ lớn** để tỷ lệ lỗi có ý nghĩa thống kê.
    
*   Lời gọi **giữ tài nguyên** (thread/connection) trong lúc chờ, nên dependency chậm có thể gây cascading failure.
    
*   Có sẵn một **phản hồi fallback chấp nhận được** khi mạch mở.
    

## Khi nào KHÔNG nên dùng

*   **In-process call.** Method call trong cùng process không có độ trễ mạng hay partial failure kiểu cần breaker. Bọc nó chỉ thêm nhiễu và điểm cấu hình sai.
    
*   **Dependency lưu lượng rất thấp.** Vài lời gọi mỗi phút không đủ để tính tỷ lệ; breaker chủ yếu tạo lỗi giả. Cân nhắc chỉ dùng timeout + retry.
    
*   **Không có fallback hợp lý.** Nếu breaker mở mà chẳng có gì để trả ngoài `500`, giá trị của breaker chỉ còn là giảm tải lên dependency — đôi khi vẫn đáng, nhưng phải biết rõ mình đang đánh đổi trải nghiệm người dùng để lấy điều đó.
    
*   **Thao tác mà lỗi cần được nhìn thấy ngay và không nên bị che.** Với một số luồng, fail fast rồi báo lỗi rõ ràng tốt hơn là im lặng chuyển sang fallback.
    

## Lựa chọn thay thế và bổ sung

*   **Service mesh (Istio/Envoy, Linkerd).** Envoy có outlier detection và circuit breaking ở tầng sidecar, tách khỏi code ứng dụng. Ưu điểm: áp dụng đồng nhất cho mọi service không cần sửa code, đa ngôn ngữ. Nhược điểm: breaker ở tầng mạng không biết ngữ cảnh nghiệp vụ (không phân biệt được `CardDeclined` với lỗi hạ tầng), và thêm một tầng hạ tầng phải vận hành. Suy luận: nhiều đội dùng cả hai — mesh cho circuit breaking hạ tầng thô, breaker trong app cho logic phân biệt lỗi nghiệp vụ. Tôi chưa kiểm chứng con số hiệu năng của hai cách trên một benchmark chung nên không so sánh định lượng ở đây.
    
*   **Chỉ timeout + bulkhead, bỏ circuit breaker.** Với dependency nội bộ ổn định, đôi khi bulkhead (giới hạn concurrency) đã đủ ngăn cạn tài nguyên mà không cần trạng thái breaker. Đơn giản hơn, ít điểm cấu hình sai hơn.
    

Circuit breaker không phải một cơ chế ngôn ngữ thay thế được như Strategy bằng lambda — nó là một stability pattern ở tầng runtime. Điều "hiện đại hóa" được chỉ là *nơi đặt* nó: trong thư viện (Resilience4j) hay ở tầng hạ tầng (service mesh).

## Tham khảo

*   [Resilience4j — CircuitBreaker documentation](https://resilience4j.readme.io/docs/circuitbreaker) — nguồn bậc 1, dùng cho tên property, sliding window, ba trạng thái.
    
*   [Spring Cloud Circuit Breaker](https://docs.spring.io/spring-cloud-circuitbreaker/reference/) — nguồn bậc 1, tích hợp Resilience4j với Spring Boot.
    
*   [Martin Fowler — CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html) — nguồn bậc 4, mô tả ba trạng thái và ý tưởng gốc.
    
*   Michael T. Nygard, *Release It! Design and Deploy Production-Ready Software* (Pragmatic Bookshelf) — nguồn bậc 7, nguồn gốc của circuit breaker như một stability pattern.
    
*   [Bản đồ resiliency pattern](/design-patterns/resiliency-patterns-overview) — thứ tự phối hợp circuit breaker với các pattern khác.
