Повторные попытки и работа с переходными ошибками 101

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

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

Проблема: переходные ошибки

Если вы работаете с программным обеспечением, возможно, вы уже сталкивались с постоянной ошибкой, например, с ошибкой в системе, которая выдает ошибку в 100% случаев, когда возникает определенное условие или когда сервер полностью отключается.
Другая категория ошибок — это переходные ошибки или временные ошибки. Эти ошибки обычно возникают неожиданно, часто длятся всего 1 секунду или всего несколько миллисекунд, достаточно, чтобы разрушить успех вашего запроса 🤡 .

Если мы помним предыдущий пост, заблуждения распределенных систем помогают объяснить переходные ошибки:

  1. Сеть надежна: сеть выйдет из строя. Сбой может произойти всего на 1 секунду. Но этого может быть достаточно, чтобы остановить запрос, который ваша система пыталась успешно выполнить.
  2. Топология не меняется: во времена облаков машины постоянно добавляются и удаляются из сети. Представьте, что произойдет, если система A зависит от системы B, и в момент, когда поступает запрос A -> B, система B выполняет развертывание, а машина, ответственная за обслуживание запроса, удаляется из сети.
  3. Топология не меняется: при использовании мобильных устройств пользователи постоянно отключаются и снова подключаются к различным сетям. Что происходит, когда пользователь выполняет запрос в секунды отключения от сети?

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

Пример

Отказ от ответственности: приведенный ниже код не проходит проверку качества производства 😬.

Ладно, это все ерунда, но как насчет того, чтобы смоделировать нашу проблему? Полный код можно найти на github.

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

@RequestMapping("/")
public ResponseEntity<String> home() throws InterruptedException {
  final var now = LocalDateTime.now();
  logger.info("Failure flag value: " + now);
  // Sempre envie um erro durante os primeiros 5s de cada minuto.
  if (now.getSecond() >= 0 && now.getSecond() <= 5)
    throw new IllegalStateException();
  return ResponseEntity.status(200).body("Current time: " + now);
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Я написал клиента, который постоянно вызывает это приложение, однако, если клиент воспринимает 1 ошибку, он прекращает выполнение.

public static void main(String[] args) throws URISyntaxException, InterruptedException {

  final HttpRequest request = HttpRequest.newBuilder()
      .uri(new URI("http://localhost:8080"))
      .GET()
      .build();

  while (NUM_ERRORS < 1) {
    try {
      var response = callService(request)
      NUM_ERRORS = 0;
      System.out.println("--------------------------------------------");
      System.out.println(Thread.currentThread().getName());
      System.out.println(response.statusCode());
      System.out.println(response.body());
      System.out.println("--------------------------------------------");
    } catch (RuntimeException ex) {
      System.out.println(ex.getMessage());
      NUM_ERRORS++;
    }
    Thread.sleep(500);
  }
}

private static HttpResponse<String> callService(HttpRequest request) {
  try {
    System.out.println("Executing request at" + LocalDateTime.now());
    final HttpResponse<String> response =
        HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
    if (response.statusCode() == 500)
      throw new RuntimeException("Failed to call API");
    return response;
  } catch (IOException e) {
    System.out.println("Operation failed, exception IO");
    throw new RuntimeException(e);
  } catch (InterruptedException e) {
    System.out.println("Operation failed, exception Interrupted");
    throw new RuntimeException(e);
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

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

main
200
Current time: 2022-07-26T18:19:59.238087
--------------------------------------------
Executing request at2022-07-26T18:19:59.743644
--------------------------------------------
main
200
Current time: 2022-07-26T18:19:59.744904
--------------------------------------------
Executing request at2022-07-26T18:20:00.250584
Failed to call API
Войдите в полноэкранный режим Выход из полноэкранного режима

Итак, как мы можем избежать того, чтобы наше приложение останавливалось только с одной ошибкой?

Линейные повторные попытки

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

Основной код приведен ниже:

 public static void main(String[] args) throws URISyntaxException, InterruptedException {

    final HttpRequest request = HttpRequest.newBuilder()
        .uri(new URI("http://localhost:8080"))
        .GET()
        .build();

    Function<HttpRequest, HttpResponse> service = (HttpRequest x) -> callService(x);

    var config = RetryConfig.custom().retryExceptions(Exception.class).maxAttempts(3).waitDuration(
        Duration.ofSeconds(3)).build();
    var registry = RetryRegistry.of(config);
    var retry = registry.retry("retry");

    final var retryableServiceCall = Retry.decorateFunction(retry, service);


    while (NUM_ERRORS < 1) {
      try {
        var response = retryableServiceCall.apply(request);
        NUM_ERRORS = 0;
        System.out.println("--------------------------------------------");
        System.out.println(Thread.currentThread().getName());
        System.out.println(response.statusCode());
        System.out.println(response.body());
        System.out.println("--------------------------------------------");
      } catch (RuntimeException ex) {
        System.out.println(ex.getMessage());
        NUM_ERRORS++;
      }
      Thread.sleep(500);
    }
  }
Войдите в полноэкранный режим Выход из полноэкранного режима

Возможным результатом выполнения приведенного выше кода будет:

main
200
Current time: 2022-07-27T17:05:59.227065
--------------------------------------------
Executing request at2022-07-27T17:05:59.731737
--------------------------------------------
main
200
Current time: 2022-07-27T17:05:59.733363
--------------------------------------------
Executing request at2022-07-27T17:06:00.238117
Executing request at2022-07-27T17:06:03.303864
Executing request at2022-07-27T17:06:06.316072
--------------------------------------------
main
200
Current time: 2022-07-27T17:06:06.318999
Войдите в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что выполнение происходит каждые 500 мс, но при этом происходит следующее поведение:

  1. В 2022-07-27T17:06:00.238117 мы получаем 1 сбой.
  2. Сразу после этого мы видим, что наш клиент пытается снова 2022-07-27T17:06:03.303864, еще одна неудача, поскольку мы все еще находимся в диапазоне неудач (00-05s).
  3. Наконец, наша 3-я попытка успешно выполнена 2022-07-27T17:06:06.06.316072.
  4. Наше выполнение продолжается, как будто никакого провала не было. В конце концов, звонок был успешно выполнен с использованием повторных попыток.

Мило, правда? Но, как и все в программной инженерии, вышеупомянутое решение имеет некоторые проблемы.

Проблемы с повторными попытками

Представьте себе следующую ситуацию.

  1. Это начало «черной пятницы», и все решили получить доступ к вашему сервису в одно и то же время.
  2. При попытке обработать этот поток запросов одновременно наш бедный сервер не справляется и посылает временную ошибку.
  3. Получив временную ошибку, все клиенты решают повторить запрос.
  4. Повторные запросы поступают все сразу.
  5. Наш сервер по-прежнему перегружен и продолжает отклонять запросы.
  6. Пользователь недоволен, потому что он потерял это хорошее повышение…

Можем ли мы сделать лучше?

Повторные попытки с отступлением и джиттером

В этом посте от марта 2015 года Марк Брукер, старший главный инженер AWS, представляет идею решения этой проблемы с помощью алгоритма повторных попыток с отступлением и джиттером. Как работает эта идея?

  1. Вместо того чтобы выполнить повторный запрос сразу после неудачи, клиент ждет некоторое время
  2. Это время ожидания определяется по формуле, которая учитывает некоторую случайность (это часть джиттера) и экспоненциальную часть, связанную с количеством отказов (это часть обратного хода).

Зачем нужна обратная связь?

Идея отката заключается в том, чтобы не пытаться выполнить запрос немедленно. Вместо этого для каждого отказа мы удваиваем время ожидания. Например, при первом сбое мы ждем 2 с для повторной попытки, при втором — 4 с, при третьем — 8 с и так далее. Эта стратегия не позволяет повторным попыткам продолжать оказывать давление на и без того перегруженный сервер.

Почему джиттер?

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

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

Больше проблем с повторными попытками

Возвращаясь к проблеме «черной пятницы», представьте вторую ситуацию.

  1. Мы добавили алгоритм отката и джиттера к нашим повторным попыткам.
  2. Несмотря на это, нагрузка настолько высока, что наш сервер не справляется с запросами, которые продолжают поступать через 2,1 с, 2,2 с или 2,3 с.
  3. При добавлении повторных попыток к обычному трафику, каждый раз, когда сервер собирается восстановиться, он снова падает.

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

Повторные попытки с алгоритмом «ведро жетонов

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

  1. Для каждого успешного запроса мы добавляем процент ведра в переменную. Например: 0,1 жетон за каждый успех.
  2. За каждую неудачу мы удаляем 1 маркер из переменной.
  3. Мы можем совершать звонки только до тех пор, пока токен больше 0.

Например:

  1. Предположим, что наш маркер bucket начинается со значения 3.
  2. Когда первый запрос не выполняется, мы вычитаем 1 из ведра. Теперь ведро имеет значение 2.
  3. Поскольку значение bucket больше 0, мы выполняем новый запрос.
  4. Мы получаем еще одну неудачу, и ведро опускается до 1.
  5. Мы пробуем снова, получаем еще одну неудачу, и ведро опускается до 0.
  6. Повторные попытки полностью прекращаются.
  7. Новые запросы могут быть сделаны, но обратите внимание, что повторные запросы не будут выполняться, так как ведро достигло 0.
  8. Когда запросы начинают приносить успех, они начинают добавлять 0,1 ведро за каждый успех.
  9. После 10 успешных запросов мы имеем значение bucket равное 1, и теперь, если произойдет сбой, мы получим 1 повторную попытку.

Этот алгоритм отлично подходит для предотвращения проблемы шторма повторных попыток, которая возникает, когда служба A вызывает службу B, которая вызывает службу C. Каскадная ошибка может вызвать повторную попытку от A, которая может быть 3x, B может быть также 3x, что делает C может получить до 9x трафика 😱 .

Этот алгоритм можно использовать при работе с AWS SDK с помощью стратегии TokenBucketRetryCondition.

Еще больше проблем с повторными попытками

К сожалению, даже жетонное ведро не решает всех наших проблем. Например:

  1. Что происходит, когда мы запускаем повторную попытку, но сервер все еще обрабатывает первый запрос?
  2. Если мы потерпели неудачу дважды, стоит ли продолжать расходовать ресурсы и пытаться в третий раз?
  3. Каждый раз, когда мы повторяем попытку, мы все еще задерживаем ответ пользователю, что приводит к увеличению задержки запроса и вскоре к появлению большего количества ошибок таймаута.
  4. Должны ли мы повторить попытку на самом низком уровне, например, при вызове API, или на самом высоком уровне, чтобы избежать шторма повторных попыток?

Эта статья не ответит на все эти вопросы, но некоторые из них мы сможем рассмотреть в новых статьях в будущем.

Заключение

В этой статье мы обсудили:

  1. Временные ошибки и причины их возникновения.
  2. Различные стратегии повторных попыток, такие как линейная, обратная, джиттер и Token Bucket.
  3. Несколько проблем при использовании повторных попыток.

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

Надеюсь, вам понравился этот пост, и если он вам понравился, пожалуйста, поделитесь и поставьте лайк!

Ссылки

  1. Устранение повторных попыток с помощью маркерных ведер и выключателей
  2. Тайм-ауты, повторные попытки и откат с джиттером
  3. Экспоненциальный откат и джиттер
  4. Краткое описание стратегий повторных попыток

Оцените статью
devanswers.ru
Добавить комментарий