Một đơn hàng thanh toán qua cổng bên thứ ba. Một buổi tối cổng này không sập hẳn mà chỉ chậm: mỗi cuộc gọi mất 30 giây thay vì 300 mili giây. Luồng Tomcat bị giữ lại chờ, và sau vài phút cả website không phản hồi dù chỉ một chức năng phụ thuộc cổng đó. Đây là lỗi lan truyền (cascading failure), và Circuit Breaker được sinh ra để chặn nó: khi dịch vụ phụ thuộc lỗi liên tục, ta ngừng gọi một lúc thay vì tiếp tục xếp hàng chờ. Bài này dùng Resilience4j 2.3.0 trên Spring Boot 3.4.4 và JDK 21 để cài Circuit Breaker, Retry và Bulkhead, chạy thật, đo thật và chỉ ra ba cái bẫy: thứ tự ghép, lỗi nào đáng đếm, và cách Bulkhead tương tác với Circuit Breaker.
Bài nối tiếp CQRS và Event Sourcing trong Java: khi nào nên dùng, nơi mô hình ghi và đọc tách riêng nhưng vẫn phải gọi nhau qua mạng, và Nâng cấp lên Spring Boot 3: Jakarta EE, Java 17 và observability, vì ta sẽ đọc trạng thái breaker qua Actuator.
Vấn đề: gọi dịch vụ ngoài mà không có giới hạn
Martin Fowler mô tả trong bài về Circuit Breaker rằng các cuộc gọi từ xa có thể treo cho tới khi hết timeout, và nếu có nhiều cuộc gọi như vậy cùng lúc, tài nguyên (luồng, kết nối) cạn kiệt và gây lỗi dây chuyền. Michael Nygard phổ biến pattern này trong cuốn Release It!. Ba cơ chế của Resilience4j trả lời ba câu hỏi khác nhau:
| Cơ chế | Câu hỏi nó trả lời | Hành động |
|---|---|---|
| Retry | Lỗi này có thể chỉ là thoáng qua? | Gọi lại vài lần, cách nhau một khoảng tăng dần |
| Circuit Breaker | Dịch vụ kia có đang hỏng thật không? | Ngừng gọi một lúc, trả lỗi ngay |
| Bulkhead | Một chức năng chậm có được nuốt hết tài nguyên không? | Giới hạn số cuộc gọi đồng thời, từ chối phần dư |
Ba cơ chế bổ sung nhau: Retry thiếu breaker khuếch đại tải lên dịch vụ đang hỏng, breaker thiếu bulkhead để cuộc gọi chậm chiếm hết luồng trước khi kịp mở.
Circuit Breaker hoạt động thế nào: ba trạng thái
Theo tài liệu CircuitBreaker của Resilience4j, Circuit Breaker là máy trạng thái hữu hạn. Ở trạng thái CLOSED, cuộc gọi đi qua bình thường và kết quả được ghi vào cửa sổ trượt (sliding window); khi tỷ lệ lỗi hoặc tỷ lệ cuộc gọi chậm chạm ngưỡng, breaker chuyển sang OPEN và từ chối mọi cuộc gọi bằng CallNotPermittedException. Sau waitDurationInOpenState, Circuit Breaker sang HALF_OPEN cho một số cuộc gọi thử; nếu tỷ lệ lỗi vẫn vượt ngưỡng thì quay về OPEN, ngược lại về CLOSED.
Các tham số quan trọng và giá trị mặc định theo tài liệu: failureRateThreshold 50%, slidingWindowSize 100, minimumNumberOfCalls 100, waitDurationInOpenState 60 giây, permittedNumberOfCallsInHalfOpenState 10, slowCallDurationThreshold 60 giây. Với mặc định, breaker không đánh giá gì cho tới khi có 100 cuộc gọi; và nó chỉ sang HALF_OPEN khi có cuộc gọi tới, trừ khi bật automaticTransitionFromOpenToHalfOpenEnabled.
Chạy thử Circuit Breaker bằng thư viện lõi
Trước khi dùng Spring, ta xem từng cơ chế trong một chương trình Java thuần (code/s6-27, lớp Plain). Cổng thanh toán giả có ba chế độ: ok, fail (ném lỗi 503) và slow (chậm 300 ms). Cấu hình Circuit Breaker dùng cửa sổ nhỏ để dễ quan sát:
CircuitBreakerConfig cbCfg = CircuitBreakerConfig.custom()
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(10).minimumNumberOfCalls(5).failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofMillis(1000))
.permittedNumberOfCallsInHalfOpenState(2).build();
CircuitBreaker cb = CircuitBreaker.of("payment", cbCfg);
cb.getEventPublisher().onStateTransition(e ->
System.out.println(" [sự kiện] " + e.getStateTransition()));Gửi tám cuộc gọi khi cổng đang hỏng, rồi cho cổng phục hồi và đợi hết thời gian OPEN. Kết quả thật:
=== 1. Circuit Breaker: CLOSED -> OPEN -> HALF_OPEN -> CLOSED ===
call 1 -> lỗi: gateway 503 state=CLOSED gọi thật tới gateway=1 mất 1722 µs
call 2 -> lỗi: gateway 503 state=CLOSED gọi thật tới gateway=2 mất 35 µs
call 3 -> lỗi: gateway 503 state=CLOSED gọi thật tới gateway=3 mất 23 µs
call 4 -> lỗi: gateway 503 state=CLOSED gọi thật tới gateway=4 mất 20 µs
[sự kiện] State transition from CLOSED to OPEN
call 5 -> lỗi: gateway 503 state=OPEN gọi thật tới gateway=5 mất 1991 µs
call 6 -> BỊ CHẶN (CallNotPermittedException) state=OPEN gọi thật tới gateway=5 mất 155 µs
call 7 -> BỊ CHẶN (CallNotPermittedException) state=OPEN gọi thật tới gateway=5 mất 21 µs
call 8 -> BỊ CHẶN (CallNotPermittedException) state=OPEN gọi thật tới gateway=5 mất 20 µs
failureRate=100% failed=5 notPermitted=3
-- gateway phục hồi, chờ 1,1 giây --
[sự kiện] State transition from OPEN to HALF_OPEN
probe 1 -> PAID:DH state=HALF_OPEN
[sự kiện] State transition from HALF_OPEN to CLOSED
probe 2 -> PAID:DH state=CLOSED
probe 3 -> PAID:DH state=CLOSEDNăm cuộc gọi đầu đều tới được cổng và đều lỗi; sau cuộc gọi thứ 5 (đúng bằng minimumNumberOfCalls) tỷ lệ lỗi là 100% nên breaker mở. Ba cuộc gọi sau bị chặn tức thì với CallNotPermittedException và số cuộc gọi thật tới cổng đứng yên ở 5. Đó chính là giá trị của Circuit Breaker: cổng đang hỏng không phải nhận thêm tải, và luồng của bạn nhận lỗi ngay (cột "mất" đo bằng System.nanoTime: cuộc gọi bị chặn mất 20 đến 155 micro giây trong lần chạy này, còn con số này dao động giữa các lần chạy) thay vì chờ timeout. Sau khi chờ 1,1 giây, breaker sang HALF_OPEN, cuộc thăm dò đầu tiên thành công và cuộc thứ hai đóng Circuit Breaker lại.
Retry: gọi lại có kiểm soát
Retry xử lý lỗi thoáng qua như mất gói tin hay một lần 503 do cổng đang khởi động lại. Theo tài liệu Retry, mặc định maxAttempts là 3 (tính cả lần đầu) và waitDuration là 500 ms; có thể dùng IntervalFunction.ofExponentialBackoff để khoảng chờ tăng dần, hoặc ofRandomized để thêm ngẫu nhiên tránh nhiều client cùng gọi lại một lúc. Tham số retryExceptions và ignoreExceptions quyết định lỗi nào đáng thử lại.
RetryConfig rCfg = RetryConfig.custom().maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialBackoff(Duration.ofMillis(100), 2.0))
.retryExceptions(IllegalStateException.class).build();
Retry retry = Retry.of("payment", rCfg);Với backoff 100 ms nhân đôi, ba lần gọi thật diễn ra như sau (số đo thật, đồng hồ bắt đầu trước lần gọi đầu):
=== 2. Retry: 3 lần, backoff 100ms x2, kèm thời điểm từng lần ===
lần gọi thật thứ 1 tại t=1 ms
lần gọi thật thứ 2 tại t=108 ms
lần gọi thật thứ 3 tại t=314 ms
Kết quả: thất bại sau 3 lần gọi thật: gateway 503Lần hai cách lần một khoảng 100 ms và lần ba cách lần hai khoảng 200 ms, đúng cấp số nhân. Tổng hơn 300 ms (314 ms ở lần chạy này) cho một cuộc thanh toán thất bại, con số cần nằm trong ngân sách timeout của request gốc. Retry giả định thao tác idempotent: gọi lại cổng thanh toán mà không có idempotency key có thể trừ tiền khách hai lần nếu lần đầu đã thành công nhưng phản hồi bị mất.
Bulkhead: vách ngăn khoang tàu
Tên gọi lấy từ vách ngăn trong thân tàu: một khoang thủng không làm chìm cả tàu. Resilience4j có hai loại: SemaphoreBulkhead giới hạn số cuộc gọi đồng thời bằng semaphore (mặc định maxConcurrentCalls là 25, maxWaitDuration là 0), và ThreadPoolBulkhead dùng thread pool cùng hàng đợi có giới hạn riêng. Loại semaphore nhẹ hơn, hợp khi bạn đã tự quản lý thread pool của client.
Thử với 10 luồng cùng gọi vào cổng chậm 300 ms, bulkhead cho tối đa 3 cuộc gọi đồng thời và không chờ:
=== 3. Bulkhead: tối đa 3 cuộc gọi đồng thời, 10 luồng cùng lúc, mỗi cuộc gọi 300 ms ===
thành công=3 bị từ chối ngay (BulkheadFullException)=7 gateway thực nhận=3Ba cuộc gọi vào, bảy cuộc bị từ chối ngay bằng BulkheadFullException, và cổng chỉ thực nhận ba lần. Cổng chậm giờ chỉ chiếm tối đa 3 luồng, các luồng còn lại phục vụ chức năng khác.
Thứ tự ghép: Retry bọc ngoài hay Circuit Breaker bọc ngoài
Khi dùng Resilience4j qua Spring Boot, thứ tự mặc định của các aspect theo tài liệu Spring Boot 3 là Retry bọc CircuitBreaker, bọc RateLimiter, bọc TimeLimiter, bọc Bulkhead, trong cùng là hàm của bạn. Câu hỏi là thứ tự này khác gì thứ tự ngược lại? Chúng tôi chạy sáu request vào cổng hỏng hoàn toàn, mỗi request tối đa 3 lần thử, breaker có cửa sổ 6 cuộc gọi:
Supplier<String> chain = retryOutside
? Retry.decorateSupplier(r, CircuitBreaker.decorateSupplier(c, call)) // Retry bọc ngoài
: CircuitBreaker.decorateSupplier(c, Retry.decorateSupplier(r, call)); // CircuitBreaker bọc ngoài=== 4. Thứ tự ghép, gateway hỏng hoàn toàn, 6 request của người dùng ===
Retry( CircuitBreaker ) gateway nhận 6 | breaker ghi 6 thất bại, chặn 12 | cuối: OPEN
CircuitBreaker( Retry ) gateway nhận 18 | breaker ghi 6 thất bại, chặn 0 | cuối: OPEN
Retry(CB), bỏ qua CallNotPermitted gateway nhận 6 | breaker ghi 6 thất bại, chặn 4 | cuối: OPENHai thứ tự cho kết quả rất khác nhau:
- Retry( CircuitBreaker ): mỗi lần thử lại đi qua Circuit Breaker và được breaker đếm. Cổng chỉ nhận 6 lần gọi, rồi Circuit Breaker mở. Nhưng 12 lần thử lại sau đó đều đập vào breaker đã mở và bị chặn; với backoff thật (hàng trăm mili giây) request của người dùng bị chậm vô ích.
- CircuitBreaker( Retry ): Circuit Breaker chỉ thấy kết quả cuối của cả chuỗi thử, nên mỗi request chỉ tính một lần lỗi. Breaker cần sáu request mới mở, và trong thời gian đó cổng phải chịu 18 lần gọi, gấp ba lần. Breaker phản ứng chậm và cổng đang hỏng bị tăng tải.
- Retry( CircuitBreaker ) cộng bỏ qua
CallNotPermittedException: cổng nhận 6 lần, breaker mở đúng lúc, và 4 request còn lại bị chặn ngay mà không thử lại. Đây là cấu hình chúng tôi khuyên dùng và sẽ cấu hình trong Spring.
Quy tắc rút ra: để Retry bọc ngoài Circuit Breaker (như mặc định), nhưng luôn khai báo CallNotPermittedException trong ignoreExceptions của Retry, vì thử lại một cuộc gọi bị breaker từ chối chỉ tốn thời gian.
Tích hợp vào Spring Boot 3 bằng annotation
Với Spring Boot 3, dependency là io.github.resilience4j:resilience4j-spring-boot3, kèm spring-boot-starter-aop và Actuator. Cấu hình đặt trong application.yml; ví dụ này đặt tên instance payment cho cả ba cơ chế:
resilience4j:
circuitbreaker:
instances:
payment:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 3s
permittedNumberOfCallsInHalfOpenState: 2
ignoreExceptions:
- demo.r4j.InvalidOrderException
retry:
instances:
payment:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- demo.r4j.GatewayException
ignoreExceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
- demo.r4j.InvalidOrderException
bulkhead:
instances:
payment:
maxConcurrentCalls: 3
maxWaitDuration: 0Có ba quyết định quan trọng trong file này. Thứ nhất, InvalidOrderException (lỗi 4xx do dữ liệu khách, ví dụ đơn không hợp lệ) nằm trong ignoreExceptions của cả Circuit Breaker lẫn Retry: lỗi do khách không nói lên sức khoẻ của cổng và thử lại cũng không thay đổi kết quả. Fowler cũng lưu ý không phải lỗi nào cũng nên làm breaker nhảy. Thứ hai, retryExceptions chỉ liệt kê GatewayException (lỗi tạm thời), tức là mặc định không retry các lỗi lạ. Thứ ba, Retry bỏ qua CallNotPermittedException như phân tích ở trên. Dịch vụ dùng ba annotation:
@Service
public class PaymentService {
private final FakeGateway gateway;
PaymentService(FakeGateway gateway) { this.gateway = gateway; }
// Thứ tự mặc định của Resilience4j: Retry( CircuitBreaker( Bulkhead( hàm ) ) )
// Fallback đặt ở lớp NGOÀI CÙNG (@Retry). Đặt ở @CircuitBreaker sẽ nuốt exception và Retry không bao giờ thấy lỗi.
@Retry(name = "payment", fallbackMethod = "pending")
@CircuitBreaker(name = "payment")
@Bulkhead(name = "payment")
public String pay(String orderId) {
return gateway.charge(orderId);
}
/** Fallback: cùng chữ ký + 1 tham số Throwable. Không báo "đã thanh toán", mà báo "đang chờ xử lý". */
String pending(String orderId, Throwable t) {
if (t instanceof InvalidOrderException e) throw e; // lỗi của khách: không che bằng fallback
return "PENDING:" + orderId + " (" + t.getClass().getSimpleName() + ")";
}
}Hàm pending là fallback: cùng chữ ký với phương thức gốc cộng thêm một tham số Throwable, đúng quy tắc trong tài liệu. Fallback đặt ở annotation ngoài cùng (@Retry); nếu đặt ở @CircuitBreaker, nó sẽ nuốt exception trước khi Retry kịp thấy lỗi và Retry không bao giờ chạy. Và fallback không được "che" mọi lỗi: lỗi của khách (InvalidOrderException) được ném lại, còn lỗi hạ tầng trả về trạng thái PENDING chứ không phải PAID, vì báo "đã thanh toán" khi chưa chắc là cái bẫy nghiệp vụ nghiêm trọng hơn cả lỗi kỹ thuật.
Quan sát hành vi thật qua HTTP và Actuator
Để tự chạy lại: trong code/s6-27 gõ mvn -q package, rồi bash drive.sh (JDK 21; sửa JAVA_HOME trong script cho đúng máy bạn). Script drive.sh khởi động ứng dụng và gửi yêu cầu theo kịch bản. Kết quả thật (JDK 21, Apple M4, 16 GB):
--- A. gateway khoẻ
PAID:DH001
--- B. đơn sai dữ liệu (4xx): không retry, không tính vào breaker
Đơn không hợp lệ: BAD1 (HTTP 422)
gateway nhận 1 lần
--- C. gateway hỏng: 8 request liên tiếp (mỗi request tối đa 3 lần gọi thật nhờ Retry)
req 1 -> PENDING:DH001 (GatewayException) | gateway nhận thêm 3 lần
req 2 -> PENDING:DH002 (CallNotPermittedException) | gateway nhận thêm 1 lần
req 3 -> PENDING:DH003 (CallNotPermittedException) | gateway nhận thêm 0 lần
req 4 -> PENDING:DH004 (CallNotPermittedException) | gateway nhận thêm 0 lần
req 5 -> PENDING:DH005 (CallNotPermittedException) | gateway nhận thêm 0 lần
req 6 -> PENDING:DH006 (CallNotPermittedException) | gateway nhận thêm 0 lần
req 7 -> PENDING:DH007 (CallNotPermittedException) | gateway nhận thêm 0 lần
req 8 -> PENDING:DH008 (CallNotPermittedException) | gateway nhận thêm 0 lần
breaker: OPEN | failureRate 80.0% | failed 4 | notPermitted 7
--- D. chờ 3,2 giây rồi gateway phục hồi
probe 1 -> PAID:OK1
breaker: HALF_OPEN | failureRate -1.0% | failed 0 | notPermitted 0
probe 2 -> PAID:OK2
breaker: CLOSED | failureRate -1.0% | failed 0 | notPermitted 0
probe 3 -> PAID:OK3
breaker: CLOSED | failureRate -1.0% | failed 0 | notPermitted 0Có bốn điều đáng đọc kỹ. Một, đơn BAD1 trả HTTP 422 và cổng chỉ nhận 1 lần: không retry. Hai, request đầu tiên khi cổng hỏng khiến gateway nhận 3 lần (đúng maxAttempts), và request thứ hai chỉ chạm cổng 1 lần rồi Circuit Breaker mở: sau đó 6 request tiếp theo trả PENDING ngay mà gateway nhận 0 lần. Ba, con số failureRate 80.0% với 4 lần lỗi nghĩa là cửa sổ có 5 cuộc gọi ghi nhận: một cuộc thành công ở bước A và bốn cuộc lỗi (cuộc gọi BAD1 không được tính vì nằm trong danh sách bỏ qua). Bốn, sau 3,2 giây breaker sang HALF_OPEN khi cuộc thăm dò đầu tới, rồi về CLOSED sau cuộc thứ hai; failureRate -1.0% là cách Resilience4j báo "chưa đủ số cuộc gọi tối thiểu để tính".
Breaker nên được giám sát. Fowler khuyên cảnh báo mỗi khi Circuit Breaker mở, ghi log mọi chuyển trạng thái và cho phép vận hành can thiệp bằng tay. Resilience4j xuất metric qua Micrometer, nên bạn có thể đưa trạng thái breaker lên Grafana cùng các metric khác như trong Giám sát Kubernetes với kube-prometheus và Grafana, và liên kết với endpoint kiểm tra sức khoẻ như trong Health endpoint cho Laravel trên Kubernetes. Lưu ý: nếu breaker OPEN làm /actuator/health trả DOWN, readiness probe có thể loại pod khỏi Service và biến sự cố của dịch vụ phụ thành sự cố cả ứng dụng; chỉ nên làm vậy khi dịch vụ đó là bắt buộc.
Bẫy: Bulkhead đẩy Circuit Breaker mở
Kịch bản E trong drive.sh bắn 10 yêu cầu đồng thời vào cổng chậm 400 ms. Kết quả: 3 thành công, các yêu cầu còn lại bị từ chối, và breaker mở dù cổng không hề lỗi. Nguyên nhân: bulkhead nằm trong Circuit Breaker nên BulkheadFullException chạy qua breaker và bị đếm như lỗi. Cuộc gọi bị từ chối vì chính ta giới hạn, chứ không phải vì cổng hỏng. So sánh hai cấu hình trên cùng kịch bản (script drive-bulkhead.sh):
[default] 10 request đồng thời, gateway chậm 400 ms:
3 PAID:SLn
7 PENDING:SLn (BulkheadFullException)
gateway thực nhận: 3 lần
breaker: OPEN | failed 7 | notPermitted 0
[ignore] 10 request đồng thời, gateway chậm 400 ms:
3 PAID:SLn
7 PENDING:SLn (BulkheadFullException)
gateway thực nhận: 3 lần
breaker: CLOSED | failed 0 | notPermitted 0Cấu hình mặc định để Circuit Breaker đếm 7 lỗi và mở, trong khi nếu thêm BulkheadFullException vào ignoreExceptions của breaker, trạng thái giữ nguyên CLOSED và bulkhead chỉ làm đúng việc của nó. Các lần chạy khác nhau cho tỷ lệ bị chặn bởi bulkhead và bởi breaker hơi khác nhau (chúng tôi ghi lại hai lần: 5 bị bulkhead và 2 bị breaker ở drive.sh, rồi 7 bị bulkhead và 0 bị breaker ở bảng trên) vì yêu cầu đến không đồng bộ; hãy test bất biến (cổng nhận không quá 3 lần) thay vì số chính xác.
Khi nào không nên dùng Circuit Breaker, Retry và Bulkhead
- Thao tác không idempotent: không bật Retry khi chưa có idempotency key hoặc cơ chế khử trùng.
- Dịch vụ ít traffic: với
minimumNumberOfCallsmặc định 100, breaker gần như không bao giờ mở; hãy hạ ngưỡng. - Đã có service mesh: Envoy hay Istio đã retry sẵn, lặp lại ở ứng dụng sẽ nhân số lần thử.
Checklist cho Circuit Breaker, Retry và Bulkhead
- Circuit Breaker có
minimumNumberOfCalls, cửa sổ vàwaitDurationInOpenStatephù hợp với traffic thực. - Lỗi 4xx và lỗi nghiệp vụ nằm trong
ignoreExceptions. - Retry chỉ cho thao tác idempotent, dùng backoff luỹ thừa cộng ngẫu nhiên, và bỏ qua
CallNotPermittedException. - Bulkhead giới hạn đồng thời theo năng lực dịch vụ phụ thuộc; cân nhắc bỏ qua
BulkheadFullExceptiontrong Circuit Breaker. - Fallback trả trạng thái trung thực (đang chờ xử lý), không che lỗi nghiệp vụ.
- Có metric và cảnh báo khi Circuit Breaker mở; quyết định rõ có để breaker ảnh hưởng readiness hay không.
Tóm lại, Circuit Breaker chỉ bảo vệ được khi cấu hình đúng: chỉ cần đổi thứ tự hoặc bỏ sót một dòng ignoreExceptions là cổng hỏng nhận gấp ba tải, hoặc Circuit Breaker mở vì lỗi do chính ta gây ra.
Bài tiếp theo
Resilience4j giúp một dịch vụ tự bảo vệ, nhưng khi một nghiệp vụ trải qua nhiều dịch vụ cùng ghi dữ liệu thì cần cách khác để giữ nhất quán. Mời bạn đọc tiếp Saga pattern và Transactional Outbox cho microservice.



