Xử Lý Thất Bại Với Circuit Breaker Trong Spring Cloud | Lộ Trình Java Spring

Chào mừng các bạn quay trở lại với series Lộ Trình Java Spring! Trong các bài viết trước, chúng ta đã cùng nhau khám phá những nền tảng cốt lõi của Spring, từ lý do chọn Spring, các thuật ngữ cơ bản, Dependency Injection, Spring IoC cho đến Spring BootSpring Cloud. Chúng ta đã tìm hiểu về Spring Cloud GatewaySpring Cloud Config, những mảnh ghép quan trọng trong kiến trúc microservices.

Khi chuyển sang kiến trúc microservices, chúng ta đạt được sự linh hoạt, khả năng mở rộng và độc lập. Tuy nhiên, chúng ta cũng đối mặt với một thách thức lớn: sự phụ thuộc giữa các service. Điều gì sẽ xảy ra nếu một service mà ứng dụng của bạn đang gọi gặp sự cố? Nó có thể dẫn đến tình trạng chờ đợi kéo dài, tài nguyên bị chiếm dụng, và thậm chí là sự sụp đổ dây chuyền (cascading failure) của toàn bộ hệ thống. Đây là lúc mô hình Circuit Breaker (Ngắt mạch) phát huy tác dụng.

Trong bài viết này, chúng ta sẽ đi sâu vào cách sử dụng Circuit Breaker trong Spring Cloud để xây dựng các hệ thống microservices bền bỉ (resilient) hơn, có khả năng phục hồi (fault-tolerant) trước những sự cố không thể tránh khỏi.

Tại Sao Cần Circuit Breaker Trong Microservices?

Hãy tưởng tượng hệ thống của bạn bao gồm nhiều service nhỏ giao tiếp với nhau. Service A gọi Service B, Service B gọi Service C, v.v. Đây là kịch bản phổ biến trong kiến trúc microservices. Tuy nhiên, không có gì đảm bảo rằng Service B hoặc Service C luôn hoạt động hoàn hảo.

  • Service B có thể bị quá tải.
  • Service C có thể gặp lỗi mạng.
  • Service C có thể đang trong quá trình triển khai lại.

Nếu Service A liên tục cố gắng gọi một Service B đang gặp sự cố, nó sẽ phải chờ (timeout). Trong khi chờ, tài nguyên (thread, kết nối) của Service A bị chiếm giữ. Nếu có nhiều yêu cầu đến Service A đồng thời, tất cả đều cố gắng gọi Service B và chờ đợi, Service A sẽ nhanh chóng cạn kiệt tài nguyên và bản thân nó cũng sụp đổ. Tệ hơn nữa, các service khác gọi đến Service A cũng sẽ bị ảnh hưởng, tạo nên hiệu ứng domino hay còn gọi là Cascading Failure.

Circuit Breaker Pattern ra đời để giải quyết vấn đề này.

Mô Hình Circuit Breaker Hoạt Động Như Thế Nào?

Mô hình Circuit Breaker được lấy cảm hứng từ thiết bị ngắt mạch điện trong đời sống. Khi dòng điện quá tải, thiết bị ngắt mạch sẽ tự động ngắt kết nối để bảo vệ các thiết bị khác khỏi bị hỏng. Trong phần mềm, Circuit Breaker cũng làm tương tự.

Thay vì cho phép một service liên tục gọi đến một service bị lỗi, Circuit Breaker sẽ “bọc” (wrap) lời gọi đến service đó. Nó theo dõi số lượng yêu cầu thành công và thất bại.

