Cái tên không phải ngẫu nhiên: circuit breaker đúng là cái aptomat trong tủ điện nhà bạn. Chập điện một chỗ thì aptomat ngắt, cắt dòng để cái lỗi đó không đốt cháy cả đường dây và thiết bị phía sau — rồi bạn không gạt lại được ngay, phải đợi, gạt thử, chập lại thì nó ngắt tiếp. Điều đáng nhớ là nó bảo vệ cái phía sau nó, không chỉ bảo vệ nguồn. Phần 23 kết bằng một câu: timeout không dừng được việc đang chạy, nên nó không giảm tải. Bài này đo thứ duy nhất làm được điều đó.
Tình huống
Một dịch vụ hạ nguồn treo — còn sống, nhận kết nối, nhưng không bao giờ trả lời. Đây là kiểu hỏng khó chịu nhất, và phần 11 đo được rằng nếu không có phép chờ thì nó làm mọi thứ về không.
Ta so hai cách, cùng 30 request, phép chờ 500 ms cho cả hai:
| Kết quả | Tổng thời gian | Số lần thật sự chạm hạ nguồn | |
|---|---|---|---|
| Chỉ có timeout | 0 thành công / 30 lỗi | 15,1 s | 30 / 30 |
| Có circuit breaker | 0 thành công / 30 lỗi | 2,5 s | 5 / 30 |
Cả hai đều thất bại hết — hạ nguồn đang chết, không ai cứu được điều đó. Khác biệt nằm ở hai cột phải:
Nhanh hơn sáu lần khi báo thất bại. Sau năm lần hỏng, mạch mở và mọi lời gọi sau đó thất bại ngay lập tức thay vì chờ đủ 500 ms.
Đập vào hạ nguồn ít hơn sáu lần. Đây mới là điều quan trọng. Một dịch vụ đang ốm cần được để yên để hồi phục; ba mươi request nữa dội vào là cách chắc chắn để nó không bao giờ đứng dậy nổi.
Ba trạng thái, đo bằng chính máy chủ
CircuitBreaker cb = CircuitBreaker.create("ha-nguon", vertx, new CircuitBreakerOptions()
.setMaxFailures(5)
.setTimeout(500)
.setResetTimeout(2000));
hạ nguồn chết -> trạng thái đã đi qua: [MỞ]
hạ nguồn sống lại, sau resetTimeout -> [ĐÓNG], 20/20 thành công, trung vị 1 ms
Đóng là bình thường. Đủ maxFailures lần hỏng thì sang mở — mọi lời gọi bị từ chối ngay, không chạm tới hạ nguồn. Sau resetTimeout, mạch sang nửa mở: cho một lời gọi thăm dò đi qua. Thành công thì đóng lại, hỏng thì mở tiếp.
Phép đo cho thấy vòng hồi phục hoạt động đúng: hạ nguồn sống lại, đợi qua resetTimeout, và 20 request tiếp theo thành công hết với độ trễ 1 ms.
Ba mặc định nên đổi ngay
maxFailures mặc định = 5
timeout mặc định = 10 000 ms
resetTimeout mặc định = 30 000 ms
timeout mười giây dài hơn phép chờ của gần như mọi client đứng trước bạn. Đặt nó theo ngân sách độ trễ thật, thường là vài trăm mili giây — và phần 23 nhắc rằng phép chờ phải giảm dần từ ngoài vào trong.
resetTimeout ba mươi giây nghĩa là sau khi hạ nguồn hồi phục, bạn còn phải chờ tới nửa phút nữa mới thử lại. Với dịch vụ hay chớp tắt, con số vài giây hợp lý hơn.
maxFailures bằng 5 hợp lý cho lưu lượng thấp, nhưng ở mười nghìn request mỗi giây thì năm lần hỏng xảy ra trong nửa mili giây — mạch sẽ mở vì một trục trặc thoáng qua. Cân nhắc tăng lên, hoặc dùng setFailuresRollingWindow.
Fallback chỉ chạy khi mạch đã mở
lần 1: null sau 301 ms
lần 2: null sau 301 ms
lần 3: gia-tri-du-phong sau 0 ms
lần 4: gia-tri-du-phong sau 0 ms
Hai lần đầu vẫn thất bại thật; chỉ từ lần thứ ba — khi mạch đã mở — fallback mới trả về giá trị dự phòng, và trả tức thì.
Lý do nằm ở một mặc định: isFallbackOnFailure = false. Muốn fallback che cả những lần hỏng trước khi mạch mở thì phải bật nó lên:
new CircuitBreakerOptions().setFallbackOnFailure(true);
Đây là lựa chọn thật sự: bật thì người dùng không bao giờ thấy lỗi, nhưng bạn cũng khó nhận ra hạ nguồn đang hỏng vì mọi thứ trông vẫn "thành công".
Đặt ở đâu
Một breaker cho mỗi phụ thuộc, không phải một cái dùng chung. Nếu dịch vụ thanh toán ốm thì lời gọi sang dịch vụ kho không việc gì phải bị chặn — và đó cũng là ý tưởng của bulkhead ở bài 31.
Bọc lời gọi ra ngoài, đừng bọc logic nội bộ. Circuit breaker dành cho thứ có thể ốm độc lập với bạn.
Ghi log mỗi lần đổi trạng thái. openHandler bắn đúng lúc bạn cần biết, và nó là tín hiệu sớm hơn mọi biểu đồ độ trễ:
cb.openHandler(v -> log.error("MACH MO: {} dang hong", ten))
.closeHandler(v -> log.info("MACH DONG: {} da hoi phuc", ten));
Hai dòng này biến circuit breaker thành hệ thống cảnh báo sớm của bạn. Nếu log của bạn chưa bao giờ có dòng nào như vậy, hoặc là mọi thứ đang rất tốt — hoặc là bạn chưa bọc lời gọi nào cả.
Mẫu số chung
Điều phản trực giác nhất: thứ tử tế nhất bạn làm được cho một dịch vụ đang ốm là để yên nó. Cứ thử lại và dội request vào một hạ nguồn đang gục biến một trục trặc thoáng qua thành một sự cố kéo dài — đây đúng là "retry storm" / sụp đổ do tắc nghẽn (congestion collapse) mà dân vận hành hệ lớn đều sợ: hệ vào một trạng thái xấu ổn định, tự nó không thoát ra được vì chính lưu lượng thử-lại giữ nó ở đó. Circuit breaker cắt vòng đó bằng cách ngừng gọi và chừa chỗ cho hạ nguồn thở — cùng một cơ chế trong Resilience4j/Hystrix của Java, Polly của .NET, gobreaker của Go, hay outlier detection của Envoy/Istio. Nó là bà con gần với áp-lực-ngược ở phần ống dẫn dữ liệu: cả hai đều là cách một hệ tự bảo vệ khỏi chính đà của mình.
Điều thứ hai: hai lớp phòng vệ trông giống nhau có thể canh hai phía đối diện — timeout chặn cái chờ của chính bạn (bảo vệ bên gọi), breaker chặn bạn nhấn chìm bên kia (bảo vệ bên bị gọi) — nên đừng chọn một trong hai, chúng bù cho nhau và bạn cần cả chồng (timeout, retry có chừng, breaker, bulkhead, fallback). Nhưng mỗi lớp có cái giá của nó, và fallback là ví dụ sắc nhất: bật fallbackOnFailure thì người dùng không bao giờ thấy lỗi — nghe tuyệt, cho tới khi bạn nhận ra mình cũng mù luôn trước việc hạ nguồn đang chết vì mọi thứ trông vẫn "thành công". Che lỗi cho người dùng và giấu lỗi khỏi chính mình là hai mặt của một hành động; nên khi bật nó, phải cảnh báo trên trạng thái mạch (openHandler), không trên tỉ lệ lỗi thấy được — lại đúng bài học đo-triệu-chứng-thật, nhìn từ một góc khác.
Bài sau: Vert.x Config — nhiều nguồn cấu hình, thứ tự ưu tiên, và một dòng thiếu làm việc nạp nóng không bao giờ chạy.