Повышение устойчивости микросервиса к сбоям на нижнем уровне

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

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

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

Нестабильность может испортить вам день

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


Дерьмо попадает в вентилятор

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

Появилась Resilience4j

Наш сервис написан на языке Java. Это делает resilience4j естественным решением. Если вы не знаете, это библиотека, написанная компанией Netflix и ориентированная на отказоустойчивость. В качестве бонуса, она основана на Vavr. Таким образом, она получает преимущества от таких вещей, как тип Either и все остальное. Это как раз по моей части.

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

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

Использование операторов устойчивости

resilience4j предоставляет кучу операторов из коробки, которые повышают отказоустойчивость ваших удаленных вызовов. Большим плюсом этой библиотеки является ее композитивность. Нет особой разницы между использованием только Retry и другими операторами, такими как CircuitBreaker. Я не буду вдаваться в подробности того, как они работают, обратитесь к документации, чтобы узнать больше деталей.

Если вы используете Spring Boot, там есть поддержка использования аннотаций. Аннотации всегда поддерживаются, если используется SpringBoot. Интересно, что я чувствую, что аннотации создают слишком много дублирования. Например, я хотел применить общий набор операторов к шести или семи удаленным вызовам. Аннотирование методов и добавление отступлений привело к запутанному решению.

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

Retry getRetry(String name, MeterRegistry meterRegistry) {
  RetryRegistry retryRegistry = RetryRegistry.ofDefaults();
  TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry);
  return retryRegistry.retry(name);
}

CircuitBreaker getCircuitBreaker(String name, MeterRegistry meterRegistry) {
  CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry
      .ofDefaults();
  TaggedCircuitBreakerMetrics
      .ofCircuitBreakerRegistry(circuitBreakerRegistry).bindTo(meterRegistry);
  return circuitBreakerRegistry.circuitBreaker(name);
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Создание резервного кэша

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

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


Это счастливый пользователь

Это сводится к трем шагам:

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

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

public class SyncFallbackCache<T, R extends Cacheable<T>> extends AbstractFallbackCache<T, R> {
  public T get(Supplier<String> keySupplier, Supplier<R> valueSupplier);
  private T getAndCache(String key, Supplier<R> valueSupplier);
  private T getFromCacheOrThrow(String key, RuntimeException exception);
}
Войти в полноэкранный режим Выход из полноэкранного режима

Давайте разберемся по порядку.

Выполнение вызова

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

public T get(Supplier<String> keySupplier, Supplier<R> valueSupplier) {
  Supplier<T> decoratedSupplier = Decorators.ofSupplier(() -> getAndCache(key, valueSupplier))
      .withRetry(retry)
      .withCircuitBreaker(cb).decorate();

  var key = keySupplier.get();

  return Try.ofSupplier(decoratedSupplier)
      .recover(RuntimeException.class, (exception) -> getFromCacheOrThrow(key, exception))
      .get();
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Хранение результата

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

private T getAndCache(String key, Supplier<R> valueSupplier) {
  /*
    This has the disadvantage that the [get result, cache it] is not atomic. So if two requests try to update the same key at the same time, it might create an undetermined result.

    However, two calls to the same service should return the same value, so we should be able to live with that.

    This is not happening inside the compute method anymore due to problems acquiring the lock that led to significant timeouts.
   */
  var result = valueSupplier.get();
  if (result == null) {
    return null;
  }

  return Try.of(() -> {
    cache.save(result);
    return result;
  })
      .recover(Exception.class, e -> {
        log.error("Could not save result to cache", e);
        return result;
      })
      .map(Cacheable::data)
      .get();
}
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Используйте запасной вариант

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

private T getFromCacheOrThrow(String key, RuntimeException exception) {
  var value = cache.findById(key);

  value.ifPresent((v) -> {
    log.info(String.format("FallbackCache[%s] got cached value", cb.getName()), v);
  });

  return value
      .map(Cacheable::data)
      .orElseThrow(() -> {
            log.error(
                String.format("FallbackCache[%s] got an error without a cached value", cb.getName()),
                exception
            );
            throw exception;
          }
      );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Перспектива пользователя

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

private SyncFallbackCache<Collection<SupplierUserPermission>> permissions;

private Collection<SupplierUserPermission> getPermissions(
    int userId, 
    int supplierId) {
  return permissions.get(
      () -> String.format("id:%s;suId:%s", userId, supplierId),
      () -> client.getUserPermissions(userId, supplierId)
          .getData()
          .getSupplierUserMyInformation()
          .getPermissions()
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Где кэш?

Я еще не говорил о кэше. Самая простая альтернатива — использование кэша в памяти, например Caffeine. Что-то вроде этого:

static <R> Cache<String, R> cache(String name, MeterRegistry meterRegistry) {
  Cache<String, R> cache = Caffeine.newBuilder()
      .recordStats()
      .expireAfterWrite(Duration.ofHours(24))
      .maximumSize(6000)
      .build();

  CaffeineCacheMetrics.monitor(meterRegistry, cache, name);
  return cache;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот подход удобен, но имеет свои недостатки. Наше приложение работает как контейнер. При каждом перезапуске кэш стирается. Это приводит к плохому проценту попаданий, который колеблется в районе 65-70%. Это не очень хорошо для кэша, и все равно конечный пользователь получает слишком много отказов.

Мы перешли на постоянный кэш на базе Aerospike, чтобы решить эту проблему. SpringBoot имеет несколько интеграций для облегчения этой задачи:

public interface NavigationRepository extends
    AerospikeRepository<CachedNavigation, String> {
}

@Value
@Document(collection = "navigation_CachedNavigationMenu")
public class CachedNavigation implements Cacheable<List<LegacyNavMenuItem>> {
  @Id
  String id;
  List<LegacyNavMenuItem> navigation;

  @Override
  public List<LegacyNavMenuItem> data() {
    return navigation;
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Использование кэша для сценариев отказа означает, что аннулирование не так актуально. Постоянный кэш дал ощутимую разницу, благодаря которой наш процент попадания в цель достиг 90-х годов. Это намного лучше, не так ли?

Путешествие со счастливым концом

Стоили ли все эти усилия того? Да, черт возьми. Наш коэффициент ошибок вырос с 2% до 0,03%. Мы установили SLO для доступности сервиса на уровне 99,95%. Наши ротации стали значительно более плавными. У меня появилось столько свободного времени, что я мог бы писать об этом посты в блоге!

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