[Из песочницы] Делаем простой Circuit Breaker на базе кеша в Spring

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

Если вы узнали себя, читайте дальше.

Что такое Circuit Breaker


Кадр из фильма Back to the Future
Тема избитая как мир и утомлять вас, увеличивая энтропию и повторяя одно и тоже, не стану. С моей точки зрения, лучше всего об этом рассказал Мартин Фаулер здесь, я же попробую уместить определение в одно предложение:
функциональность, предотвращающая заведомо обреченные запросы к недоступному сервису, позволяя ему «встать с колен» и продолжить нормальную работу.

В идеале, предотвращая обреченные запросы, Circuit Breaker (далее CB) не должен ломать ваше приложение. Вместо этого, хорошая практика — вернуть пусть и не самые актуальные данные, но все еще релевантные («не протухшие»), либо, если это невозможно, какое-то значение по умолчанию.

Цели


Выделим главное:

  1. Нужно дать источнику данных восстановиться, останавливая на какое-то время запросы к нему
  2. В случае остановки запросов к целевому сервису, нужно отдавать, пусть не самые последние, но все еще актуальные данные
  3. В случае недоступности целевого сервиса и отсутствия актуальных данных, предусмотреть стратегию поведения (возврат значения по умолчанию или другую стратегию, подходящую для конкретного случая)


Механизм реализации


Кейс: сервис доступен (первый запрос)


  1. Идем в кеш. По ключу (CRT см. ниже). Видим, что в кеше ничего нет
  2. Идем в целевой сервис. Получаем значение
  3. Сохраняем в кеш значение, устанавливаем ему такой TTL, который будет покрывать максимально возможное время недоступности целевого сервиса, но при этом оно не должно превышать срок актуальности данных, которые вы готовы отдавать клиенту в случае потери связи с целевым сервисом
  4. Сохраняем в кеш для значения из п.3 Cache Refresh Time (CRT) — время, после которого нужно попытаться сходить в целевой сервис и обновить значение
  5. Возвращаем пользователю значение из п.2


Кейс: CRT не истекло


  1. Идем в кеш. По ключу находим CRT. Видим, что оно актуально
  2. Получаем для него значение из кеша
  3. Возвращаем пользователю значение


Кейс: CRT истекло, целевой сервис доступен


  1. Идем в кеш. По ключу находим CRT. Видим, что оно неактуально
  2. Идем в целевой сервис. Получаем значение
  3. Обновляем значение в кеше и его TTL
  4. Обновляем CRT для него, прибавляя Cache Refresh Period (CRP) — это значение, которое нужно прибавить к CRT для получения следующего CRT
  5. Возвращаем пользователю значение


Кейс: CRT истекло, целевой сервис недоступен


  1. Идем в кеш. По ключу находим CRT. Видим, что оно неактуально
  2. Идем в целевой сервис. Он недоступен
  3. Получаем значение из кеша. Не самое свежее (с протухшим CRT), но все еще актуальное, так как его TTL еще не истек
  4. Возвращаем его пользователю


Кейс: CRT истекло, целевой сервис недоступен, в кеше ничего нет


  1. Идем в кеш. По ключу находим CRT. Видим, что оно неактуально
  2. Идем в целевой сервис. Он недоступен
  3. Получаем значение из кеша. Его нет
  4. Пытаемся применять специальную стратегию для таких случаев. Например, возврат значения по умолчанию для указанного поля, либо специального значения типа «В настоящий момент эта информация недоступна». В общем, если такое возможно, то лучше что-то вернуть и не сломать работу приложения. Если такое невозможно, тогда нужно применить стратегию выброса exception и быстрого ответа пользователю исключением.


Что будем использовать


Я в своем проекте использую Spring Boot 1.5, все еще не нашел времени обновиться до второй версии.

Чтобы статья не получилась в 2 раза длиннее, буду использовать Lombok.

В качестве Key-Value storage (далее просто KV) использую Redis 5.0.3, но уверен, что подойдет Hazelcast или аналог. Главное, чтобы была реализация интерфейса CacheManager. В моем случае, это RedisCacheManager из spring-boot-starter-data-redis.

