← назад к разделу

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

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

Пул 200 потоков · 40 вызовов/с к оплате · шлюз отвечает по 30 с склад и страховая здоровы — им нужны свободные потоки без защиты200/20040 × 30 с = 1200 ожиданий на 200 мест — очередь встала + таймаут 5 с200/20040 × 5 с = 200 — таймаут есть, пул всё равно полон + переборка на 2020/20021-й вызов отклонён сразу — свободно 180 потоков + выключатель OPENвызовов к оплате нет0/20050% ошибок в окне из 10 → OPEN 30 с, свободно 200таймаута при 40 вызовах/с мало — потолок ставит переборка

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

Обязательно

Бюджет ответа: откуда берётся каждое число

Первый вопрос к любому тайм-ауту — «почему именно столько?». «Пять секунд, как везде» — не ответ. У вызова есть тот, кто его ждёт, и ждать он согласен не бесконечно: страница оформления заказа должна ответить за 500 мс. Это бюджет ответа. Из него вычитается своя работа — проверить корзину, записать заказ, скажем, 100 мс, — а остаток делится между зависимостями. Доставке достаётся не «сколько она обычно отвечает», а сколько ей позволено: 300 мс, с запасом на сборку ответа.

бюджет ответа 500 мс своя работа 100 мс доставка: тайм-аут 300 мс сборка ответа 100 мс

Триста миллисекунд на доставку — не её медиана и не 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 запросов к нижнему сервису вместо одного, и приходят они ровно тогда, когда тот и так не справляется: повтор срабатывает по ошибке, а ошибки идут, когда сервису плохо. Отсюда два ограничения. Повторяют на одном уровне, у края, а не на каждом звене. И держат бюджет повторов: если повторы — больше десятой части всех запросов к зависимости, это уже не страховка от случайности, а вторая волна нагрузки, и её отключают.

Ограничение по времени — второе. Все сто клиентов получили ошибку в один момент, все выждали секунду, все повторили одновременно — сервис лёг снова. Задержку делают нарастающей (секунда, две, четыре) и добавляют случайный разброс, чтобы клиенты разошлись во времени:

сто клиентов упали в одну секунду · повтор через 1, 2 и 4 с без разброса 100 100 100 0 1 2 3 4 5 6 7 8 с три удара по сто — тот же шквал, который сервис и положил с разбросом те же 300 повторов, пик — около 50 в секунду 0 1 2 3 4 5 6 7 8 с

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

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 — перестать ходить к тому, кто лежит

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

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

окно из 10 вызовов · порог 50 % ошибок · 30 с в OPEN CLOSED вызовы идут, считаем OPEN отказ сразу, без сети HALF_OPEN 3 пробных вызова ≥ 50 % ошибок из 10 прошло 30 с ошибок ≥ 50 % из 3 пробных → снова OPEN ошибок < 50 % из 3 пробных → снова CLOSED успех и ошибка ложатся в окно; ignore-exceptions — мимо окна: это ответы, а не сбои

Первая же ошибка выключатель не размыкает: пока в окне меньше 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 — потолок на одного соседа

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

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

оплата зависла: двадцать вызовов висят · склад и страховая здоровы семафор · max-concurrent-calls: 20 20 оплата — висят 180 — складу и страховой 21-й вызов оплаты — отказ сразу висит вызов — висит поток общего пула живёт только вместе с тайм-аутом отдельный пул · max-thread-pool-size: 20 общий пул 200 — не занят вовсе пул оплаты 20 — висят очередь 50 51-й в очередь не влез — отказ висит вызов — висит только свой поток цена: другой поток, контекст не переезжает

Семафорная переборка потоков не заводит: она ставит потолок на то, сколько потоков общего пула может забрать один сосед, и живёт только вместе с тайм-аутом — зависший вызов держит поток общего пула. Отдельный пул отвязывает зависание от общего пула целиком, но каждый вызов едет через передачу в другой поток, и контекст запроса — 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, неизвестное поле, не прошла бизнес-проверка. С сетевым сбоем помог бы повтор, но здесь причина в самом сообщении, и десятая попытка кончится тем же, что первая. Хуже того, повторы не бесплатны: в партиции порядок, смещение фиксируется по очереди, и пока потребитель бьётся об одно сообщение, все следующие стоят за ним.

партиция 0: m1 отравлено · четыре попытки с паузой в секунду в партиции: m1 ✗ m2 m3 m4 смещение фиксируется по порядку — m2–m4 стоят за m1 попытка 1 ✗ пауза 1 с попытка 2 ✗ пауза 1 с попытка 3 ✗ пауза 1 с попытка 4 ✗ → .dlq 0 с 1 с 2 с 3 с m2, m3, m4: стоят три секунды за m1 идут дальше пауза в минуту и десять попыток — десять минут простоя всей партиции

Повторы отравленного сообщения — это простой партиции: пока 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 порядок обёрток зашит в библиотеке: аннотации на методе можно писать в любом порядке, аспекты всё равно выстроятся так:

Retry Circuit Breaker Rate Limiter Timeout Bulkhead внешний сервис

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

Разомкнулся выключатель посреди серии повторов — следующая попытка отвалится мгновенно, для этого повтор и игнорирует CallNotPermittedException. Если нужен другой порядок, его задают числами: retry-aspect-order, circuit-breaker-aspect-order и такие же свойства у остальных — чем больше число, тем внешнее обёртка.

Теперь худший случай. Возьмём вводную задачу — оплата, 40 вызовов в секунду, пул 200 — и типичные настройки «пять секунд, три попытки, пауза секунда с удвоением». Шлюз завис. Один вызов при таких настройках живёт так:

попытка 1 — тайм-аут 5000 мс пауза 1000 мс попытка 2 — тайм-аут 5000 мс пауза 2000 мс попытка 3 — тайм-аут 5000 мс

Тайм-аут 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 — аннотаций и таймаутов нет, три проверки красные.

Что почитать дальше