Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

Resilience4j trong Spring Boot

Tung Bui Thanh (TungDaDev)10 mins read

Rocky mountain ridge at dusk.webp

Trong hệ thống phân tán, cái gì cũng có thể hỏng (everything fails, all the time). Hệ thống "cứng" không phải là hệ thống không bao giờ lỗi, mà là hệ thống biết khoanh vùng sự cố để vẫn phục vụ được người dùng, dù có hơi kém đi một chút (graceful degradation).

1. Sập dây chuyền (cascading failure)

Thử hình dung một tình huống rất hay gặp trong hệ thống e-commerce:

  1. OrderService gọi PaymentGatewayService để thanh toán hoá đơn.
  2. Cổng thanh toán bên thứ ba gặp sự cố. Đáng lẽ trả lỗi trong 100ms thì nó lại treo, 30 giây sau mới timeout.
  3. Mỗi request của người dùng vào OrderService giữ chặt 1 Tomcat worker thread suốt 30 giây đó.
  4. Chưa tới 2 phút, cả 200 thread trong Tomcat thread pool của OrderService đã bị dùng hết (thread exhaustion).
  5. Từ lúc này, ngay cả API xem danh sách đơn hay xem giỏ hàng của OrderService, vốn chẳng dính gì tới thanh toán, cũng không được phục vụ: request mới phải xếp hàng chờ thread (Tomcat còn một hàng đợi kết nối accept-count, mặc định 100), đầy hàng đợi thì kết nối bị từ chối, phía client thấy timeout hoặc lỗi kết nối.
  6. Một service bên ngoài chạy chậm đã kéo sập luôn hệ thống chính của mình.

Thử tính nhanh: mỗi request giữ thread 30 giây, chỉ cần khoảng 200 / 30 ≈ 7 request thanh toán mỗi giây là 200 thread bị chiếm kín. Với một trang e-commerce thì 7 request/giây là con số rất nhỏ.

Để chặn chuyện này, có hai pattern gần như bắt buộc phải biết: Circuit Breaker (cầu dao tự động) và Bulkhead (vách ngăn). Còn trong thế giới Java/Spring Boot, Resilience4j hiện là thư viện ai cũng dùng cho việc này, thay cho Netflix Hystrix đã deprecated.


2. Circuit Breaker trong Resilience4j hoạt động thế nào

Cầu dao điện chỉ có bật và tắt. Circuit Breaker trong phần mềm thì là một finite state machine (FSM) với 3 trạng thái chính:

  1. CLOSED (đóng mạch, chạy bình thường):
    • Request nào cũng được chuyển sang downstream service như thường.
    • Resilience4j theo dõi tỷ lệ lỗi trong một sliding window.
  2. OPEN (ngắt mạch, cầu dao nhảy):
    • Khi tỷ lệ lỗi hoặc tỷ lệ request chậm chạm ngưỡng (ví dụ >= 50%), cầu dao tự ngắt. Tỷ lệ chỉ được tính khi đã có đủ minimum-number-of-calls request trong window, để vài lỗi lẻ tẻ lúc ít traffic không làm cầu dao nhảy.
    • Mọi request sau đó bị chặn ngay tại cửa, trả về CallNotPermittedException (hoặc chạy fallback), không hề gọi qua mạng sang downstream.
    • Nhờ vậy downstream có thời gian hồi lại, còn phía gọi thì không bị kẹt thread.
  3. HALF-OPEN (nửa mở, thử xem đã ổn chưa):
    • Sau một khoảng thời gian cấu hình (wait-duration-in-open-state, mặc định 60 giây, ví dụ bên dưới đặt 15 giây), cầu dao chuyển sang nửa mở.
    • Nó cho đúng permitted-number-of-calls-in-half-open-state request đi qua để thăm dò xem downstream đã khoẻ lại chưa; request vượt số đó vẫn bị chặn.
    • Tỷ lệ lỗi của nhóm request thử dưới ngưỡng thì cầu dao đóng lại (CLOSED). Vẫn chạm ngưỡng thì ngắt tiếp (OPEN).

3. Cấu hình Circuit Breaker với Spring Boot 3

3.1. Thêm dependency

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.4.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

Annotation của Resilience4j chạy bằng Spring AOP nên cần thêm starter AOP. Nếu dùng Spring Boot 4 thì đổi sang artifact resilience4j-spring-boot4, và starter AOP ở Boot 4 đổi tên thành spring-boot-starter-aspectj.