Реализация


Выше, в разделе «Механизм реализации», прозвучали два важных определения: CRT и CRP. Напишу их еще раз более развернуто, т.к. они очень важны для понимания кода, который последует далее:

Cache Refresh Time (CRT) — это отдельная запись в KV (key + postfix »_crt»), которая показывает время, когда пора бы сходить в целевой сервис за свежими данными. В отличии от TTL, наступление CRT не означает, что ваши данные «протухли», а только то, что есть вероятность получить более свежие в целевом сервисе. Получили свежие — хорошо, если нет, и текущие сойдут.

Cache Refresh Period (CRP) — это величина, которая прибавляется к CRT после опроса целевого сервиса (неважно, успешного или нет). Благодаря ей удаленный сервис имеет возможность «отдышаться» и восстановить свою работу в случае падения.

Итак, традиционно начнем с проектирования главного интерфейса. Именно через него нужно будет работать с кешом, если нужна логика CB. Он должен быть максимально простым:

public interface CircuitBreakerService {
    T getStableValue(StableValueParameter parameter);
   void evictValue(EvictValueParameter parameter);
}


Параметры интерфейса:

@Getter
@AllArgsConstructor
public class StableValueParameter {
   private String cachePrefix; // исключает пересечения ключей
   private String objectCacheKey;
   private long crpInSeconds; // Cache Refresh Period
   private Supplier targetServiceAction; // получение данных с целевого сервиса
   private DisasterStrategy disasterStrategy; // реализация логики кейса: CRT истекло, целевой сервис недоступен, в кеше ничего нет

public StableValueParameter(
   String cachePrefix,
   String objectCacheKey,
   long crpInSeconds,
   Supplier targetServiceAction
) {
   this.cachePrefix = cachePrefix;
   this.objectCacheKey = objectCacheKey;
   this.crpInSeconds = crpInSeconds;
   this.targetServiceAction = targetServiceAction;
   this.disasterStrategy = new ThrowExceptionDisasterStrategy();
}
}
@Getter
@AllArgsConstructor
public class EvictValueParameter {
   private String cachePrefix;
   private String objectCacheKey;
}


Вот так будем его использовать:

public AccountDataResponse findAccount(String accountId) {
   final StableValueParameter parameter = new StableValueParameter<>(
       ACCOUNT_CACHE_PREFIX,
       accountId,
       properties.getCrpInSeconds(),
       () -> bankClient.findById(accountId)
   );

   return circuitBreakerService.getStableValue(parameter);
}


Если нужно очистить кеш, то:

public void evictAccount(String accountId) {
   final EvictValueParameter parameter = new EvictValueParameter(
       ACCOUNT_CACHE_PREFIX,
       accountId
   );

   circuitBreakerService.evictValue(parameter);
}


Теперь самое интересное — реализация (пояснения изложил в комментариях в коде):

@Override
   public  T getStableValue(StableValueParameter parameter) {
       final Cache cache = cacheManager.getCache(parameter.getCachePrefix());
       if (cache == null) {
           return logAndThrowUnexpectedCacheMissing(parameter.getCachePrefix(), parameter.getObjectCacheKey());
       }

       // Идем в кеш. По ключу CRT
       final String crtKey = parameter.getObjectCacheKey() + CRT_CACHE_POSTFIX;
	 // Получаем CRT из кеша, либо заведомо истекшее
       final LocalDateTime crt = Optional.ofNullable(cache.get(crtKey, LocalDateTime.class))
           .orElseGet(() -> DateTimeUtils.now().minusSeconds(1));

       if (DateTimeUtils.now().isBefore(crt)) {
           // если CRT еще не наступил, возвращаем значение из кеша
           final Optional valueFromCache = getFromCache(parameter, cache);

           if (valueFromCache.isPresent()) {
               return valueFromCache.get();
           }
       }

       // если CRT уже наступил, пытаемся обновить кеш значением из целевого сервиса
       return getFromTargetServiceAndUpdateCache(parameter, cache, crtKey, crt);
   }

