«1. Обзор

В этом руководстве мы рассмотрим, как мы можем улучшить количество повторных попыток клиента с помощью двух разных стратегий: экспоненциальная отсрочка и дрожание.

2. Повторите попытку

В распределенной системе сетевое взаимодействие между многочисленными компонентами может прерваться в любой момент. Клиентские приложения обрабатывают эти сбои, реализуя повторные попытки.

Предположим, у нас есть клиентское приложение, которое вызывает удаленную службу — PingPongService.

interface PingPongService {
    String call(String ping) throws PingPongServiceException;
}

Клиентское приложение должно повторить попытку, если PingPongService возвращает исключение PingPongServiceException. В следующих разделах мы рассмотрим способы реализации повторных попыток клиента.

3. Повторная попытка Resilience4j

В нашем примере мы будем использовать библиотеку Resilience4j, в частности ее модуль повторной попытки. Нам нужно добавить модуль resilience4j-retry в наш pom.xml:

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-retry</artifactId>
</dependency>

Чтобы освежить в памяти использование повторных попыток, не забудьте ознакомиться с нашим руководством по Resilience4j.

4. Экспоненциальная отсрочка

Клиентские приложения должны ответственно реализовывать повторные попытки. Когда клиенты без ожидания повторяют неудачные вызовы, они могут перегрузить систему и способствовать дальнейшему ухудшению качества услуги, которая и без того находится в аварийном состоянии.

Экспоненциальная отсрочка — это обычная стратегия обработки повторных попыток неудачных сетевых вызовов. Проще говоря, клиенты ждут все более длинные интервалы между последовательными повторными попытками:

wait_interval = base * multiplier^n

где,

    base — это начальный интервал, т. е. ожидание первой повторной попытки n — количество произошедших сбоев, множитель — это произвольный множитель, который можно заменить любым подходящим значением

При таком подходе мы предоставляем системе передышку для восстановления после периодических сбоев или даже более серьезных проблем.

Мы можем использовать алгоритм экспоненциальной отсрочки в повторной попытке Resilience4j, настроив его IntervalFunction, который принимает начальный интервал и множитель.

IntervalFunction используется механизмом повторных попыток в качестве функции ожидания:

IntervalFunction intervalFn =
  IntervalFunction.ofExponentialBackoff(INITIAL_INTERVAL, MULTIPLIER);

RetryConfig retryConfig = RetryConfig.custom()
  .maxAttempts(MAX_RETRIES)
  .intervalFunction(intervalFn)
  .build();
Retry retry = Retry.of("pingpong", retryConfig);

Function<String, String> pingPongFn = Retry
    .decorateFunction(retry, ping -> service.call(ping));
pingPongFn.apply("Hello");

Давайте смоделируем реальный сценарий и предположим, что у нас есть несколько клиентов, одновременно вызывающих PingPongService:

ExecutorService executors = newFixedThreadPool(NUM_CONCURRENT_CLIENTS);
List<Callable> tasks = nCopies(NUM_CONCURRENT_CLIENTS, () -> pingPongFn.apply("Hello"));
executors.invokeAll(tasks);

Давайте посмотрим в журналах удаленных вызовов для NUM_CONCURRENT_CLIENTS, равного 4:

[thread-1] At 00:37:42.756
[thread-2] At 00:37:42.756
[thread-3] At 00:37:42.756
[thread-4] At 00:37:42.756

[thread-2] At 00:37:43.802
[thread-4] At 00:37:43.802
[thread-1] At 00:37:43.802
[thread-3] At 00:37:43.802

[thread-2] At 00:37:45.803
[thread-1] At 00:37:45.803
[thread-4] At 00:37:45.803
[thread-3] At 00:37:45.803

[thread-2] At 00:37:49.808
[thread-3] At 00:37:49.808
[thread-4] At 00:37:49.808
[thread-1] At 00:37:49.808

Здесь мы видим четкую закономерность — клиенты ждут экспоненциально растущие интервалы, но все они вызывают удаленную службу точно в одно и то же время на каждом повторная попытка (столкновения).

Мы решили только часть проблемы — мы больше не забиваем удаленный сервис повторными попытками, а вместо того, чтобы распределять нагрузку по времени, у нас чередуются периоды работы с большим временем простоя. Такое поведение сродни проблеме громоподобного стада.

5. Знакомство с джиттером

В нашем предыдущем подходе ожидания клиента постепенно увеличиваются, но все еще синхронизируются. Добавление джиттера позволяет нарушить синхронизацию между клиентами, тем самым избегая коллизий. В этом подходе мы добавляем случайность в интервалы ожидания.

wait_interval = (base * 2^n) +/- (random_interval)

где random_interval добавляется (или вычитается), чтобы нарушить синхронизацию между клиентами.

Мы не будем вдаваться в механику вычисления случайного интервала, но рандомизация должна распределять пики для гораздо более плавного распределения клиентских вызовов.

Мы можем использовать экспоненциальную отсрочку с дрожанием в повторной попытке Resilience4j, настроив экспоненциальную случайную отсрочку IntervalFunction, которая также принимает randomizationFactor:

IntervalFunction intervalFn = 
  IntervalFunction.ofExponentialRandomBackoff(INITIAL_INTERVAL, MULTIPLIER, RANDOMIZATION_FACTOR);

Давайте вернемся к нашему реальному сценарию и посмотрим на журналы удаленных вызовов с помощью jitter:

[thread-2] At 39:21.297
[thread-4] At 39:21.297
[thread-3] At 39:21.297
[thread-1] At 39:21.297

[thread-2] At 39:21.918
[thread-3] At 39:21.868
[thread-4] At 39:22.011
[thread-1] At 39:22.184

[thread-1] At 39:23.086
[thread-5] At 39:23.939
[thread-3] At 39:24.152
[thread-4] At 39:24.977

[thread-3] At 39:26.861
[thread-1] At 39:28.617
[thread-4] At 39:28.942
[thread-2] At 39:31.039

Теперь у нас гораздо лучший разброс. Мы устранили как коллизии, так и время простоя, и в итоге получили почти постоянную скорость клиентских вызовов, исключая первоначальный всплеск.

Примечание. Мы завысили интервал для иллюстрации, и в реальных сценариях у нас были бы меньшие промежутки.

6. Заключение

В этом руководстве мы рассмотрели, как можно улучшить повторение неудачных вызовов клиентскими приложениями, дополнив экспоненциальную отсрочку дрожанием.

Исходный код примеров, используемых в руководстве, доступен на GitHub.