3.2. Cấu hình chi tiết trong application.yml

Resilience4j có 2 kiểu sliding window: COUNT_BASED (tính theo số request) và TIME_BASED (tính theo khoảng thời gian).

resilience4j:
  circuitbreaker:
    instances:
      paymentGateway:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20 # Quan sát 20 request gần nhất
        minimum-number-of-calls: 10 # Phải có ít nhất 10 request mới bắt đầu tính tỷ lệ
        failure-rate-threshold: 50 # Nếu lỗi >= 50% -> Ngắt cầu dao
        slow-call-rate-threshold: 70 # Nếu 70% request chậm -> Ngắt cầu dao
        slow-call-duration-threshold: 2s # Request > 2 giây được coi là chậm
        wait-duration-in-open-state: 15s # Chờ 15s trước khi chuyển sang HALF_OPEN
        permitted-number-of-calls-in-half-open-state: 5 # Thử 5 request lúc Half-Open
        automatic-transition-from-open-to-half-open-enabled: true

Mặc định mọi exception đều bị tính là lỗi. Với client gọi API, lỗi 4xx (request sai, hết tiền trong tài khoản) là lỗi nghiệp vụ, downstream vẫn khoẻ, không nên làm cầu dao nhảy. Khai báo ignore-exceptions (hoặc chỉ liệt kê record-exceptions gồm timeout, lỗi kết nối, 5xx) cho đúng. Với automatic-transition-from-open-to-half-open-enabled: true, Resilience4j dùng một thread nền để tự chuyển sang HALF_OPEN khi hết thời gian chờ; để false thì nó chỉ chuyển khi có request tiếp theo đi vào.

3.3. Gắn annotation và viết hàm fallback

@Service
@Slf4j
public class PaymentClient {
 
    private final RestClient restClient;
 
    public PaymentClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("https://api.payment-gateway.com").build();
    }
 
    @CircuitBreaker(name = "paymentGateway", fallbackMethod = "fallbackProcessPayment")
    public PaymentResponse charge(PaymentRequest req) {
        return restClient.post()
            .uri("/v1/charge")
            .body(req)
            .retrieve()
            .body(PaymentResponse.class);
    }
 
    // Hàm Fallback phải có cùng tham số truyền vào + thêm Throwable ở cuối
    public PaymentResponse fallbackProcessPayment(PaymentRequest req, Throwable t) {
        log.error("Cổng thanh toán lỗi hoặc Cầu dao đang OPEN. Kích hoạt fallback: {}", t.getMessage());
        return PaymentResponse.builder()
            .status("PENDING_OFFLINE")
            .message("Giao dịch đang được tạm giữ để xử lý đối soát sau")
            .build();
    }
}

Ba điều cần cẩn thận với ví dụ này:

  • Circuit Breaker không thay được timeout. Nếu RestClient không đặt read timeout, một request vẫn có thể treo thread rất lâu trước khi bị tính là lỗi. slow-call-duration-threshold chỉ đánh dấu request là chậm sau khi nó đã xong, không cắt ngang nó. Luôn đặt connect/read timeout cho HTTP client (từ Spring Boot 3.4 có thể đặt qua spring.http.client.connect-timeout và spring.http.client.read-timeout).
  • Fallback bắt mọi Throwable, kể cả lỗi validation 4xx. Với thanh toán, trả PENDING_OFFLINE cho một request sai dữ liệu là che lỗi. Nên kiểm tra kiểu exception trong fallback (CallNotPermittedException là lúc cầu dao đang mở), hoặc viết nhiều fallback với kiểu exception khác nhau; Resilience4j chọn fallback có kiểu exception khớp gần nhất.
  • Timeout không có nghĩa là thất bại. Request bị timeout có thể đã trừ tiền thành công ở phía cổng thanh toán. Vì vậy gọi charge phải kèm idempotency key, và trạng thái "chờ đối soát" phải có job hỏi lại cổng thanh toán, không được coi là chưa thanh toán.

4. Bulkhead: vách ngăn để tàu không chìm

Cái tên Bulkhead lấy từ vách ngăn kín nước trên tàu thuỷ. Thân tàu thủng một khoang thì nước chỉ tràn vào khoang đó, cả con tàu vẫn nổi.

