Bài viết này chúng ta sẽ tìm hiểu về Retry pattern trong chuỗi bài viết về resilience4j.
Khi làm việc với các hệ thống phân tán, hãy luôn nhớ một điều rằng chúng ta có thể gặp phải các vấn đề về độ trễ mạng, dịch vụ từ xa không khả dụng hay đang chạy chậm,…những sự cố này có thể ảnh hưởng đến hiệu suất tổng thể của hệ thống.
Nếu một hệ thống có khả năng phục hồi sau những sự cố như vậy sẽ tránh được sự cố domino (sụp đổ xếp tầng liên tiếp, sự cố này kéo theo sự cố khác) khi các dịch vụ giao tiếp với nhau.
Vậy chính xác thì Retry Pattern là gì?
Trong thực tế dù rất ít nhưng cũng có những lúc chúng ta gặp phải tình trạng google hoạt động không ổn định (có thể treo hoặc chậm phản hồi). Nếu chúng ta tiếp tục thử lại gửi yêu cầu vài lần (chẳng hạn nhấn F5 refresh) có thể những lần thử lại sau đó thì google lại phản hồi, đó có thể là sự cố mạng không liên tục và điều này là rất phổ biến. Trong thế giới microservices, chúng ta có thể đang chạy nhiều instance của cùng một dịch vụ để cân bằng tải và tăng tính khả dụng cao. Nếu một trong các instance đó gặp sự cố và nó không phản hồi yêu cầu và chúng ta tiếp tục thử gửi lại yêu cầu thì bộ cân bằng tải có thể chuyển yêu cầu đến một node nào đó khác chứa instance đang hoạt động tốt và phản hồi lại. Vì vậy với tùy chọn thử lại (retry) chúng ta có nhiều cơ hội hơn để nhận được kết quả phản hồi.
Khi nào sử dụng Retry
Thông thường, đó là một trong những trường hợp sau:
- Gửi yêu cầu HTTP đến REST endpoint
- Gọi thủ tục từ xa (RPC) hoặc dịch vụ web
- Đọc và ghi dữ liệu vào cơ sở dữ liệu SQL / NoSQL
- Gửi tin nhắn đến và nhận tin nhắn từ một message brocker (RabbitMQ / ActiveMQ / Kafka,..)
Chúng ta sẽ gọi chung những trường hợp này gọi dịch vụ từ xa (hay sử dụng dịch vụ từ xa – remote call). Có hai tùy chọn khi thực hiện các cuộc gọi dịch vụ từ xa mà kết quả không thành công : một là trả lại lỗi ngay lập tức cho khách hàng (khách hàng ở đây có thể là người dùng hoặc một service-client-side) hoặc thử lại việc gửi yêu cầu. Nếu khi thử lại thành công, khách hàng thậm chí sẽ không biết rằng đã có một sự cố tạm thời vừa xảy ra, điều này khiến trải nghiệm của khách hàng tốt hơn. Quyết định chọn loại tùy chọn nào phụ thuộc vào từng loại lỗi (lỗi tạm thời hoặc vĩnh viễn) và từng trường hợp sử dụng.
Lỗi tạm thời là lỗi mà khi thử lại có khả năng thành công. Ví dụ như bị mất kết nối mạng hoặc hết thời gian chờ (timeout) do dịch vụ từ xa tạm thời không khả dụng.
Lỗi vĩnh viễn là lỗi phần cứng hoặc phản hồi 404, 403, 401 từ API REST,… mà việc thử lại sẽ không hữu ích.
Giả sử dịch vụ từ xa đã nhận và xử lý yêu cầu đến nhưng xảy ra sự cố khi gửi phản hồi. Trong trường hợp đó, khi chúng ta thử lại, chúng ta không muốn dịch vụ từ xa coi yêu cầu này là yêu cầu mới hoặc trả về lỗi không mong muốn (ví dụ như chuyển tiền trong ngân hàng). Việc thử lại có thể không phù hợp với những nghiệp vụ kiểu này.
Việc thử lại sẽ làm tăng thời gian phản hồi của các API. Đây có thể không phải là vấn đề nếu client application là một ứng dụng kiểu như cronjob hoặc ứng dụng chạy nền. Tuy nhiên nếu đó là người dùng thì đôi khi tốt hơn hết là nhanh chóng gửi thông báo phản hồi hơn là bắt người dùng phải đợi trong khi chúng ta tiếp tục thử lại (ví dụ client gọi tới product-service để lấy thông tin sản phẩm, product lại gọi tới rating-service để lấy thông tin đánh giá. Nếu việc gọi tới rating-service bị lỗi chúng ta có thể trả về thông tin sản phẩm ở product-serivce luôn mà ko cần thông tin đánh giá. Trường hợp có hay không có thông tin đánh giá có thể không thật sự cần thiết).
Đối với một số trường hợp khác thì độ tin cậy có thể quan trọng hơn thời gian phản hồi và chúng ta có thể sẽ cần thực hiện thử lại ngay cả khi client là người dùng mà không phải một cronjob hay một ứng dụng chạy nền. Chuyển tiền trong ngân hàng hoặc đại lý đặt vé máy bay du lịch và khách sạn là những ví dụ điển hình – người dùng mong đợi độ tin cậy chứ không phải phản hồi tức thời cho những trường hợp sử dụng như vậy. Chúng ta có thể đáp ứng bằng cách thông báo ngay cho người dùng rằng chúng tôi đã chấp nhận yêu cầu và thông báo cho người dùng biết sau khi hoàn thành.
Sử dụng Recilience4j
RetryRegistry
, RetryConfig
và Retry
là những class chính trong module recoveryience4j-retry
. RetryRegistry
là class tạo và quản lý các đối tượng Retry
. RetryConfig
đóng gói thông tin cấu hình như số lần thử lại, thời gian chờ giữa các lần thử lại…. Mỗi đối tượng Retry
được liên kết với một class RetryConfig
. Chúng ta sẽ trình bày cách sử dụng các tính năng khác nhau có sẵn trong Retry
module bên dưới. Giả sử rằng chúng ta đang xây dựng một trang web cho một hãng hàng không để cho phép khách hàng của họ tìm kiếm và đặt chuyến bay. Dịch vụ của chúng ta giao tiếp với một dịch vụ từ xa, giả sử xử lý này nằm trong class FlightSearchService
.
Cấu hình Retry đơn giản
Thử lại sẽ được thực hiện nếu một RuntimeException
được ném khi thực hiện gọi dịch vụ từ xa. Chúng ta có thể cấu hình số lần thử lại, thời gian chờ giữa các lần thử lại,.. như sau:
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.of(2, SECONDS))
.build();
// Registry, Retry creation omitted
FlightSearchService service = new FlightSearchService();
SearchRequest request = new SearchRequest("NYC", "LAX", "18/11/2021");
Supplier<List<Flight>> flightSearchSupplier =
() -> service.searchFlights(request);
Supplier<List<Flight>> retryingFlightSearch =
Retry.decorateSupplier(retry, flightSearchSupplier);
System.out.println(retryingFlightSearch.get());
Chúng ta tạo RetryConfig
cấu hình thông tin thử lại tối đa 3 lần và khoảng cách giữa những lần thử lại là 2 giây. Nếu chúng ta sử dụng phương thức RetryConfig.ofDefaults()
các giá trị mặc định sẽ là 3 lần thử lại và thời gian chờ giữa các lần thử là 500ms. Chúng ta thể hiện lệnh gọi tìm kiếm chuyến bay dưới dạng biểu thức lambda – Supplier<List<Flight>>
. Phương thức Retry.decorateSupplier()
bổ trợ cho Supplier
chức năng thử lại. Cuối cùng chúng ta gọi phương thức get()
trên Supplier
để thực hiện gọi dịch vụ từ xa.
Chúng ta sử dụng phương thức decorateSupplier()
nếu chúng ta muốn tạo và sử dụng lại nó ở một vị trí khác trong mã nguồn. Còn nếu muốn tạo nó và thực thi nó ngay lập tức, chúng ta sẽ sử dụng phương thức executeSupplier()
để thay thế:
List<Flight> flights = retry.executeSupplier(
() -> service.searchFlights(request));
Output khi thực hiện:
Searching for flights; current time = 20:51:34 975
Operation failed
Searching for flights; current time = 20:51:36 985
Flight search successful
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'}, ...]
Thử lại khi có ngoại lệ xảy ra
Bây giờ, giả sử chúng ta muốn thử lại cho cả hai trường hợp checked và unchecked exception. Giả sử chúng ta đang gọi FlightSearchService.searchFlightsThrowingException()
, phương thức này có thể ném một checked exception. Vì Supplier không thể throw ra một checked exception nên chúng ta sẽ gặp lỗi trình biên dịch trên dòng này:
Supplier<List<Flight>> flightSearchSupplier =
() -> service.searchFlightsThrowingException(request);
Chúng ta có thể xử lý exception trong biểu thức lambda và trả về Collections.emptyList()
để chương trình không lỗi, nhưng điều này có vẻ sẽ không ổn vì chúng ta đang tự bắt Exception nên việc thử lại không hoạt động nữa:
Supplier<List<Flight>> flightSearchSupplier = () -> {
try {
return service.searchFlightsThrowingException(request);
} catch (Exception e) {
// don't do this, this breaks the retry!
}
return Collections.emptyList();
};
Vậy câu hỏi là chúng ta nên làm gì khi chúng ta muốn thử lại ngay cả khi việc gọi từ xa sang dịch vụ khác ném ra tất cả các loại exception? Chúng ta có thể sử dụng phương thức Retry.decorateCheckedSupplier()
hoặc phương thức executeCheckedSupplier()
thay Retry.decorateSupplier()
:
CheckedFunction0<List<Flight>> retryingFlightSearch =
Retry.decorateCheckedSupplier(retry,
() -> service.searchFlightsThrowingException(request));
try {
System.out.println(retryingFlightSearch.apply());
} catch (...) {
// việc xử lý exception này sẽ xảy ra khi chương trình thực hiện hết số lần thử lại mà ta đã cấu hình
}
Retry.decorateCheckedSupplier()
trả về CheckedFunction0
đại diện cho một hàm không có đối số. Lưu ý việc gọi phương thức apply()
trên đối tượng CheckedFunction0
mới thực sự gọi dịch vụ từ xa. Nếu chúng ta không muốn làm việc với Supplier, Retry
cung cấp thêm các phương thức bổ trợ khác như decorateFunction(), decorateCheckedFunction(), decorateRunnable(), decorateCallable()
,… Sự khác biệt giữa decorate*
và decorateChecked*
là decorate*
thực hiện retry khi xảy ra RuntimeException
và decorateChecked*
thực hiện retry khi xảy ra Exception
.
Mình sẽ dành thời gian viết một bài về các loại Exception sau.
Thử lại theo điều kiện
Ví dụ bên trên cho thấy cách mà chương trình thử lại khi chúng ta nhận được RuntimeException
hoặc một checked Exception
khi gọi một dịch vụ từ xa. Trong các ứng dụng thực tế, chúng ta có thể không muốn retry đối với tất cả các trường hợp exception. Ví dụ: nếu chúng ta nhận được AuthenticationFailedException
thì việc thử lại cùng một yêu cầu sẽ không hữu ích vì sẽ gặp phải phản hồi tương tự (vì bản chất request gọi không có quyền truy cập dịch vụ). Khi thực hiện cuộc gọi HTTP, chúng ta có thể muốn kiểm tra mã trạng thái phản hồi HTTP hoặc kiểm tra thông điệp cụ thể trong phản hồi để quyết định xem chúng ta có nên thử lại hay không. Hãy xem cách triển khai các lần thử lại có điều kiện như vậy.
Thử lại có điều kiện theo thông tin response trả về
Giả sử rằng dịch vụ bay của hãng hàng không thường xuyên khởi tạo dữ liệu chuyến bay trong cơ sở dữ liệu của hãng. Thao tác nội bộ này mất vài giây cho dữ liệu chuyến bay của một ngày nhất định. Khi chúng ta gọi dịch vụ và tìm kiếm chuyến bay cho ngày hôm đó trong khi quá trình khởi tạo này đang diễn ra, dịch vụ sẽ trả về mã lỗi cụ thể FS-167 trong kết quả tìm kiếm trả về, điều này cho biết đây là lỗi tạm thời và thao tác có thể được thử lại sau vài giây.
RetryConfig config = RetryConfig.<SearchResponse>custom()
.maxAttempts(3)
.waitDuration(Duration.of(3, SECONDS))
.retryOnResult(searchResponse -> searchResponse
.getErrorCode()
.equals("FS-167"))
.build();
Chúng ta sử dụng phương thức retryOnResult()
và tham số kiểu Predicate<T>
cho việc thực hiện kiểm tra này. Logic trong Predicate này có thể phức tạp hay đơn giản tùy vào logic chúng ta muốn – nó có thể là kiểm tra đối với mã lỗi hoặc nó có thể là một nghiệp vụ tùy chỉnh để quyết định xem có nên thử lại hay không.
Khái niệm để mọi người hiểu nhanh về Predicate<T>
. Predicate<T>
là một functional interface, do đó nó có thể được sử dụng với lambda expression hoặc method reference cho một mục đích cụ thể nào đó. Predicate sẽ trả về giá trị true/false của một tham số kiểu T mà chúng ta đưa vào có thỏa với điều kiện của Predicate đó hay không, cụ thể là điều kiện được viết trong phương thức test()
. Ví dụ:
// Predicate String
Predicate<String> predicateString = s -> {
return s.equals("gpcoder");
};
System.out.println(predicateString.test("gpcoder")); // true
System.out.println(predicateString.test("GP Coder")); // false
// Predicate integer
Predicate<Integer> predicateInt = i -> {
return i > 0;
};
System.out.println(predicateInt.test(1)); // true
System.out.println(predicateInt.test(-1)); // false
Thử lại có điều kiện dựa trên loại exception
Giả sử chúng ta có một exception chung FlightServiceBaseException
được throw ra khi có bất kỳ lỗi gì trong quá trình tương tác với flight-service. Theo chính sách chung, chúng ta muốn thử lại khi exception xảy ra. Nhưng có một lớp con của class SeatsUnavailableException
là ngoại lệ mà chúng ta không muốn thử lại (nếu như không còn chỗ trên chuyến bay, việc thử lại sẽ vô ích). Chúng ta có thể làm điều này bằng cách tạo RetryConfig
như sau:
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.of(3, SECONDS))
.retryExceptions(FlightServiceBaseException.class)
.ignoreExceptions(SeatsUnavailableException.class)
.build();
Trong retryExceptions()
, chúng ta chỉ định một danh sách các exception. Resilience4j sẽ thử lại bất kỳ exception nào phù hợp hoặc kế thừa từ các exception trong danh sách này. Chúng ta đặt những exception muốn bỏ qua và không thử lại vào ignoreExceptions()
. Nếu chương trình ném ra một số exception khác trong khi chạy, chẳng hạn như IOException, nó cũng sẽ không được thử lại.
Giả sử rằng đối với một exception nhất định, chúng ta không muốn thử lại trong mọi trường hợp. Có thể chúng ta chỉ muốn thử lại nếu exception có mã lỗi cụ thể hoặc một thông điệp nhất định trong thông báo của exception. Chúng ta có thể sử dụng phương thức retryOnException()
trong trường hợp đó:
Predicate<Throwable> rateLimitPredicate = rle ->
(rle instanceof RateLimitExceededException) &&
"RL-101".equals(((RateLimitExceededException) rle).getErrorCode());
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.of(1, SECONDS))
.retryOnException(rateLimitPredicate)
build();
Như trong Predicate bên trên, việc kiểm tra điều kiện thử lại có thể phức tạp tùy theo nghiệp vụ.
Chiến lược dự phòng khi thực hiện thử lại
Trong các ví dụ trên chúng ta tạo thời gian chờ cố định để thử lại. Thông thường, chúng ta muốn tăng thời gian chờ sau mỗi lần thử lại thất bại, điều này nhằm cho dịch vụ từ xa đủ thời gian để khôi phục trong trường hợp hiện tại nó đang bị quá tải. Chúng ta có thể làm điều này bằng cách sử dụng IntervalFunction
. IntervalFunction
là một function interface, đó là một hàm nhận đối số và trả về thời gian chờ tính bằng mili giây.
Thời gian chờ ngẫu nhiên giữa những lần thử lại
Ở đây chúng ta chỉ định thời gian chờ ngẫu nhiên giữa các lần thử:
RetryConfig config = RetryConfig.custom()
.maxAttempts(4)
.intervalFunction(IntervalFunction.ofRandomized(2000))
.build();
IntervalFunction.ofRandomized()
có một randomizationFactor()
được liên kết với nó. RandizationFactor
này xác định phạm vi mà giá trị ngẫu nhiên sẽ trả về. Giá trị được tạo ra sẽ nằm trong khoảng mà công thức là: X - X * 0.5
tính ra. Trong ví dụ trên là 1000ms (2000 – 2000 * 0,5), với 3000ms là (2000 + 2000 * 0,5).
Ví dụ output:
Searching for flights; current time = 20:27:08 729
Operation failed
Searching for flights; current time = 20:27:10 643
Operation failed
Searching for flights; current time = 20:27:13 204
Operation failed
Searching for flights; current time = 20:27:15 236
Flight search successful
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'},...]
Thời gian chờ tăng theo cấp số nhân giữa những lần thử lại
Đối với việc tăng thời gian chờ theo cấp số nhân, chúng ta chỉ định hai giá trị – thời gian chờ ban đầu và hệ số nhân
. Trong phương pháp này, thời gian chờ tăng lên theo cấp số nhân giữa các lần thử lại. Ví dụ: nếu chúng ta chỉ định thời gian chờ ban đầu là 1 giây và hệ số nhân là 2, thì việc thử lại sẽ được thực hiện sau 1 giây, 2 giây, 4 giây, 8 giây, 16 giây, v.v. Phương pháp này là một cách tiếp cận được khuyến nghị khi phía client là một ứng dụng chạy nền.
RetryConfig config = RetryConfig.custom()
.maxAttempts(6)
.intervalFunction(IntervalFunction.ofExponentialBackoff(1000, 2))
.build();
Ví dụ output:
Searching for flights; current time = 20:37:02 684
Operation failed
Searching for flights; current time = 20:37:03 727
Operation failed
Searching for flights; current time = 20:37:05 731
Operation failed
Searching for flights; current time = 20:37:09 731
Operation failed
Searching for flights; current time = 20:37:17 731
Thử lại khi các cuộc gọi dịch vụ từ xa là bất đồng bộ
Các ví dụ bên trên đều là các lệnh gọi đồng bộ. Bây giờ chúng ta hãy xem cách thử lại các lệnh gọi động không đồng bộ. Giả sử chúng ta đang gửi yêu cầu tìm kiếm các chuyến bay (gọi không đồng bộ) như sau:
CompletableFuture.supplyAsync(() -> service.searchFlights(request))
.thenAccept(System.out::println);
Lệnh gọi searchFlight()
xảy ra trên một luồng xử lý khác và khi nó trả về List<Flight>
sẽ được chuyển đến thenAccept()
. Chúng ta có thể thực hiện thử lại các lệnh gọi không đồng bộ như trên bằng cách sử dụng phương thức executeCompletionStage()
trên đối tượng Retry
. Phương thức này nhận hai tham số, một là ScheduledExecutorService
mà quá trình thử lại sẽ được lên lịch và một là Supplier<CompletionStage>
. Phương thức này thực thi một CompletionStage
sau đó trả về một CompletionStage
và chúng ta có thể gọi thenAccept()
như trước đó để lấy kết quả:
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
Supplier<CompletionStage<List<Flight>>> completionStageSupplier =
() -> CompletableFuture.supplyAsync(() -> service.searchFlights(request));
retry.executeCompletionStage(scheduler, completionStageSupplier)
.thenAccept(System.out::println);
Trong ứng dụng thực tế, chúng ta nên sử dụng cách chia sẻ nhiều luồng xử lý trong một nhóm (Executors.newScheduledThreadPool()
) để thử lại thay vì thực thi một luồng như bên trên.
Các event khi thực hiện thử lại
Trong tất cả các ví dụ trên chúng ta không biết khi nào một lần thử lại là thành không thành công. Giả sử đối với một yêu cầu nhất định, chúng ta muốn ghi lại một số thông tin chi tiết như số lần thử lại hoặc thời gian chờ cho đến lần thử tiếp theo. Chúng ta có thể làm điều đó bằng cách sử dụng Retry
với EventPublisher
tại các thời điểm thực thi khác nhau. EventPublisher
có các phương thức như onRetry()
, onSuccess()
,…
Retry.EventPublisher publisher = retry.getEventPublisher();
publisher.onRetry(event -> System.out.println(event.toString()));
publisher.onSuccess(event -> System.out.println(event.toString()));
Tương tự, RetryRegistry
cũng có EventPublisher
xuất bản các sự kiện khi các đối tượng Retry
được thêm vào hoặc xóa khỏi RetryRegistry
.
Các số liệu quan trọng khi thực hiện thử lại có thể chúng ta quan tâm
Retry
duy trì một bộ đếm để theo dõi số lần những hoạt động sau xảy ra:
- Thành công trong lần gọi dịch vụ từ xa đầu tiên
- Thành công sau khi thử lại
- Không thành công mà không thử lại
- Không thành công ngay cả sau khi thử lại
Nó cập nhật các bộ đếm này mỗi khi được thực thi.
Tại sao nên nắm được thông tin các số liệu đo lường này?
Việc nắm bắt và thường xuyên phân tích các chỉ số này có thể cung cấp cho chúng ta thông tin chi tiết về hoạt động của các dịch vụ. Nó cũng có thể giúp xác định việc tắc nghẽn và các vấn đề tiềm ẩn khác. Ví dụ: nếu chúng ta nhận thấy rằng một hoạt động thường không thành công trong lần thử đầu tiên, chúng ta có thể xem xét nguyên nhân của nó là gì. Nếu chúng ta nhận thấy rằng các yêu cầu của chúng ta đang bị chặn lại hoặc chúng ta phải chờ lâu cho đến khi thiết lập kết nối, điều đó có thể cho thấy rằng dịch vụ từ xa cần thêm tài nguyên hoặc dung lượng vì nó đang quá tải.
Làm sao để lấy được thông tin các số liệu này?
Resilience4j sử dụng Micrometer để xuất bản số liệu. Micrometer cung cấp thông tin cho các thiết bị đo đạc cho các hệ thống giám sát như Prometheus, Azure Monitor, New Relic… Vì vậy, chúng ta có thể xuất bản các số liệu cho bất kỳ hệ thống nào trong số này. Đầu tiên, chúng ta tạo RetryConfig
, RetryRegistry
và Retry
như bình thường. Sau đó, chúng ta tạo một MeterRegistry
và liên kết RetryRegistry
với nó:
MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry);
Sau khi chạy, các thao tác có thể thử lại một vài lần và chúng ta có thể in ra các số liệu đã thu được:
Consumer<Meter> meterConsumer = meter -> {
String desc = meter.getId().getDescription();
String metricName = meter.getId().getTag("kind");
Double metricValue = StreamSupport.stream(meter.measure().spliterator(), false)
.filter(m -> m.getStatistic().name().equals("COUNT"))
.findFirst()
.map(m -> m.getValue())
.orElse(0.0);
System.out.println(desc + " - " + metricName + ": " + metricValue);
};
meterRegistry.forEachMeter(meterConsumer);
Ví dụ output:
The number of successful calls without a retry attempt - successful_without_retry: 4.0
The number of failed calls without a retry attempt - failed_without_retry: 0.0
The number of failed calls after a retry attempt - failed_with_retry: 0.0
The number of successful calls after a retry attempt - successful_with_retry: 6.0
Trong ứng dụng thực, chúng ta sẽ gửi dữ liệu này sang hệ thống giám sát để có giao diện theo dõi tổng quan.
Tổng kết
Và trên đây là cách chúng ta thiết lập Resilience4j – Retry pattern. Hi vọng mọi người hiểu được ý tưởng của pattern này và áp dụng vào dự án một cách tốt nhất.
Thanks for watching!
Bài gốc:https://thenewstack.wordpress.com/2021/11/20/msdp-resilience4j-retry-pattern/
Follow me: thenewstack
Nguồn: viblo.asia