В распределённой системе отказы — норма, а не исключение. Опасен не сам сбой одного сервиса, а каскад: медленный зависимый сервис исчерпывает пулы, очереди растут, и падает вся система. Resilience-паттерны — это набор предохранителей, которые локализуют отказ: лучше деградировать частично, чем обрушиться целиком.
Вторая статья серии «System design: resilience» и её хребет: остальные механизмы серии стоят вокруг этих предохранителей. Все числа — со стенда architecture/resilience/patterns, где каскад воспроизводится под реальной нагрузкой, а сами механизмы проверяются детерминированно (время инъектируется).
В статье
- Каскадный отказ — как одна медленная зависимость роняет всё
- Таймаут — главный антипаттерн: его отсутствие
- Retry — когда повтор спасает, а когда добивает
- Circuit breaker — быстрый отказ вместо медленного
- Bulkhead — изоляция, чтобы жадный не съел всё
- Fallback и деградация — частичный ответ вместо ошибки
- Чего здесь нет — границы с соседями по серии
- Checklist
Каскадный отказ
Начнём с того, от чего защищаемся. Худший сценарий — не «зависимость упала», а «зависимость отвечает медленно». Быстрый отказ вы обработаете; медленный — занимает ресурс.
Механика: каждый запрос берёт воркера из пула и уходит в зависимость. Та не отвечает — воркер занят. Приходят новые запросы, берут оставшихся воркеров, тоже виснут. Пул кончается, и сервис перестаёт отвечать всем — включая тех, кому эта зависимость была не нужна. Отказ одного компонента стал отказом системы.
На стенде это воспроизводится буквально. Зависимость висит 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 |
Три условия, без которых повтор опаснее пользы:
- Идемпотентность. Повторять можно только то, что безопасно выполнить дважды. Иначе повтор после таймаута создаёт второй заказ или второе списание (как делать операции идемпотентными — в гарантиях доставки и идемпотентности).
- Backoff с джиттером. Экспоненциальная задержка с потолком, а к ней — полный джиттер: случайная задержка в
[0, backoff]. Без джиттера все, кто упал одновременно, вернутся тоже одновременно — синхронной волной ровно в момент, когда зависимость пытается подняться. - Бюджет повторов. Ограничение доли повторов к обычному трафику (приём из 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 работает.
- Пулы разделены переборками: один потребитель не выбирает весь ресурс.
- Для каждого предохранителя решено, что отдавать при срабатывании, и это согласовано с продуктом.
- Проверено под нагрузкой, а не в теории: медленная (не упавшая!) зависимость — самый показательный сценарий.
Первоисточники
- Michael Nygard, «Release It!» — откуда пошли circuit breaker и bulkhead как инженерные термины.
- gRPC retry throttling — бюджет повторов.
- Timeouts, retries and backoff with jitter (AWS) — почему джиттер обязателен.
- Все числа — со стенда architecture/resilience/patterns.
Комментарии