Trong phần mềm, Bulkhead giới hạn lượng tài nguyên (thread hoặc số call đồng thời) mà một tác vụ được phép dùng:

Resilience4j có 2 kiểu Bulkhead:

  1. SemaphoreBulkhead: giới hạn số request chạy đồng thời (chạy luôn trên thread hiện tại). Sơ đồ trên chỉ là cách nhìn: không có thread nào bị "dành riêng", semaphore chỉ đảm bảo Payment không bao giờ chiếm quá 15 thread cùng lúc, phần còn lại luôn để dành cho API khác.
  2. ThreadPoolBulkhead: chạy request trên một thread pool riêng, có queue riêng. Kiểu này chỉ dùng được với method trả về CompletionStage/CompletableFuture, vì việc thật sự chạy trên thread khác.

Áp vào code bằng @Bulkhead(name = "paymentGateway") (mặc định là kiểu semaphore, muốn kiểu thread pool thì thêm type = Bulkhead.Type.THREADPOOL):

resilience4j:
  bulkhead:
    instances:
      paymentGateway:
        max-concurrent-calls: 15 # Tối đa 15 request đồng thời vào Payment
        max-wait-duration: 20ms # Request thứ 16 chờ tối đa 20ms, vẫn hết slot thì bị từ chối (BulkheadFullException)

5. RateLimiter: giới hạn lưu lượng request

Dùng để service của bạn không bị quá tải khi traffic tăng vọt bất ngờ hoặc khi bị crawl dữ liệu:

resilience4j:
  ratelimiter:
    instances:
      publicApi:
        limit-for-period: 100 # Tối đa 100 requests
        limit-refresh-period: 1s # Mỗi 1 giây
        timeout-duration: 0ms # Không chờ, reject ngay nếu vượt

Dùng trong code:

@RateLimiter(name = "publicApi")
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) {
    return productService.findById(id);
}

Vượt giới hạn thì method ném RequestNotPermitted; không xử lý thì client nhận HTTP 500. Nên bắt nó trong @RestControllerAdvice để trả 429 Too Many Requests. Một điểm hay bị hiểu nhầm: RateLimiter của Resilience4j đếm trong bộ nhớ của từng instance. Chạy 5 instance thì giới hạn thật của cả hệ thống là 500 request/giây. Cần giới hạn chung cho cả cụm hay theo từng user/API key thì làm ở API Gateway hoặc dùng Redis.


6. Thứ tự khi kết hợp nhiều pattern (decorator execution order)

Khi gắn nhiều annotation trên cùng một method, Resilience4j mặc định chạy chúng theo thứ tự từ ngoài vào trong như sau:

  1. Retry: thử lại khi gặp lỗi tạm thời.
  2. CircuitBreaker: ngắt mạch khi tỷ lệ lỗi quá cao.
  3. RateLimiter: kiểm soát tần suất gọi.
  4. TimeLimiter: giới hạn thời gian chạy tối đa (timeout).
  5. Bulkhead: giới hạn số thread chạy đồng thời.

Thứ tự này lấy theo tài liệu Resilience4j (Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( Function ) ) ) ) )) và đổi được bằng các thuộc tính kiểu resilience4j.retry.retry-aspect-order. Vài hệ quả đáng biết: Retry nằm ngoài cùng nên mỗi lần thử lại đều được CircuitBreaker ghi nhận, 3 lần thử là 3 lần lỗi trong window; khi cầu dao mở, Retry gặp CallNotPermittedException thì nên cấu hình bỏ qua exception này, không thì nó retry vô ích. @TimeLimiter cũng chỉ áp được cho method trả về CompletableFuture hoặc kiểu reactive; với code đồng bộ như PaymentClient ở trên thì timeout phải đặt ở HTTP client.


7. Tổng kết

Kết hợp timeout ở HTTP client, Circuit Breaker, Bulkhead, RateLimiter và Fallback của Resilience4j cho đúng chỗ, service Spring Boot của bạn sẽ chịu được phần lớn các kiểu sự cố mạng hay downstream chập chờn, thay vì sập theo người khác. Bước tiếp theo nên làm là bật Actuator (/actuator/circuitbreakers, metric resilience4j_circuitbreaker_state) và đặt cảnh báo khi cầu dao mở, vì cầu dao mở âm thầm mà không ai biết thì fallback sẽ che mất sự cố. Chi tiết từng module có trong tài liệu Resilience4j.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.