Circuit Breaker có ba trạng thái chính:

  1. CLOSED (Đóng): Trạng thái ban đầu. Các yêu cầu được phép đi qua service mục tiêu như bình thường. Circuit Breaker đếm số lượng lỗi. Nếu số lượng lỗi vượt quá ngưỡng cấu hình trong một khoảng thời gian nhất định, nó sẽ chuyển sang trạng thái OPEN.
  2. OPEN (Mở): Khi ở trạng thái OPEN, Circuit Breaker sẽ chặn tất cả các yêu cầu đến service mục tiêu ngay lập tức và trả về một phản hồi lỗi (hoặc một giá trị mặc định/fallback) mà không cần thực sự gọi service đó. Điều này giúp service gọi tránh lãng phí tài nguyên vào một service đang bị lỗi và cho phép service bị lỗi có thời gian để phục hồi. Sau một khoảng thời gian chờ nhất định (configurable wait time), nó sẽ chuyển sang trạng thái HALF_OPEN.
  3. HALF_OPEN (Nửa mở): Sau khi chờ đợi, Circuit Breaker cho phép một số lượng nhỏ yêu cầu đi qua service mục tiêu để kiểm tra xem nó đã phục hồi chưa.
    • Nếu các yêu cầu thử nghiệm này thành công, nó sẽ chuyển về trạng thái CLOSED, cho phép tất cả các yêu cầu đi qua lại.
    • Nếu các yêu cầu thử nghiệm này vẫn thất bại, nó sẽ ngay lập tức quay trở lại trạng thái OPEN và bắt đầu lại chu kỳ chờ.

Mô hình này giúp hệ thống:

  • Ngăn chặn các lỗi lan truyền (cascading failures).
  • Giảm tải cho service đang bị lỗi, tạo điều kiện để nó phục hồi.
  • Cung cấp trải nghiệm người dùng tốt hơn bằng cách trả về lỗi nhanh chóng hoặc dữ liệu dự phòng (fallback data) thay vì chờ đợi vô vọng.

Spring Cloud CircuitBreaker

Spring Cloud cung cấp một abstraction (tầng trừu tượng) cho mô hình Circuit Breaker, cho phép bạn dễ dàng tích hợp nó vào ứng dụng Spring Boot của mình. Trước đây, Spring Cloud sử dụng thư viện Hystrix của Netflix, nhưng Hystrix đã không còn được bảo trì tích cực. Hiện tại, Spring Cloud khuyến khích sử dụng các thư viện thay thế như Resilience4j hoặc Sentinel.

Trong bài viết này, chúng ta sẽ tập trung vào cách sử dụng Resilience4j, vì nó là lựa chọn mặc định và phổ biến nhất trong Spring Cloud hiện đại.

Thêm Dependency

Để sử dụng Circuit Breaker với Resilience4j trong Spring Cloud, bạn cần thêm dependency tương ứng vào file pom.xml (Maven) hoặc build.gradle (Gradle).

Maven:

<dependency>
    <groupId<org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>

Gradle:

implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'

Đảm bảo rằng bạn cũng có các dependency Spring Boot và Spring Cloud cần thiết khác, giống như chúng ta đã thảo luận trong bài Spring Boot StartersWhat Is Spring Cloud?.

Áp Dụng Circuit Breaker

Cách đơn giản nhất để áp dụng Circuit Breaker là sử dụng annotation @CircuitBreaker được cung cấp bởi Spring Cloud CircuitBreaker.

Giả sử bạn có một service gọi đến một external service hoặc một microservice khác:

@Service
public class RemoteServiceCaller {

    private final RestTemplate restTemplate;

    public RemoteServiceCaller(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public String callRemoteService() {
        // Giả định đây là lời gọi đến một service khác có thể thất bại
        return restTemplate.getForObject("http://remote-service/api/data", String.class);
    }
}

Để bảo vệ lời gọi callRemoteService bằng Circuit Breaker, bạn thêm annotation @CircuitBreaker như sau:

import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4jCircuitBreakerFactory; // Import factory if needed for config, but annotation is simpler
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; // Abstraction layer
import org.springframework.stereotype.Service;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; // Resilience4j specific annotation

@Service
public class RemoteServiceCaller {

    private final RestTemplate restTemplate;

    public RemoteServiceCaller(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @CircuitBreaker(name = "remoteService", fallbackMethod = "callRemoteServiceFallback")
    public String callRemoteService() {
        System.out.println("Calling remote service...");
        // Giả định đây là lời gọi đến một service khác có thể thất bại
        // Ví dụ: return restTemplate.getForObject("http://localhost:8081/api/data", String.class);
        // Để mô phỏng lỗi, bạn có thể gọi một endpoint không tồn tại hoặc service down.
        // For demonstration, let's simulate a potential failure
        if (Math.random() > 0.5) {
             throw new RuntimeException("Simulated remote service failure!");
        }
        return "Data from remote service";
    }

    // Fallback method signature MUST match the original method, plus an Throwable parameter
    public String callRemoteServiceFallback(Throwable t) {
        System.out.println("Executing fallback method due to: " + t.getMessage());
        return "Fallback data: Service currently unavailable.";
    }
}

Trong ví dụ trên:

  • @CircuitBreaker(name = "remoteService"): Áp dụng Circuit Breaker cho phương thức callRemoteService. name là tên định danh cho Circuit Breaker này. Tên này được sử dụng để cấu hình và theo dõi.
  • fallbackMethod = "callRemoteServiceFallback": Chỉ định tên của phương thức sẽ được gọi khi Circuit Breaker ở trạng thái OPEN hoặc khi lời gọi gốc thất bại (và Circuit Breaker ở trạng thái CLOSED nhưng lỗi kích hoạt nó chuyển sang OPEN). Phương thức fallback cần có cùng chữ ký (kiểu trả về, tham số) với phương thức gốc, cộng thêm một tham số Throwable cuối cùng (tùy chọn, nhưng nên có để biết lỗi gì đã xảy ra).

Cấu Hình Circuit Breaker

Spring Cloud CircuitBreaker với Resilience4j cho phép cấu hình chi tiết hành vi của Circuit Breaker thông qua file cấu hình của Spring Boot (ví dụ: application.yml hoặc application.properties).

Bạn có thể cấu hình cho tất cả các Circuit Breaker hoặc cấu hình riêng cho từng Circuit Breaker dựa trên tên (ví dụ: “remoteService”).

Cấu hình Global (cho tất cả Circuit Breaker):

spring.cloud.circuitbreaker.resilience4j.circuitbreaker.configs.default:
  slidingWindowType: COUNT_BASED # or TIME_BASED
  slidingWindowSize: 10 # Number of calls in the sliding window
  failureRateThreshold: 50 # Percentage of failures to open the circuit
  waitDurationInOpenState: 10s # Time the circuit stays open
  permittedNumberOfCallsInHalfOpenState: 3 # Number of calls allowed in HALF_OPEN
  minimumNumberOfCalls: 5 # Minimum calls needed before calculating failure rate

Cấu hình Riêng (cho Circuit Breaker có tên “remoteService”):

spring.cloud.circuitbreaker.resilience4j.circuitbreaker.instances.remoteService:
  slidingWindowType: TIME_BASED
  slidingWindowSize: 60s # Window of 60 seconds
  failureRateThreshold: 60 # Open circuit if 60% of calls fail
  waitDurationInOpenState: 20s # Wait 20 seconds in OPEN state
  permittedNumberOfCallsInHalfOpenState: 5 # Allow 5 calls in HALF_OPEN
  minimumNumberOfCalls: 10 # Need at least 10 calls to start calculating failure rate

Các thuộc tính cấu hình quan trọng bao gồm:

  • slidingWindowType: Loại cửa sổ trượt để tính toán tỷ lệ lỗi. Có thể là COUNT_BASED (dựa trên số lượng yêu cầu) hoặc TIME_BASED (dựa trên khoảng thời gian).
  • slidingWindowSize: Kích thước của cửa sổ trượt. Nếu là COUNT_BASED, đây là số lượng yêu cầu gần nhất. Nếu là TIME_BASED, đây là khoảng thời gian (ví dụ: 60s).
  • failureRateThreshold: Ngưỡng tỷ lệ lỗi (%) để Circuit Breaker chuyển từ CLOSED sang OPEN.
  • waitDurationInOpenState: Thời gian mà Circuit Breaker ở trạng thái OPEN trước khi chuyển sang HALF_OPEN.
  • permittedNumberOfCallsInHalfOpenState: Số lượng yêu cầu được phép đi qua khi ở trạng thái HALF_OPEN.
  • minimumNumberOfCalls: Số lượng yêu cầu tối thiểu trong cửa sổ trượt trước khi Circuit Breaker bắt đầu tính toán tỷ lệ lỗi và có thể chuyển trạng thái. Điều này giúp tránh việc Circuit Breaker mở quá sớm chỉ vì một vài lỗi ban đầu.

Việc cấu hình phù hợp là rất quan trọng và phụ thuộc vào đặc điểm của service mà bạn đang gọi và mức độ chịu lỗi mong muốn của ứng dụng bạn.

Fallback Methods Chi Tiết Hơn

Phương thức fallback là một phần không thể thiếu của mô hình Circuit Breaker, đặc biệt khi bạn không muốn trả về lỗi trắng trơn cho người dùng cuối hoặc các service gọi khác.

Phương thức fallback có thể:

