[Design Patterns] Tìm hiểu về kiến trúc Circuit Breaker trong thiết kế phần mềm.
Circuit Breaker dịch một cách thô thiển gọi là cái “cầu giao”, nhiệm vụ chính của nó là ngắt mạch khi hệ thống có vấn đề “gì đó” để tránh hệ thống quá tải dẫn đến các thành phần trong hệ thống bị sụp đổ theo dẫn đến một thảm hoạ thác (cascading failures or catastrophic cascade).
Trong lập trình cũng vậy, trong một môi trường phân tán (distributed environment) việc gặp lỗi khi triệu gọi những service từ bên ngoài hệ thống (remote calls) như kết nối mạng bị chậm (cá mật cắn cáp 😀 ), hay thời gian service trả về quá lâu (timeouts) hoặc service đó đang bị quá tải (overload) hoặc tạm thời không khả dụng (temporarily unavailable).
Bạn đọc đến đấy có thể nghĩ “có cái quái gì đâu mà phải làm to chuyện, fail thì retry khi nào được thì thôi, có cái gì?”
Nhưng đời không như mơ, tình không như phim Hàn Cuốc các bạn ạ 😀 , hãy tưởng tượng việc hệ thống bên ngoài (external services) mà chúng ta triệu gọi bị down quá lâu, mà hệ thống của chúng ta lại có quá nhiều requests gọi đến nó mà mỗi request lại có một khoảng thời gian chờ (timeout) dẫn đến rất nhiều các request khác bị chặn lại (blocking) cho đến hết thời gian chờ (timeout), những blocking request này có thể chứa những tài nguyên của hệ thống khác (system resources) như memory, threads, database connections … dẫn đến các tài nguyên này nhanh chóng bị cạn kiệt dẫn đến các hệ thống khác không liên quan bị sụp đổ theo (vãi nồi 😀 ).
Trong tình huống này đòi hỏi hệ thống phải có cơ chế kiểm tra, đánh giá, ngăn chặn và trả về lỗi ngay tức thì và chỉ cho phép các “remote calls” thực thi khi hệ thống bên ngoài hết lỗi và đã sẵn sàng, và cơ chế đó trong thiết kế phần mềm gọi là “Circuit Breaker”.
Trong cuốn Release It! của Michael T. Nygard ông ta cũng nhắc đến Circuit Breaker như mà một biện pháp để tránh hệ thống bị một thảm hoạ thác như bên trên đề cập (catastrophic cascade)
OK vậy chúng ta đã hiểu Circuit Breaker là gì và vì sao chúng ta cần đến nó, giờ tiếp tục với việc thiết kế nó ra sao. Let Go!!!
Tư tưởng của Circuit Breaker là quản lý lỗi và theo dõi số lần lỗi xảy ra trong 1 khoảng thời gian và sử dụng thông tin này để quyết định xem có cho phép chương trình tiếp tục hay “ngắt mạch” ngay lập tức và trả về “exception”.
Nó giống như ngắt cầu giao vậy, khi cầu giao đã mở (open) thì toàn bộ chương trình sẽ không được phép gọi đến những external services nữa và trả về lỗi ngay tức thì. Và sau 1 khoảng thời gian “cầu giao” sẽ mở hé ra (half-open) để một vài remote calls được thực thi, nếu mọi thứ ổn tức các external services hoạt động bình thường thì toàn bộ “cầu giao” sẽ đóng lại (close) để cho chương trình có thể kết nối lại bình thường, còn nếu không cầu giao lại đóng lại ngay lập tức.
Việc quyết định này sẽ dựa theo 3 trạng thái (state) của Circuit Breaker như sau:
- CLOSED: Đây là trạng thái đóng, khi ở trạng thái này toàn bộ chương trình hoạt động bình thường, các remote calls vẫn được phép triệu gọi, nhưng khi một remote call nào đó bị Fail thì bộ đếm lỗi của CB (Circuit Breaker) kích hoạt và tăng lên 1 đơn vị. Ở đây bộ đếm lỗi (error counter) mục đích để xác định số lần fail tối đa mà hệ thống cho phép, nếu vượt quá thì CB sẽ thực hiện mở ra trạng thái OPEN.
- OPEN: Ở trạng thái này các request từ ứng dụng sẽ bị Fail ngay tức thì và exception sẽ được trả về cho ứng dụng, các services từ bên ngoài sẽ bị cô lập với chương trình của chúng ta.
- HALF-OPEN: Nếu CB đang ở trạng tái OPEN sau một khoảng thời gian timeout nào đó mà chúng ta cài đặt thì CB sẽ chuyển về trạng thái HALF-OPEN (mở hé 😀 ) và sẽ cho phép một vài remote calls đến từ chương trình, nếu các external services hoạt động ổn thì CB sẽ chuyển về trạng thái CLOSED, còn nếu không thì CB sẽ truyển trạng thái sang OPEN và reset lại error counter. Trạng thái này hữu ích cho việc từ từ kiểm tra các remote calls đã hoạt động được không mà tránh việc mở ra ồ ạt các remote calls bằng cách chỉ cho phép một vài request đi.
Ngoài ra Circuit Breaker còn rất hữu dụng cho việc monitoring hay health check hệ thống, bất cứ khi trạng thái CB được OPEN sẽ cảnh báo cho người quản trị theo dõi và có các biện pháp xử lý nếu cần.
Hệ thống sẽ trở nên ổn định hơn rất nhiều nếu chúng ta biết cách tuỳ biến và sử dụng CB một cách uyển chuyển ví dụ thay về trả về lỗi khi trạng thái CB là OPEN thì ta trả về một giá trị mặc định nào đó, hay thời gian timeout khi trạng thái OPEN mặc định ban đầu ít thôi (khoảng vài giây) nếu remote calls tiếp tục lỗi ta lại tăng timeout timer lên một chút nữa, nhưng cũng không nên tăng lên quá lâu, hoặc có màn hình monitor để người quản trị có thể tuỳ biến “timeout timer” của hệ thống v.v…
Nghe có vẻ lợi hại nhỉ? Nhưng tóm lại khi nào thì nên áp dụng Circuit Breaker
- Nên: và chỉ nên sử dụng ở các ứng dụng gọi đến các dịch vụ bên ngoài hệ thống (remote service) hoặc các tài nguyên được chia sẻ (shared resource) với tần suất lớn và có khẳ năng bị FAIL.
- Không nên:
+ Cho việc quản lý ứng dụng gọi đến các tài nguyên nằm trong mạng của hệ thống (local private resource) ví dụ như in-memory data hay internal services, với trường hợp này áp dụng Circuit Breaker chỉ làm cho hệ thống của bạn bị tốn tài nguyên hay overhead chứ chẳng có tác dụng gì.
+ Hay dùng nó thay thế việc quản lý lỗi trong nghiệp vụ của ứng dụng của bạn cũng là điều không nên.
Một vài chú ý khi sử dụng Circuit Breaker:
- Exception: CB sử dụng exception để quyết định có OPEN hay không, nhưng trong hệ thống thật chúng ta có 1 tỷ 9 vạn các exception, không phải cứ exeption nào CB cũng sẽ xử lý, mà ta chỉ nên chỉ định một vài exception cụ thể nào đó để kích hoạt CB, ví dụ như timeout hay service unavailable exception.
- Recoverability:
+ CB không được ở trạng thái OPEN quá lâu, không được để trường hợp external services đã hoạt động ổn mà CB vẫn ở lỳ trạng thái OPEN, CB cần phải chuyển trạng thái OPEN sang HALF-OPEN thật nhanh.
+ Trong trường hợp external service bị down (service unavailable) CB cũng có thể tự mình ping đến các external service để kiểm tra nếu ổn sẽ mở sang trạng thái HALF-OPEN một cách nhanh nhất mà không phải chờ đợi hết CB timeout.
+ Nhưng trong trường hợp external service đang bị quá tải (Too Many Requests) thì việc mở HALF-OPEN lại chẳng có ích gì, tốt nhất trong trường hợp này timeout nên để dài hơn thường lệ (vài phút chẳng hạn).
+ Tuyệt với nhất là có màn hình monitor giúp cho việc người quản trị hệ thống có thể trong một vài tình huống có thể ghi đè hoặc tự chuyển tạm thời trạng thái của CB bằng tay (manual overwite).
+ Tóm lại việc quản lý trạng thái và timeout phải thông minh và uyển chuyển trong từng tình huống để CB hoạt động một cách hiệu quả nhất.
— Clustering: Cẩn thận khi sử dụng CB với các hệ thống chạy clustering, nếu chỉ một node trong hệ thống bị down nhưng các node khác vẫn chạy tốt mà CB lại được kích hoạt blocking hết các request thì đó quả là một điều cực kỳ ngớ ngẩn.
— Concurrency: CB sẽ là nơi tập chung một lượng lớn request từ hệ thống, cho nên việc thiết kế asynchronous và unblocking cho CB để không làm ảnh hưởng đến performance là điều rất quan trọng.
- Logging: CB nên log toàn bộ những fail request hay trạng thái hệ thống để người quản trị có thể nhận biết được tình trạng của hệ thống.
OK! Lý thuyết có lẽ đã đủ, giờ tôi sẽ minh hoạt một Circuit Breaker cực kỳ đơn giản và chân phương bằng Java nhé.
Giải thích lớp CircuitBreaker bên trên tôi vừa implement nhé.
Đầu tiên ta sẽ có một enum BreakerState để lưu 3 trạng thái của CB là OPEN, HALF_CLOSED và CLOSED rất rõ ràng không cần giải thích gì thêm
Tiếp theo là các tham số của lớp
Lần lượt tôi sẽ có biến
— state: để lưu trạng thái hiện tại của CB, tôi gán nó với kiểu volatile để đồng bồ hoá biến đó, có nghĩa là trong 1 thời điểm chỉ duy nhất 1 tiến trình (thread) được phép thay đổi nó, tránh việc state bị update loạn xới khi hệ thống đang chạy đa luồng. Mặc định state luôn là CLOSED
— numberFailure: là bộ điếm lỗi (fail counter) của CB, cứ quá số lần lỗi là CB OPEN ngay.
— lastFailure: Lưu thời điểm lỗi cuối cùng, dùng để tính toán timeout để tự động chuyển trạng thái từ sang HALF_OPEN.
— resetMillis: Đây chính là thời gian timeout của CB.
— limit: số lần lỗi tối đa trước khi chúng ta OPEN CB.
Tôi có hai hàm open và close state của CB, với việc open thì ta làm thêm việc reset lastFailure và numberFailure.
Hàm allowRequest hàm này có tác dụng check nếu CB đang trong trạng thái OPEN mà timeout đã hết thì chuyển trạng thái CB sang HALF_OPEN.
Tiếp theo là hàm handleFailure, hàm này để lọc các Exeption hợp lệ để tăng bộ đếm lỗi, nếu bộ đếm đã tới limit thì mở trạng thái OPEN còn không thì trạng thái CB vẫn tiếp tục là CLOSED
Ở đây có thể thấy đầu vào của tôi là một Throwable class và tôi kiểm tra nó có phải là thuộc lớp ServiceUnavailableException hay không? Vậy bạn sẽ thắc mắc cái lớp ServiceUnavailableException là cái quái gì? Ở đây tôi dùng một trick nhỏ để lọc các Exeption hợp lệ bằng cách tạo một class ServiceUnavailableException như bên dưới, ở bên ngoài ứng dụng mỗi khi gặp một lỗi hợp lệ muốn kích hoạt CB thì ta chỉ cần throw ra lớp ServiceUnavailableException là được.
Cuối cùng sẽ là linh hồn của Circuit Breake đó là hàm invoke, ý tưởng ở đây là tạo ra một hàm với đầu vào là một Callable và đầu ra (return) sẽ tuỳ vào việc ứng dụng trả ra cái gì, ứng dụng sẽ gọi đến hàm invoke của CB và truyền vào hàm gọi đến external services bên ngoài. Java tuy là một ngôn ngữ mạnh về OOP nhưng phương pháp này có chút gì gợi nhớ đến declarative programming nhỉ 😀 .
Bạn cũng có thể thấy, trước khi cho phép ứng dụng làm việc, CB luôn check xem trạng thái CB đang là gì, nếu CLOSED hoặc HALF_OPEN mới cho đi tiếp (bằng việc dùng hàm allowRequest()) sau đó sẽ handle lỗi từ ứng dụng bằng hàm handleFailure() như người lớn he he 😀 .
Từ phía ứng dụng nếu muốn bảo vệ bằng CB thì ta làm như sau:
DONE! Vậy ta đã cài đặt xong một Circuit Breaker đơn giản nhất, ta có thể nâng cấp (turning) lên để đáp ứng được các chú ý khi sử dụng Circuit Breaker tôi đã đề cập bên trên… nhưng mà đừng làm thế nhé, tội gì mà phải sáng tạo lại cái bánh xe, ta có rất nhiều thư viện hỗ trợ tận răng, thâm chí có cả giao diện quản lý cực kỳ cool ngầu cho CB, ví dụ như Netflix Hystrix hay Resilience4j nhé
Link tham khảo:
https://martinfowler.com/bliki/CircuitBreaker.html
https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker