Пока приложение ходит в одну базу, сбои редки. В распределённой системе вызов соседнего сервиса — норма, и норма же, что какой-то из них не ответит: сеть потеряла пакеты, под перезапустился, база ушла на переключение. Вопрос не «упадёт ли», а что произойдёт с остальной системой, когда это случится.
Паттерны из этой статьи сбои не предотвращают. Их работа — не дать одному отказу каскадно уронить всё вокруг. Проще всего это увидеть на пуле потоков: один тормозящий вызов съедает его целиком, и каждая следующая защита меняет, сколько потоков он удерживает.
Таймаут сокращает время удержания потока, но при 40 вызовах в секунду занятых всё равно ровно двести — пул полон и с ним. Потолок, который не зависит от нагрузки, ставит переборка, а выключатель на тридцать секунд убирает и эти двадцать.
Бюджет ответа: откуда берётся каждое число
Первый вопрос к любому тайм-ауту — «почему именно столько?». «Пять секунд, как везде» — не ответ. У вызова есть тот, кто его ждёт, и ждать он согласен не бесконечно: страница оформления заказа должна ответить за 500 мс. Это бюджет ответа. Из него вычитается своя работа — проверить корзину, записать заказ, скажем, 100 мс, — а остаток делится между зависимостями. Доставке достаётся не «сколько она обычно отвечает», а сколько ей позволено: 300 мс, с запасом на сборку ответа.
Триста миллисекунд на доставку — не её медиана и не p99, а то, что осталось от бюджета после своей работы и запаса на сборку ответа. Повтор после тайм-аута сюда не влезает: 300 + 300 больше 500. Влезает только повтор после быстрого отказа — соединение отвергнуто за миллисекунды, и вторая попытка укладывается с запасом.
Дальше это число сталкивают с реальностью. Если здоровая доставка укладывается в 300 мс в 99 случаях из ста, тайм-аут отрежет один процент запросов — эти заказы уедут без срока доставки. Это цена, и назначает её продукт, а не файл настроек. Если же доставка укладывается в 300 мс через раз, бюджет не сходится, и никакой тайм-аут это не чинит: вызов уносят из синхронного пути — заказ принимают, а срок доставки досылают событием.
Отсюда же правило для повторов: тайм-аут, умноженный на число попыток, плюс паузы между ними — меньше бюджета. Иначе вызывающий отвалится по своему тайм-ауту раньше, чем вы закончите повторять, и вся работа была для никого.
Бюджет не заканчивается на одном сервисе. Если оформление зовёт доставку, а та — картографию, картографии нельзя давать те же 300 мс: из бюджета доставки уже что-то потрачено. Остаток передают вниз вместе с вызовом — это распространение дедлайна. В gRPC дедлайн едет с вызовом и наследуется вложенными вызовами сам; в HTTP его передают заголовком и вычитают на каждом узле руками. Без этого нижний сервис честно доделывает работу, ответа на которую наверху уже никто не ждёт.
Timeout — не ждать дольше, чем можно
Сервис не отвечает, и без тайм-аута поток висит на сокете, пока сервер по ту сторону не закроет соединение, — а он может и не закрыть. Сто таких потоков, и приложение перестаёт отвечать на всё, включая запросы, к упавшему соседу отношения не имеющие.
Тайм-аутов у одного HTTP-вызова три, и защищают они от разного. Тайм-аут соединения ловит сервис, которого нет: пакеты уходят, ответа нет, ждать дольше 100–200 мс внутри одного ЦОДа незачем. Тайм-аут чтения ловит сервис, который принял запрос и замолчал; в клиентах он считается между байтами, а не на весь ответ, поэтому сервис, отдающий по байту в секунду, через него не ловится. Тайм-аут ожидания соединения из пула — самый забываемый: зависшая зависимость сначала исчерпает пул соединений клиента, и следующие вызовы будут ждать не сервер, а свободное соединение; у Apache HttpClient это connectionRequestTimeout.
spring:
http:
client:
connect-timeout: 200ms
read-timeout: 300ms
Так со Spring Boot 3.4 настраивается автоконфигурированный RestClient. Потолок на вызов целиком — вместе с ожиданием соединения и ответом по байту — ставят снаружи, TimeLimiter:
private final ExecutorService deliveryExecutor = Executors.newFixedThreadPool(20);
@TimeLimiter(name = "delivery", fallbackMethod = "noDeliveryDate")
public CompletableFuture<DeliveryEstimate> estimate(Order order) {
return CompletableFuture.supplyAsync(
() -> deliveryClient.estimate(order), deliveryExecutor);
}
resilience4j:
timelimiter:
instances:
delivery:
timeout-duration: 300ms
cancel-running-future: true
Две оговорки, без которых пример не делает того, ради чего написан. Первая — свой пул: supplyAsync без второго аргумента отправляет задачу в общий ForkJoinPool, на котором живут параллельные потоки данных всего приложения, и блокирующий поход в сеть притормозит всё подряд. Вторая важнее. cancel-running-future вызывает у будущего значения отмену с прерыванием, а CompletableFuture прерывание не поддерживает — в его документации прямо сказано, что на исполнение этот флаг не влияет. Вызывающий через 300 мс получит управление и уйдёт по запасному пути, а поток так и останется стоять на сокете. Освобождает его только тайм-аут чтения из первого блока. TimeLimiter без клиентских тайм-аутов — украшение, а не защита.
Это касается не только HTTP: у вызова к базе, Redis, gRPC и брокеру тайм-аут тоже задаётся на клиенте и тоже из бюджета.
Retry — повторить, но не добить
Вызов упал, и причина могла быть случайной: под перезапускался, сеть моргнула, сборщик мусора взял паузу. Секунду спустя тот же запрос прошёл бы. Не повторять — терять запрос из-за случайности. Повторять сразу — обрушить сто клиентов на сервис, который только пытается подняться.
Повтор — это умножение нагрузки, и умножается она по цепочке. Три попытки на каждом из трёх звеньев — до 27 запросов к нижнему сервису вместо одного, и приходят они ровно тогда, когда тот и так не справляется: повтор срабатывает по ошибке, а ошибки идут, когда сервису плохо. Отсюда два ограничения. Повторяют на одном уровне, у края, а не на каждом звене. И держат бюджет повторов: если повторы — больше десятой части всех запросов к зависимости, это уже не страховка от случайности, а вторая волна нагрузки, и её отключают.
Ограничение по времени — второе. Все сто клиентов получили ошибку в один момент, все выждали секунду, все повторили одновременно — сервис лёг снова. Задержку делают нарастающей (секунда, две, четыре) и добавляют случайный разброс, чтобы клиенты разошлись во времени:
Без разброса все сто клиентов ждут одну и ту же секунду и бьют одновременно: три волны по сто, каждая — тот же шквал, который сервис положил. Разброс не уменьшает число повторов, он растягивает их по времени: пик падает со ста в секунду до нескольких десятков, и поднявшийся сервис получает нагрузку, которую способен переварить.
resilience4j:
retry:
instances:
delivery:
max-attempts: 2
wait-duration: 50ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
ignore-exceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
Без enable-exponential-backoff множитель молча игнорируется, и паузы останутся одинаковыми. Разброс включают enable-randomized-wait с randomized-wait-factor; вместе с нарастанием оба флага работают: из них Spring Boot собирает IntervalFunction.ofExponentialRandomBackoff, в коде это писать не нужно. Две попытки, а не пять, — из бюджета: 300 мс тайм-аута, 50–100 мс паузы и вторая попытка — это уже край. CallNotPermittedException в ignore-exceptions — чтобы не повторять то, что уже отклонил выключатель из следующего раздела: пауза перед вызовом, который заведомо не пойдёт в сеть, — время впустую. И помните, что паузу синхронный Retry ждёт через Thread.sleep: поток занят всё время повтора, включая паузы.
Не всякую ошибку безопасно повторять, и граница проходит не по коду ответа, а по тому, дошёл ли запрос. Соединение отвергнуто — запрос точно не выполнялся, повторяйте. Тайм-аут чтения — запрос ушёл, и сервер, возможно, его выполнил: повтор POST /orders создаст второй заказ. То же с 500: сервер упал где-то посередине, и что он успел — неизвестно. Повторять после тайм-аута можно только идемпотентную операцию — GET, PUT по ключу, DELETE по идентификатору — или POST с ключом идемпотентности, по которому сервер узнает повтор и вернёт сохранённый результат вместо второго выполнения. Как этот ключ устроен на приёмнике и почему без него «ровно один раз» не бывает — в статье о корректности в распределённой системе.
Circuit Breaker — перестать ходить к тому, кто лежит
Платёжный шлюз лёг надолго — не на секунду, на десять минут. Тайм-аут спасает каждый отдельный поток, но не сервис: сорок вызовов в секунду по пять секунд ожидания — это двести занятых потоков постоянно, весь пул, и так все десять минут. Каждый вызов честно ждёт свои пять секунд, чтобы получить тот же отказ, что и сорок предыдущих.
Выключатель убирает само ожидание. Он ведёт скользящее окно последних вызовов и считает в нём долю ошибок. Пока доля ниже порога, вызовы идут. Когда окно заполнено и доля перевалила за порог, выключатель размыкается: следующие вызовы отклоняются за микросекунды, не доходя до сети. Через заданное время он пропускает несколько пробных вызовов и по их доле ошибок решает — замкнуться обратно или подождать ещё.
Первая же ошибка выключатель не размыкает: пока в окне меньше minimum-number-of-calls, доля не считается вовсе. Из OPEN выход только по времени, и решение в HALF_OPEN принимается по той же доле ошибок среди пробных, а не по первому пробному ответу. Исключения из ignore-exceptions в окно не попадают ни как успех, ни как ошибка.
Первое, чего не видно на картинке, — окно. Первая ошибка ничего не размыкает: пока вызовов в окне меньше minimum-number-of-calls, доля не считается, — а по умолчанию это сто вызовов, и на редкой зависимости выключатель может не сработать никогда. Второй порог рядом, slow-call-duration-threshold, по умолчанию равен минуте: при тайм-ауте в 300 мс медленных вызовов у выключателя не будет никогда.
Второе — что считать ошибкой. По умолчанию ошибка — любое исключение, и это ловушка: OrderNotFoundException на 404 — не сбой, а ответ. Ночью бот перебирает чужие номера заказов, десять «не найдено» подряд — и выключатель размыкается на живом шлюзе, оставив без оплаты всех. Такие исключения перечисляют в ignore-exceptions: они не попадают в окно ни как ошибка, ни как успех.
resilience4j:
circuitbreaker:
instances:
payment:
sliding-window-size: 10
minimum-number-of-calls: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
ignore-exceptions:
- ru.example.orders.exception.OrderException
Соблазнительно пойти от обратного и перечислить в record-exceptions только «настоящие» сбои — IOException, TimeoutException. У этой настройки есть обратная сторона: всё, что не в списке, считается успехом. Отказ переборки из следующего раздела в список не попал — и ляжет в окно успехом, разбавляя долю ошибок ровно в тот момент, когда зависимость захлёбывается. Список исключений-ответов короче и безопаснее списка сбоев.
@CircuitBreaker(name = "payment", fallbackMethod = "paymentUnavailable")
public PaymentResult charge(Long userId, Money amount) {
return paymentClient.charge(userId, amount.toKopecks());
}
private PaymentResult paymentUnavailable(Long userId, Money amount, Exception e) {
throw new ServiceTemporarilyUnavailableException("Payment service", e);
}
Тип исключения в сигнатуре запасного метода решает, когда он сработает: с CallNotPermittedException — только в разомкнутом состоянии, а при первых отказах шлюза, до размыкания, наружу полетит сырое исключение клиента. Подменять ответ этот метод не пытается — для платежа это невозможно. Его работа — перевести чужое техническое исключение в одну понятную ошибку «шлюз временно недоступен», на которую интерфейс покажет человеческий текст и кнопку «попробовать ещё раз».
И последнее: выключатель живёт в памяти одного экземпляра приложения. Десять реплик — десять выключателей, у каждого своё окно, и размыкаются они в разное время. Лёг один из трёх узлов зависимости, треть вызовов падает — доля 33 % порог в 50 % не переступит нигде; при неровном трафике одна реплика разомкнётся, а девять нет. «Выключатель разомкнут» в проде всегда означает «на каких-то репликах», и смотреть его состояние надо по каждому экземпляру, а не по сервису в целом.
Bulkhead — потолок на одного соседа
Сервис ходит в платёжный шлюз, склад и страховую из одного пула на двести потоков. Шлюз тормозит — и через несколько секунд все двести потоков стоят в ожидании оплаты. Склад и страховая работают, но запросы к ним ждать уже нечем. Тайм-аут здесь не спасает, это видно во вводной анимации: при сорока вызовах в секунду и пяти секундах ожидания занятых потоков ровно двести, каким бы тайм-аут ни был.
Спасает потолок: одному соседу — не больше двадцати одновременных вызовов, двадцать первый отклоняется сразу. Это переборка, по аналогии с герметичными отсеками корабля: затопление одного не топит остальные. Реализаций две, и различаются они тем, чей поток висит, когда завис вызов.
Семафорная переборка потоков не заводит: она ставит потолок на то, сколько потоков общего пула может забрать один сосед, и живёт только вместе с тайм-аутом — зависший вызов держит поток общего пула. Отдельный пул отвязывает зависание от общего пула целиком, но каждый вызов едет через передачу в другой поток, и контекст запроса — MDC, данные аутентификации, трассировка — сам туда не переезжает.
resilience4j:
bulkhead:
instances:
payment:
max-concurrent-calls: 20
max-wait-duration: 0
thread-pool-bulkhead:
instances:
payment:
max-thread-pool-size: 20
core-thread-pool-size: 10
queue-capacity: 50
Семафорная переборка (bulkhead) — это счётчик на двадцать, и вызов сверх него получает BulkheadFullException. Строка max-wait-duration: 0 важнее, чем кажется. Поставьте полсекунды — и лишние вызовы перестанут отклоняться: они встанут в очередь и эти полсекунды будут держать по потоку каждый; при сорока вызовах в секунду это ещё два десятка занятых потоков сверх двадцати рабочих, то есть потолок, ради которого переборку ставили, поднят вдвое. Для HTTP с настроенными тайм-аутами семафора достаточно. Отдельный пул (thread-pool-bulkhead) берут, когда тайм-аут вызову не поставить — библиотека не даёт, драйвер не умеет.
Fallback — ответить хуже, но честно
Основной путь недоступен, а сценарий пользователя закрыть надо. Курс валюты не пришёл — показать последний известный. Персональные рекомендации легли — показать популярное. Поисковый кластер недоступен — искать по базе простым LIKE. Это запасной ответ: хуже основного, но лучше ошибки.
Три примера — три разных решения; код у них одинаковый, а различия в продукте. Последний известный курс — это устаревшие данные: для витрины приемлемо, для расчёта комиссии нет. Популярное вместо персонального — обеднённая логика: пользователь получит другой результат, но того же рода. Поиск по базе вместо кластера — другое качество: часть товаров не найдётся вовсе. Какой из компромиссов допустим, решают с продуктом до отказа, а не в четыре утра во время него.
Общее у всех трёх одно: пользователю говорят правду. Молчаливая подмена — худший вариант из возможных: покупатель ищет товар, который точно есть, не находит и уходит с уверенностью, что его нет. Плашка «курс на 10:00, может отличаться» или «поиск работает в упрощённом режиме» стоит одной строки и превращает деградацию из обмана в известное ограничение. Поэтому запасной ответ несёт признак, что он запасной:
@CircuitBreaker(name = "exchangeRate", fallbackMethod = "lastKnownRate")
public ExchangeRate getRate(String currency) {
ExchangeRate rate = exchangeRateClient.getRate(currency);
rateCache.put(currency, rate);
return rate;
}
private ExchangeRate lastKnownRate(String currency, Exception e) {
return rateCache.get(currency)
.map(ExchangeRate::asStale)
.orElseThrow(() -> new RateUnavailableException(currency, e));
}
asStale — тот самый признак: та же цифра, но с пометкой и временем, по которым интерфейс покажет, что курс не свежий. И обратите внимание на orElseThrow: кэша нет — значит, честная ошибка, а не курс «по умолчанию» из воздуха.
Есть операции, где запасного ответа не бывает. Платёж нельзя «примерно» списать, резерв на складе нельзя «скорее всего» поставить. Здесь правильный запасной путь — тот, что в разделе про выключатель: одна понятная ошибка «попробуйте позже» и, если бизнес позволяет, приём заказа с отложенной оплатой.
Rate Limiter и сброс нагрузки — когда запросов слишком много
Внешний API разрешает сто запросов в секунду, за превышение — бан на пять минут. Или наоборот: это ваш сервис получил всплеск, с которым не справится. В обоих случаях защищаются не от отказа соседа, а от самого количества запросов, — но защита стоит с разных сторон.
Исходящий ограничитель бережёт чужой лимит. У Resilience4j он устроен как окно: каждые limit-refresh-period выдаётся limit-for-period разрешений, вызов без разрешения ждёт до timeout-duration и отклоняется.
resilience4j:
ratelimiter:
instances:
paymentApi:
limit-for-period: 5
limit-refresh-period: 100ms
timeout-duration: 0
Пять на сто миллисекунд, а не пятьдесят на секунду, — не случайно. У окна есть граница: пятьдесят вызовов в последнюю миллисекунду одной секунды и пятьдесят в первую миллисекунду следующей — это сто запросов за две миллисекунды при честных «50 в секунду» с нашей стороны. Если чужой лимит считается по скользящему окну или по ведру токенов, такой всплеск он поймает и забанит. Мелкое окно сглаживает границу; там, где нужны всплески по накопленному запасу, берут ведро токенов — например, Bucket4j. И снова про реплики: ограничитель Resilience4j живёт в одном экземпляре, десять реплик по пятьдесят — это пятьсот у чужого API. Общий лимит на кластер держат в общем хранилище, тот же Bucket4j умеет считать через Redis.
Входящая защита — другая история. Переборки и ограничители берегут вас от соседей, а от собственных клиентов защищает только сброс нагрузки: при перегрузке часть запросов отклоняют на входе, сразу и честно — 503 с заголовком Retry-After или 429. Интуиция сопротивляется: зачем отказывать, если можно поставить в очередь? Затем, что очередь длиннее тайм-аута вызывающего — работа для никого: клиент отвалится по своему тайм-ауту, повторит, и сервис будет обрабатывать запросы, ответ на которые никто не читает, вместе с их повторами. У Tomcat по умолчанию 200 потоков и очередь на 100 соединений сверх них. Пока запрос занимает 100 мс, очередь из ста рассасывается за 50 мс и незаметна; стоило зависимости замедлить запрос до секунды — последний в очереди ждёт полсекунды, весь бюджет клиента. Правило то же, что у бюджета: длина очереди, умноженная на время обработки и делённая на число потоков, — меньше тайм-аута вызывающего; что не влезает, отклоняется сразу. Сервис, который отвечает восьмидесяти процентам быстро, лучше сервиса, который отвечает всем медленно, а потом никому.
Dead Letter Queue — отравленное сообщение в очереди
Сообщение из Kafka не обрабатывается: битый JSON, неизвестное поле, не прошла бизнес-проверка. С сетевым сбоем помог бы повтор, но здесь причина в самом сообщении, и десятая попытка кончится тем же, что первая. Хуже того, повторы не бесплатны: в партиции порядок, смещение фиксируется по очереди, и пока потребитель бьётся об одно сообщение, все следующие стоят за ним.
Повторы отравленного сообщения — это простой партиции: пока m1 пробуют четыре раза с паузой в секунду, m2–m4 ждут, потому что смещение фиксируется по порядку, а не по одному сообщению. Поэтому у обработчика ошибок считают не число попыток, а произведение попыток на паузу — это и есть время, на которое встанут все остальные.
Отравленное сообщение после нескольких попыток убирают с дороги — в отдельный топик, очередь мёртвых писем, где его разберут руками, а партиция идёт дальше:
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> kafkaTemplate) {
DeadLetterPublishingRecoverer recoverer =
new DeadLetterPublishingRecoverer(kafkaTemplate,
(record, ex) -> new TopicPartition(record.topic() + ".dlq", -1));
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3L));
handler.addNotRetryableExceptions(ValidationException.class, JsonParseException.class);
return handler;
}
Три строки здесь несут по решению. -1 вместо record.partition() — пусть партицию в .dlq выберет брокер: если прибить номер исходной, а партиций у DLQ-топика меньше, публикация будет падать. FixedBackOff(1000, 3) — три повтора после первой попытки с паузой в секунду, то есть три секунды простоя партиции на одно отравленное сообщение; поставить сюда минуту и десять повторов — значит останавливать партицию на десять минут. addNotRetryableExceptions — ошибки, где повторять нечего: битый JSON от паузы не починится, такое сообщение уезжает в DLQ с первой попытки.
Одна ловушка: битые данные до этого обработчика сами не доходят. Разбор сообщения происходит при чтении из брокера, раньше, чем слушатель получает управление. Упал разбор — потребитель встаёт на том же сообщении и крутится на нём бесконечно, а DefaultErrorHandler про это не узнает. Разбор оборачивают в ErrorHandlingDeserializer: он перехватывает падение, отдаёт слушателю пустое значение, а ошибку передаёт обработчику — и тот кладёт сообщение в DLQ.
spring:
kafka:
consumer:
key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
DLQ — не корзина, а очередь на разбор: оповещение на неё ставят с первого сообщения, а после починки причины сообщения возвращают в основной топик. Отставание потребителя и идемпотентный приём — в статье о Kafka в проде.
Как стек ведёт себя при отказе
Паттерны редко стоят по одному, и у Resilience4j порядок обёрток зашит в библиотеке: аннотации на методе можно писать в любом порядке, аспекты всё равно выстроятся так:
Порядок обёрток у Resilience4j зашит в библиотеке: повтор снаружи, переборка ближе всех к вызову. Каждая попытка повтора заново проходит выключатель и ограничитель, а отказ переборки поднимается сквозь выключатель и ложится в его окно как ошибка.
Разомкнулся выключатель посреди серии повторов — следующая попытка отвалится мгновенно, для этого повтор и игнорирует CallNotPermittedException. Если нужен другой порядок, его задают числами: retry-aspect-order, circuit-breaker-aspect-order и такие же свойства у остальных — чем больше число, тем внешнее обёртка.
Теперь худший случай. Возьмём вводную задачу — оплата, 40 вызовов в секунду, пул 200 — и типичные настройки «пять секунд, три попытки, пауза секунда с удвоением». Шлюз завис. Один вызов при таких настройках живёт так:
Тайм-аут 5 с, три попытки, паузы 1 и 2 с — восемнадцать секунд на один вызов к зависшему шлюзу, и всё это время поток занят: синхронный повтор спит в нём же. Бюджет ответа в 500 мс такая настройка превышает в тридцать шесть раз.
Восемнадцать секунд на один вызов: по закону Литтла 40 вызовов в секунду × 18 с — это 720 занятых потоков при 200 в пуле. А вот что происходит с сервисом в целом при переборке на 20 и окне выключателя из 10 с порогом 50 %:
- В первые полсекунды двадцать вызовов уходят в шлюз и висят. Следующие десять получают
BulkheadFullExceptionмгновенно — по умолчанию для выключателя это ошибка. - Эти десять отказов заполняют окно: 100 % ошибок, выключатель размыкается. Секунды не прошло, ни один тайм-аут ещё не сработал. Вызывающие получают
CallNotPermittedExceptionза микросекунды, запасной метод переводит его в 503 «попробуйте позже». - Через 5 с двадцать зависших вызовов отваливаются по
TimeLimiter, и — только благодаряread-timeoutу HTTP-клиента — их потоки действительно освобождаются. Занято 0 из 200, склад и страховая работают. - Через 30 с — три пробных вызова. Шлюз лежит — снова OPEN на 30 с; поднялся — CLOSED, трафик пошёл.
Уберите переборку — и получится первый кадр вводной анимации: 200 потоков заняты все пять секунд до первого тайм-аута, и эти пять секунд сервис не отвечает никому. Поставьте record-exceptions только на IOException — отказы переборки лягут в окно успехами, выключатель не разомкнётся вовсе, и сервис каждые пять секунд будет отдавать двадцать тайм-аутов и сто восемьдесят мгновенных отказов. Стек проверяют не на «всё настроено», а на «что увидит вызывающий и через сколько».
Что смотреть в проде
Все семь паттернов молчаливы: без метрик разомкнутый выключатель выглядит как «оплата почему-то не работает», а полная переборка — как случайные 503. Resilience4j отдаёт в Micrometer всё, что нужно, — задача выбрать, на что ставить оповещение.
Выключатель: resilience4j_circuitbreaker_state — по значению на каждое состояние, оповещение на open дольше минуты и по каждой реплике отдельно; resilience4j_circuitbreaker_failure_rate — доля ошибок в окне, её рост виден до размыкания; resilience4j_circuitbreaker_calls с kind="not_permitted" — сколько вызовов отклонили, это то, что почувствовали пользователи. Переборка: resilience4j_bulkhead_available_concurrent_calls — ноль дольше нескольких секунд означает, что потолок достигнут и вызовы отклоняются. Повтор: resilience4j_retry_calls четырёх видов — successful_with_retry растёт, когда зависимость моргает и повтор спасает; failed_with_retry — когда повторы не помогают и только множат нагрузку; их сумма к общему числу вызовов и есть бюджет повторов. Ограничитель: resilience4j_ratelimiter_available_permissions и waiting_threads. DLQ: отставание группы, которая читает .dlq, или число сообщений в нём — оповещение с первого.
Оповещение на симптом — «выключатель открыт», «в DLQ есть сообщения» — срабатывает раньше, чем оповещение на следствие «ошибки 5xx выросли», и указывает на зависимость, а не на сервис в целом.
Глубже: деградация сценария: принять заказ и отложить оплатурасширенное
Fallback выше это запасной ответ на один вызов: рекомендации из кэша, курс из вчерашнего. Есть уровень выше, деградация целого сценария: сервис оплаты лежит десять минут, и вопрос не «что ответить вместо оплаты», а «что делать с оформлением заказа». Отказ на весь сценарий это потеря заказов; молчаливая подмена это обман.
Принять и отложить. Заказ принимают со статусом «ожидает оплаты», товар резервируют, оплату ставят в очередь и проводят, когда сервис оплаты вернётся; пользователю честно говорят: «заказ принят, оплата будет подтверждена в течение часа, мы пришлём уведомление». Это возможно, потому что бизнес переживает задержку оплаты лучше, чем потерю заказа, и это бизнес-решение, принятое заранее, а не инженером в три ночи. Техника: сага, у которой шаг оплаты может быть отложен, outbox, чтобы задача оплаты не потерялась, и опрос застрявших, чтобы отложенное не зависло.
Короткий резерв. Отложенная оплата означает, что товар зарезервирован без денег. Резерв делают со сроком: пятнадцать минут для обычного оформления, час для деградированного режима, потом резерв снимается автоматически, и заказ отменяется с уведомлением. Так деградация не превращается в склад, зарезервированный неоплаченными заказами.
Что честно показать. Правило одно: пользователь узнаёт о состоянии, которого раньше не было, словами, а не отсутствием кнопки. «Оплата временно недоступна, заказ сохранён, оплатить можно позже из личного кабинета» лучше, чем спиннер и лучше, чем «оплачено» с надеждой, что пройдёт. Интерфейс должен уметь показывать промежуточные состояния всегда, а не только при инциденте, иначе при инциденте их некуда деть.
Переключение режима. Деградированный сценарий включается не сам по себе, а по сигналу: выключатель к оплате разомкнут дольше минуты, или флаг, который включает дежурный. Автоматическое включение по выключателю удобнее, но требует, чтобы бизнес заранее согласился с последствиями (заказы без оплаты на час); ручной флаг медленнее, но с ведома. И выход из режима тоже процедура: отложенные оплаты проводятся пачкой, с ограничением скорости, чтобы не уронить только что поднявшийся сервис.
Деградация сценария сложнее запасного ответа ровно на одну вещь: её нужно спроектировать в модели данных и согласовать с бизнесом до инцидента. Зато она превращает десять минут простоя соседа из потерянных заказов в отложенные.
Коротко
- Каждое число выводится из бюджета ответа: тайм-аут — доля бюджета, а не «как обычно отвечает»; повтор влезает после быстрого отказа, после тайм-аута — нет.
- Настоящие тайм-ауты живут у клиента: соединение, чтение между байтами, ожидание из пула.
TimeLimiterбез них отпускает вызывающего, но не поток. - Повтор умножает нагрузку по цепочке (3 × 3 × 3 = 27) и держит поток на всё время пауз; повторяют у края, с разбросом, и только то, что точно не выполнилось или идемпотентно.
- Выключатель считает долю ошибок в окне: первая ошибка ничего не размыкает, ответы вроде 404 — в
ignore-exceptions, а всё внеrecord-exceptions— успех. Десять реплик — десять выключателей. - Переборка ставит потолок на одного соседа независимо от нагрузки; семафор — с тайм-аутом, отдельный пул — когда тайм-аута нет;
max-wait-durationбольше нуля поднимает потолок. - Запасной ответ — решение продукта, и он честный: с признаком «устарело» или «упрощённый режим». Платежу запасного ответа не бывает.
- Ограничитель бережёт чужой лимит и должен быть общим на кластер; от собственных клиентов защищает только сброс нагрузки: очередь длиннее тайм-аута вызывающего — работа для никого.
- Повторы отравленного сообщения — простой партиции: попытки × пауза — это время простоя; ошибки разбора доходят до DLQ только через
ErrorHandlingDeserializer. - Порядок обёрток у Resilience4j: повтор снаружи, переборка внутри; стек проверяют на худший случай — что увидит вызывающий и через сколько.
- Деградация сценария выше запасного ответа: принять заказ и отложить оплату через сагу и outbox, резерв со сроком, честный промежуточный статус, включение по выключателю или флагу с согласия бизнеса и выход с ограничением скорости.
Что пощупать
Повтор, таймаут, размыкатель и честный отказ стоят на одном клиенте в практикуме remodov/marketplace-system: сервис заказов ходит в каталог за ценами, и восьмой шаг про то, что бывает, когда каталог отвечать перестаёт. Тест подменяет каталог WireMock, который умеет и рвать соединение, и держать ответ.
Код: adapter-out-catalog, application.yml.
Сделаем сами
Ветка step-08-resilience — аннотаций и таймаутов нет, три проверки красные.
Что почитать дальше
- Синхронный или асинхронный вызов — когда вызов вообще не должен быть синхронным и бюджет не сходится никаким тайм-аутом.
- Проблемы распределённых систем — почему тайм-аут — единственный способ узнать об отказе и чем это плохо.
- Корректность в распределённой системе — ключ идемпотентности на приёмнике, без которого повтор после тайм-аута невозможен.
- Распределённые паттерны — Saga и Outbox: что стоит вокруг каждого шага, который здесь защищали.