  • Trả về dữ liệu mặc định (ví dụ: danh sách rỗng, giá trị null).
  • Trả về dữ liệu được cache trước đó.
  • Trả về một thông báo lỗi thân thiện.
  • Ghi log chi tiết về sự cố.

Quan trọng là chữ ký phương thức fallback phải khớp:

  • Cùng kiểu trả về với phương thức gốc.
  • Cùng các tham số như phương thức gốc, theo thứ tự.
  • (Tùy chọn nhưng khuyến khích) Tham số cuối cùng là Throwable để bạn có thể biết nguyên nhân cụ thể (lỗi từ remote service, timeout, hoặc Circuit Breaker mở).

Ví dụ về fallback với tham số:

@CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
public Product getProductDetails(Long productId) {
    // Call to product service
    return restTemplate.getForObject("http://product-service/api/products/" + productId, Product.class);
}

public Product getProductFallback(Long productId, Throwable t) {
    System.err.println("Fallback triggered for productId " + productId + " due to: " + t.getMessage());
    // Return a default or cached product object
    return new Product(productId, "Default Product", "Description not available");
}

Lưu ý: Logic trong phương thức fallback nên đơn giản và đáng tin cậy. Nó không nên gọi lại service đang gặp sự cố hoặc các service khác có khả năng gây ra lỗi tương tự, vì điều này có thể dẫn đến một Cascading Failure khác bắt nguồn từ chính fallback.

Circuit Breaker vs Retry

Đôi khi, mọi người nhầm lẫn giữa Circuit Breaker và Retry (Thử lại). Mặc dù cả hai đều là các mẫu thiết kế để tăng tính bền bỉ, chúng giải quyết các vấn đề khác nhau.

Đây là bảng so sánh ngắn gọn:

Feature Circuit Breaker Retry
Mục đích chính Ngăn chặn việc gọi liên tục đến service đang gặp lỗi để hệ thống phục hồi và tránh sụp đổ dây chuyền. Vượt qua các lỗi nhất thời (transient failures) bằng cách thử lại lời gọi sau một thời gian ngắn.
Thời điểm kích hoạt Khi tỷ lệ lỗi của service mục tiêu vượt quá ngưỡng cấu hình. Khi một lời gọi duy nhất đến service mục tiêu thất bại.
Hành vi khi lỗi Khi mở (OPEN), chặn các yêu cầu đến service mục tiêu và trả về fallback/lỗi ngay lập tức. Thử lại lời gọi đến service mục tiêu một số lần nhất định với độ trễ giữa các lần thử.
Trạng thái Có trạng thái (CLOSED, OPEN, HALF_OPEN) dựa trên lịch sử các lời gọi gần đây. Không có trạng thái; hành động dựa trên kết quả của lời gọi hiện tại.
Khi nào sử dụng Bảo vệ khỏi lỗi kéo dài hoặc quá tải của service mục tiêu. Phù hợp với các lỗi không nhất thời. Xử lý các lỗi nhất thời, thoáng qua (ví dụ: lỗi mạng nhỏ, service bận rộn trong giây lát).

Trong thực tế, bạn có thể sử dụng cả hai mẫu thiết kế này cùng nhau. Ví dụ, bạn có thể cấu hình Retry cho các lỗi mạng nhất thời trước khi Circuit Breaker kịp “mở”. Hoặc Circuit Breaker sẽ xử lý khi Retry liên tục thất bại và tỷ lệ lỗi vượt ngưỡng.

Spring Cloud CircuitBreaker với Resilience4j cũng hỗ trợ mẫu Retry thông qua annotation @Retry. Bạn có thể kết hợp @CircuitBreaker@Retry trên cùng một phương thức.

Theo Dõi Circuit Breaker

Một khía cạnh quan trọng khi sử dụng Circuit Breaker là theo dõi trạng thái và hiệu suất của chúng. Spring Boot Actuator, mà chúng ta đã tìm hiểu trong bài Theo Dõi và Quản Lý Ứng Dụng Spring Boot với Actuator, cung cấp endpoint /actuator/health/actuator/metrics để làm điều này.

Khi sử dụng Resilience4j với Spring Cloud CircuitBreaker, các metrics về Circuit Breaker sẽ được tự động tích hợp nếu bạn có dependency Actuator và Micrometer.

Metrics quan trọng cần theo dõi:

  • Trạng thái hiện tại của Circuit Breaker (CLOSED, OPEN, HALF_OPEN).
  • Số lượng yêu cầu thành công, thất bại, bị chặn bởi Circuit Breaker.
  • Tỷ lệ lỗi.

Bạn có thể truy cập thông tin chi tiết hơn qua endpoint /actuator/circuitbreakers (hoặc tương tự, tùy thuộc vào phiên bản và cấu hình cụ thể) hoặc xem các metrics thông qua các công cụ giám sát như Prometheus và Grafana tích hợp với Actuator và Micrometer.

Việc theo dõi giúp bạn:

  • Xác định service nào đang gây ra lỗi.
  • Hiểu khi nào Circuit Breaker đang hoạt động (mở, đóng, nửa mở).
  • Điều chỉnh cấu hình Circuit Breaker cho phù hợp hơn.

Những Điểm Cần Lưu Ý

  • Granularity (Mức độ chi tiết): Bạn nên áp dụng Circuit Breaker ở đâu? Thường là bọc quanh các lời gọi đến các external service hoặc các microservice khác mà bạn không kiểm soát hoàn toàn hoặc có khả năng gây ra lỗi. Tránh áp dụng cho các lời gọi nội bộ trong cùng một ứng dụng hoặc các thao tác rất nhanh và đáng tin cậy.
  • Cấu hình: Cấu hình Circuit Breaker không phải là “một kích thước phù hợp cho tất cả”. Các ngưỡng lỗi, thời gian chờ, kích thước cửa sổ cần được điều chỉnh dựa trên đặc điểm của service mục tiêu và SLA (Service Level Agreement) của hệ thống. Thử nghiệm và giám sát là rất quan trọng.
  • Fallback Logic: Logic trong phương thức fallback cần được thiết kế cẩn thận. Nó nên cung cấp một trải nghiệm chấp nhận được cho người dùng và không gây thêm tải cho hệ thống.
  • Testing: Hãy viết unit/integration test để kiểm tra hành vi của Circuit Breaker và fallback logic trong các tình huống khác nhau (service thành công, thất bại, timeout).

Kết Luận

Trong kiến trúc microservices, thất bại là điều không thể tránh khỏi. Điều quan trọng là cách chúng ta phản ứng với những thất bại đó. Mô hình Circuit Breaker, được hỗ trợ mạnh mẽ bởi Spring Cloud CircuitBreaker (với Resilience4j), là một công cụ thiết yếu để xây dựng các hệ thống bền bỉ, có khả năng phục hồi.

Bằng cách hiểu và áp dụng Circuit Breaker, bạn có thể ngăn chặn các lỗi lan truyền, bảo vệ các service khỏi bị quá tải khi một service phụ thuộc gặp sự cố, và cung cấp trải nghiệm người dùng tốt hơn ngay cả khi hệ thống không hoàn hảo.

Việc tích hợp Circuit Breaker vào ứng dụng Spring Boot của bạn với Spring Cloud rất đơn giản nhờ annotation @CircuitBreaker và khả năng cấu hình linh hoạt. Kết hợp với khả năng theo dõi của Spring Boot Actuator, bạn có một giải pháp mạnh mẽ để tăng cường độ tin cậy cho các hệ thống phân tán của mình.

Bài viết này là một bước tiếp theo trên Lộ trình Java Spring của chúng ta, tập trung vào việc giải quyết những thách thức thực tế khi xây dựng ứng dụng microservices. Hãy thực hành áp dụng Circuit Breaker vào các dự án của bạn để cảm nhận sức mạnh của nó nhé!

Hẹn gặp lại các bạn trong các bài viết tiếp theo của series!

Chỉ mục