private static  Optional getFromCache(StableValueParameter parameter, Cache cache) {
       return (Optional) Optional.ofNullable(cache.get(parameter.getObjectCacheKey()))
           .map(Cache.ValueWrapper::get);
   }


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

private  T getFromTargetServiceAndUpdateCache(
       StableValueParameter parameter,
       Cache cache,
       String crtKey,
       LocalDateTime crt
   ) {
       T result;

       try {
           result = getFromTargetService(parameter);
       }
       /* Circuit breaker exceptions */
       catch (WebServiceIOException ex) {
           log.warn(
               "[CircuitBreaker] Service responded with error: {}. Try get from cache {}: {}",
               ex.getMessage(),
               parameter.getCachePrefix(),
               parameter.getObjectCacheKey());

           result = getFromCacheOrDisasterStrategy(parameter, cache);
       }

       cache.put(parameter.getObjectCacheKey(), result);
       cache.put(crtKey, crt.plusSeconds(parameter.getCrpInSeconds()));

       return result;
   }

private static  T getFromTargetService(StableValueParameter parameter) {
       return (T) parameter.getTargetServiceAction().get();
   }


Если актуальных данных в кеше не оказалось (были удалены по TTL, а целевой сервис все еще недоступен), то применяем DisasterStrategy:

private  T getFromCacheOrDisasterStrategy(StableValueParameter parameter, Cache cache) {
       return (T) getFromCache(parameter, cache).orElseGet(() -> parameter.getDisasterStrategy().getValue());
   }


В удалении из кеша нет ничего интересного, приведу его здесь только для полноты картины:

private  T getFromCacheOrDisasterStrategy(StableValueParameter parameter, Cache cache) {
       return (T) getFromCache(parameter, cache).orElseGet(() -> parameter.getDisasterStrategy().getValue());
   }


В удалении из кеша нет ничего интересного, приведу его здесь только для полноты картины:

@Override
   public void evictValue(EvictValueParameter parameter) {
       final Cache cache = cacheManager.getCache(parameter.getCachePrefix());
       if (cache == null) {
           logAndThrowUnexpectedCacheMissing(parameter.getCachePrefix(), parameter.getObjectCacheKey());
           return;
       }

       final String crtKey = parameter.getObjectCacheKey() + CRT_CACHE_POSTFIX;
       cache.evict(crtKey);
   }


Disaster strategy


Кадр из фильма Back to the Future

Это, собственно, та логика, которая наступает, если CRT истекло, целевой сервис недоступен, в кеше ничего нет.

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

Разве вы не хотите испытать то чувство гордости за свое детище, когда отказало все, что только может отказать, а ваша система все равно работает. Даже несмотря на то, что, например, в поле «цена» будет выведена не актуальная стоимость товара, а надпись: «в настоящий момент уточняется», но ведь насколько это лучше, чем ответ »500 сервис недоступен». Ведь, например, остальные 10 полей: описание товара и т.д. вы вернули. На сколько при этом меняется качество такого сервиса?… Мой призыв — давайте больше уделять внимания деталям, делая их качественнее.

Заканчиваю лирическое отступление. Итак, интерфейс стратегии будет следующим:

public interface DisasterStrategy {
   T getValue();
}


Реализацию вы должны подбирать в зависимости от конкретного случая. Например, если вы можете вернуть какое-то значение по умолчанию, то можно сделать что-то такое:

public class DefaultValueDisasterStrategy implements DisasterStrategy {
   @Override
   public String getValue() {
       return "в настоящий момент уточняется";
   }
}


Либо, если в конкретном случае вам ну вообще ничего вернуть, тогда можете выбросить исключение:

public class ThrowExceptionDisasterStrategy implements DisasterStrategy {
   @Override
   public Object getValue() {
       throw new CircuitBreakerNullValueException("Ops! Service is down and there's null value in cache");
   }
}



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

Заключение


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

Есть очень много готовых решений, особенно, если вы используете Spring Boot 2, таких как Hystrix.

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

Обязательно, перед тем, как применять данный подход, измерьте эффективность своего кеша. Сделать это можно по «Cache Hit Rate» = hits / (hits + misses), должно стремиться к 1, а не к 0.

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

© Habrahabr.ru