Resilience-паттерны: circuit breaker, retry, timeout, bulkhead

Как не дать одному отказу обрушить систему: timeout, retry с backoff, circuit breaker, bulkhead, fallback и graceful degradation

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

Вторая статья серии «System design: resilience» и её хребет: остальные механизмы серии стоят вокруг этих предохранителей. Все числа — со стенда architecture/resilience/patterns, где каскад воспроизводится под реальной нагрузкой, а сами механизмы проверяются детерминированно (время инъектируется).

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

В статье

Каскадный отказ

Начнём с того, от чего защищаемся. Худший сценарий — не «зависимость упала», а «зависимость отвечает медленно». Быстрый отказ вы обработаете; медленный — занимает ресурс.

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

На стенде это воспроизводится буквально. Зависимость висит 200 мс и падает, сервис с пулом 20 воркеров; генератор подаёт нагрузку в open-loop с фактическим темпом около 1850 rps (номинальные 4000 недостижимы — Sleep в генераторе не настолько точен, поэтому стенд меряет темп, а не декларирует его). Один прогон:

режим отдано ответов отклонено ответов/с p50 p99
без предохранителя 120 1880 ~99/с 200 мс 201 мс
с circuit breaker 1646 354 ~1527/с 0 мс 200 мс

Без предохранителя пропускная способность упирается в арифметику: пул / латентность = 20 / 200 мс ≈ 100 запросов в секунду, всё остальное отклоняется. Сервис жив формально, но полезной работы почти не делает — и это при том, что зависимость всего лишь медленная.

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

Таймаут

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

Практика, которая важнее самого факта таймаута:

  • Бюджет времени, а не набор произвольных чисел. У запроса есть общий срок (скажем, 300 мс); дочерние вызовы должны укладываться в остаток, а не иметь каждый свои «на глазок» две секунды. Иначе сумма таймаутов превышает терпение пользователя.
  • Таймаут меньше, чем у вызывающего. Если ваш клиент ждёт 1 с, а вы ждёте зависимость 3 с, вы работаете вхолостую: ответ уже никому не нужен.
  • Таймаут не только на HTTP. Пулы соединений, запросы к БД, ожидание блокировки — без предела ждёт любой из них.

Ориентир для выбора числа — не среднее, а хвост распределения: p99, а не среднееСкоро.

Retry

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

Замер на стенде (зависимость лежит, 1000 запросов, наивно по 3 повтора):

стратегия ударов по зависимости усиление
наивный повтор 4000 ×4.0
с retry-бюджетом 1100 ×1.10

Три условия, без которых повтор опаснее пользы:

  1. Идемпотентность. Повторять можно только то, что безопасно выполнить дважды. Иначе повтор после таймаута создаёт второй заказ или второе списание (как делать операции идемпотентными — в гарантиях доставки и идемпотентности).
  2. Backoff с джиттером. Экспоненциальная задержка с потолком, а к ней — полный джиттер: случайная задержка в [0, backoff]. Без джиттера все, кто упал одновременно, вернутся тоже одновременно — синхронной волной ровно в момент, когда зависимость пытается подняться.
  3. Бюджет повторов. Ограничение доли повторов к обычному трафику (приём из gRPC retry throttling): обычные запросы начисляют бюджет, повторы его тратят. Кончился — повторов нет. Именно он превращает ×4.0 в ×1.10.

Отдельно: повторять имеет смысл не всё. На «неверный запрос» (4xx) повтор бессмыслен — ответ не изменится; повторяют то, что похоже на временный сбой.

Circuit breaker

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

Три состояния:

  • Closed — пропускаем всё, считаем отказы;
  • Open — после порога отказов мгновенно отвечаем ошибкой или fallback, не вызывая зависимость: она получает передышку, а мы не держим воркеров;
  • Half-open — спустя паузу пропускаем несколько пробных вызовов. Удались — закрываемся; провалились — снова размыкаем.

Именно half-open отличает предохранитель от простого выключателя: система сама проверяет, вернулась ли зависимость, и не требует ручного вмешательства.

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

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

Bulkhead

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

Без переборок все вызовы делят общий пул: жадный или деградировавший потребитель забирает все слоты, и остальные ждут, хотя их зависимости здоровы. С переборками у каждого свой лимит: заполненный отсек отвечает отказом, соседний продолжает работать. На стенде это проверяется тестом: полный отсек heavy возвращает «мест нет», отсек light в тот же момент свободен.

Разделять стоит по тому, что может деградировать независимо: по зависимостям, по классам операций (дешёвые и дорогие), по тарифам клиентов.

Fallback и деградация

Предохранитель ответил «нельзя» — что отдать пользователю? Здесь и появляется graceful degradation: частичный ответ вместо ошибки.

  • Кэшированное или устаревшее значение вместо свежего;
  • ответ без необязательной части (лента без блока рекомендаций);
  • значение по умолчанию, если оно осмысленно.

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

Чего здесь нет

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

  • Rate limiting — сколько запросов вообще впустить (защита входа, а не вызова наружу).
  • Backpressure и load sheddingготовится, с 16 сентября — что делать, когда сама система не успевает: ограниченные очереди, сброс избытка, адаптивный лимит конкурентности.

Ещё два соседа полезны рядом: повтор без outbox/inbox и саги легко ломает согласованность бизнес-процесса, а корректное завершение под нагрузкой — это graceful shutdown: дорабатываем принятое, новое не берём.

Checklist

  • У каждого сетевого вызова есть таймаут, и он выведен из бюджета времени запроса, а не назначен на глаз.
  • Повторяется только идемпотентное; есть backoff с джиттером и бюджет повторов.
  • Вызовы к нестабильным зависимостям обёрнуты предохранителем; порог и пауза подобраны под реальную зависимость, half-open работает.
  • Пулы разделены переборками: один потребитель не выбирает весь ресурс.
  • Для каждого предохранителя решено, что отдавать при срабатывании, и это согласовано с продуктом.
  • Проверено под нагрузкой, а не в теории: медленная (не упавшая!) зависимость — самый показательный сценарий.

Первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии