Эта статья для тех, кто использует в своем приложении эффективный кеш и хочет простым добавлением 1 класса в проект добавить стабильности не только приложению, но и всему окружению.
Если вы узнали себя, читайте дальше.
Тема избитая как мир и утомлять вас, увеличивая энтропию и повторяя одно и тоже, не стану. С моей точки зрения, лучше всего об этом рассказал Мартин Фаулер здесь, я же попробую уместить определение в одно предложение:
функциональность, предотвращающая заведомо обреченные запросы к недоступному сервису, позволяя ему “встать с колен” и продолжить нормальную работу.
В идеале, предотвращая обреченные запросы, Circuit Breaker (далее CB) не должен ломать ваше приложение. Вместо этого, хорошая практика — вернуть пусть и не самые актуальные данные, но все еще релевантные (“не протухшие”), либо, если это невозможно, какое-то значение по умолчанию.
Выделим главное:
Я в своем проекте использую 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. Он должен быть максимально простым:
Параметры интерфейса:
Вот так будем его использовать:
Если нужно очистить кеш, то:
Теперь самое интересное — реализация (пояснения изложил в комментариях в коде):
Если целевой сервис недоступен, пытаемся получить все еще актуальные данные из кеша:
Если актуальных данных в кеше не оказалось (были удалены по TTL, а целевой сервис все еще недоступен), то применяем DisasterStrategy:
В удалении из кеша нет ничего интересного, приведу его здесь только для полноты картины:
В удалении из кеша нет ничего интересного, приведу его здесь только для полноты картины:
Это, собственно, та логика, которая наступает, если CRT истекло, целевой сервис недоступен, в кеше ничего нет.
Эту логику я хотел описать отдельно, т.к. у многих не доходят руки подумать и как следует реализовать ее. А ведь это, по сути, то, что делает нашу систему по-настоящему устойчивой.
Разве вы не хотите испытать то чувство гордости за свое детище, когда отказало все, что только может отказать, а ваша система все равно работает. Даже несмотря на то, что, например, в поле “цена” будет выведена не актуальная стоимость товара, а надпись: “в настоящий момент уточняется”, но ведь насколько это лучше, чем ответ “500 сервис недоступен”. Ведь, например, остальные 10 полей: описание товара и т.д. вы вернули. На сколько при этом меняется качество такого сервиса?.. Мой призыв — давайте больше уделять внимания деталям, делая их качественнее.
Заканчиваю лирическое отступление. Итак, интерфейс стратегии будет следующим:
Реализацию вы должны подбирать в зависимости от конкретного случая. Например, если вы можете вернуть какое-то значение по умолчанию, то можно сделать что-то такое:
Либо, если в конкретном случае вам ну вообще ничего вернуть, тогда можете выбросить исключение:
В таком случае, CRT не будет инкрементирован и следующий запрос снова последует к целевому сервису.
Я придерживаюсь следующей точки зрения — если у вас есть возможность использовать готовое решение, а не городить, по сути, хоть и простой, но все же велосипед как в этой статье, так и поступайте. Используйте данную статью для понимания принципов работы, а не в качестве руководства к действию.
Есть очень много готовых решений, особенно, если вы используете Spring Boot 2, таких как Hystrix.
Самое главное, что нужно понимать — это решение основывается на кеше и его эффективность равна эффективности кеша. Если кеш неэффективен (мало попаданий, много промахов), то этот Circuit Breaker будет таким же неэффективным: каждый промах по кешу будет сопровождаться походом в целевой сервис, который, возможно, в это момент находится в агонии и муках, пытаясь подняться.
Обязательно, перед тем, как применять данный подход, измерьте эффективность своего кеша. Сделать это можно по “Cache Hit Rate” = hits / (hits + misses), должно стремиться к 1, а не к 0.
И да, вам никто не мешает держать у себя в проекте сразу несколько разновидностей CB, применяя тот, который лучшим образом решает конкретную проблему.
Если вы узнали себя, читайте дальше.
Что такое Circuit Breaker
Тема избитая как мир и утомлять вас, увеличивая энтропию и повторяя одно и тоже, не стану. С моей точки зрения, лучше всего об этом рассказал Мартин Фаулер здесь, я же попробую уместить определение в одно предложение:
функциональность, предотвращающая заведомо обреченные запросы к недоступному сервису, позволяя ему “встать с колен” и продолжить нормальную работу.
В идеале, предотвращая обреченные запросы, Circuit Breaker (далее CB) не должен ломать ваше приложение. Вместо этого, хорошая практика — вернуть пусть и не самые актуальные данные, но все еще релевантные (“не протухшие”), либо, если это невозможно, какое-то значение по умолчанию.
Цели
Выделим главное:
- Нужно дать источнику данных восстановиться, останавливая на какое-то время запросы к нему
- В случае остановки запросов к целевому сервису, нужно отдавать, пусть не самые последние, но все еще актуальные данные
- В случае недоступности целевого сервиса и отсутствия актуальных данных, предусмотреть стратегию поведения (возврат значения по умолчанию или другую стратегию, подходящую для конкретного случая)
Механизм реализации
Кейс: сервис доступен (первый запрос)
- Идем в кеш. По ключу (CRT см. ниже). Видим, что в кеше ничего нет
- Идем в целевой сервис. Получаем значение
- Сохраняем в кеш значение, устанавливаем ему такой TTL, который будет покрывать максимально возможное время недоступности целевого сервиса, но при этом оно не должно превышать срок актуальности данных, которые вы готовы отдавать клиенту в случае потери связи с целевым сервисом
- Сохраняем в кеш для значения из п.3 Cache Refresh Time (CRT) — время, после которого нужно попытаться сходить в целевой сервис и обновить значение
- Возвращаем пользователю значение из п.2
Кейс: CRT не истекло
- Идем в кеш. По ключу находим CRT. Видим, что оно актуально
- Получаем для него значение из кеша
- Возвращаем пользователю значение
Кейс: CRT истекло, целевой сервис доступен
- Идем в кеш. По ключу находим CRT. Видим, что оно неактуально
- Идем в целевой сервис. Получаем значение
- Обновляем значение в кеше и его TTL
- Обновляем CRT для него, прибавляя Cache Refresh Period (CRP) — это значение, которое нужно прибавить к CRT для получения следующего CRT
- Возвращаем пользователю значение
Кейс: CRT истекло, целевой сервис недоступен
- Идем в кеш. По ключу находим CRT. Видим, что оно неактуально
- Идем в целевой сервис. Он недоступен
- Получаем значение из кеша. Не самое свежее (с протухшим CRT), но все еще актуальное, так как его TTL еще не истек
- Возвращаем его пользователю
Кейс: CRT истекло, целевой сервис недоступен, в кеше ничего нет
- Идем в кеш. По ключу находим CRT. Видим, что оно неактуально
- Идем в целевой сервис. Он недоступен
- Получаем значение из кеша. Его нет
- Пытаемся применять специальную стратегию для таких случаев. Например, возврат значения по умолчанию для указанного поля, либо специального значения типа “В настоящий момент эта информация недоступна”. В общем, если такое возможно, то лучше что-то вернуть и не сломать работу приложения. Если такое невозможно, тогда нужно применить стратегию выброса 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> T getStableValue(StableValueParameter parameter);
void evictValue(EvictValueParameter parameter);
}
Параметры интерфейса:
@Getter
@AllArgsConstructor
public class StableValueParameter<T> {
private String cachePrefix; // исключает пересечения ключей
private String objectCacheKey;
private long crpInSeconds; // Cache Refresh Period
private Supplier<T> targetServiceAction; // получение данных с целевого сервиса
private DisasterStrategy disasterStrategy; // реализация логики кейса: CRT истекло, целевой сервис недоступен, в кеше ничего нет
public StableValueParameter(
String cachePrefix,
String objectCacheKey,
long crpInSeconds,
Supplier<T> 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> 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<T> valueFromCache = getFromCache(parameter, cache);
if (valueFromCache.isPresent()) {
return valueFromCache.get();
}
}
// если CRT уже наступил, пытаемся обновить кеш значением из целевого сервиса
return getFromTargetServiceAndUpdateCache(parameter, cache, crtKey, crt);
}
private static <T> Optional<T> getFromCache(StableValueParameter parameter, Cache cache) {
return (Optional<T>) Optional.ofNullable(cache.get(parameter.getObjectCacheKey()))
.map(Cache.ValueWrapper::get);
}
Если целевой сервис недоступен, пытаемся получить все еще актуальные данные из кеша:
private <T> 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> T getFromTargetService(StableValueParameter parameter) {
return (T) parameter.getTargetServiceAction().get();
}
Если актуальных данных в кеше не оказалось (были удалены по TTL, а целевой сервис все еще недоступен), то применяем DisasterStrategy:
private <T> T getFromCacheOrDisasterStrategy(StableValueParameter parameter, Cache cache) {
return (T) getFromCache(parameter, cache).orElseGet(() -> parameter.getDisasterStrategy().getValue());
}
В удалении из кеша нет ничего интересного, приведу его здесь только для полноты картины:
private <T> 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
Это, собственно, та логика, которая наступает, если CRT истекло, целевой сервис недоступен, в кеше ничего нет.
Эту логику я хотел описать отдельно, т.к. у многих не доходят руки подумать и как следует реализовать ее. А ведь это, по сути, то, что делает нашу систему по-настоящему устойчивой.
Разве вы не хотите испытать то чувство гордости за свое детище, когда отказало все, что только может отказать, а ваша система все равно работает. Даже несмотря на то, что, например, в поле “цена” будет выведена не актуальная стоимость товара, а надпись: “в настоящий момент уточняется”, но ведь насколько это лучше, чем ответ “500 сервис недоступен”. Ведь, например, остальные 10 полей: описание товара и т.д. вы вернули. На сколько при этом меняется качество такого сервиса?.. Мой призыв — давайте больше уделять внимания деталям, делая их качественнее.
Заканчиваю лирическое отступление. Итак, интерфейс стратегии будет следующим:
public interface DisasterStrategy<T> {
T getValue();
}
Реализацию вы должны подбирать в зависимости от конкретного случая. Например, если вы можете вернуть какое-то значение по умолчанию, то можно сделать что-то такое:
public class DefaultValueDisasterStrategy implements DisasterStrategy<String> {
@Override
public String getValue() {
return "в настоящий момент уточняется";
}
}
Либо, если в конкретном случае вам ну вообще ничего вернуть, тогда можете выбросить исключение:
public class ThrowExceptionDisasterStrategy implements DisasterStrategy<Object> {
@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, применяя тот, который лучшим образом решает конкретную проблему.