Устойчивость¶
Устойчивость (resilience)¶
Сеть ненадёжна. Звонок по сети — это не вызов функции: пакеты теряются, сосед тормозит, кто-то перезагружается посреди ответа. В монолите вызов соседнего модуля либо сработал, либо кинул исключение. В распределёнке появляется третий исход — повис. И он страшнее отказа, потому что молча съедает ресурсы.
Resilience-паттерны — это митигации того самого налога на распределённость (см. Macro-Architecture). Не убирают проблему, а ограничивают её радиус. Базовый принцип: проектируй под отказ, а не надейся, что не отвалится.
Дальше — паттерны снизу вверх: от защиты одного вызова до деградации всей системы.
Timeout
Timeout — это верхняя граница ожидания ответа, после которой вызов бросается, чтобы зависшая зависимость не держала твои ресурсы вечно.
Каталогизирован Michael Nygard в "Release It!" (2007) среди stability patterns. Сам приём старше и единого автора у него нет.
Зачем: - Без timeout поток (или горутина, или соединение) висит, пока его кто-то не убьёт. - Пул соединений конечен. Несколько зависших вызовов — и пул исчерпан, сервис лежит, хотя сам по себе здоров. - Timeout — фундамент. Circuit breaker и bulkhead без него не работают: нечего размыкать, если вызов никогда не завершается.
Грабли: - Дефолтные таймауты библиотек и сокетов часто бесконечные или огромные. Проверяй явно. - Частая ошибка — выставить только connect timeout и забыть про read/request timeout. Соединение установилось, а ответ не пришёл — и ты снова висишь. - Таймауты должны убывать вниз по цепочке вызовов. Если внешний gateway ждёт 1с, а сервис под ним — 3с, внешний отвалится раньше, чем внутренний успеет ответить. Время сгорело зря.
Retry с jitter
Retry — это повтор упавшего вызова в надежде, что отказ был временный (сеть моргнула, сосед на секунду перегрузился).
Наивный retry опасен. Если сервис лёг и тысяча клиентов разом начали ретраить с одинаковым интервалом — они синхронизируются и бьют залпами. Это retry storm (он же thundering herd): сервис только встаёт, его тут же добивает синхронная волна повторов.
Лечение — exponential backoff and jitter. Backoff растёт экспоненциально (100мс, 200мс, 400мс), jitter добавляет случайный разброс, чтобы клиенты расползлись по времени и не синхронизировались. Описано Marc Brooker (AWS) в 2015; сам backoff восходит к Ethernet/CSMA.
Без jitter: все клиенты ретраят в одни и те же моменты → залпы
client A: |----X----X----X
client B: |----X----X----X
client C: |----X----X----X
^^ ^^ ^^ синхронные волны добивают сервис
С jitter: разброс размазывает нагрузку
client A: |---X------X--------X
client B: |------X-------X--X
client C: |--X--------X----X
Когда подходит:
- Только идемпотентные операции. Повтор GET или PUT безопасен. Повтор не-идемпотентного
POST "создать платёж" без ключа идемпотентности спишет деньги дважды.
- Только ретраебельные ошибки: таймаут, 503, обрыв соединения. На 4xx (кроме 429) ретрай
бессмыслен — запрос невалиден, повтор ничего не изменит.
Грабли: - Backoff без jitter — половина решения. Залпы остаются, просто реже. - Нет retry budget: ретраи вложенных вызовов перемножаются. 3 повтора на каждом из 3 уровней — это до 27 запросов вниз. Ставь общий лимит и cap на число попыток. - Retry поверх circuit breaker, а не наоборот: breaker должен видеть исчерпанные попытки как один отказ, иначе размыкание не сработает.
Как обеспечить idempotency — см. секцию «Данные и согласованность», Idempotent consumer.
Circuit Breaker
Circuit Breaker — это stateful-обёртка вокруг сетевого вызова, которая размыкается после порога отказов, какое-то время отвечает отказом сразу (fail fast), потом пробует, не восстановилась ли зависимость.
Назвал и описал Michael Nygard в "Release It!" (2007). Популяризировал Netflix Hystrix, позже дистиллировал Martin Fowler в bliki (2014). Hystrix часто называют источником — это неправда, паттерн назвали раньше.
Идея простая: не долби мёртвого. Если сосед уже падает, продолжать слать ему запросы — значит копить очередь, жечь таймауты и тащить за собой собственную деградацию. Breaker размыкает цепь и возвращает ошибку (или fallback) мгновенно, не доходя до сети.
Три состояния:
отказов больше порога
┌─────────────────────────────────────────┐
│ v
┌─────────┐ ┌─────────┐
│ CLOSED │ │ OPEN │
│ пускаем │ │fail fast│
│ трафик │ │ сразу │
└─────────┘ └─────────┘
^ │
│ │ прошёл cooldown
│ пробный запрос v
│ прошёл OK ┌───────────┐
└────────────────────────────────────│ HALF-OPEN │
│ 1 пробный │
пробный упал ───────────────> │ запрос │
(назад в OPEN) └───────────┘
- closed — нормальный режим. Трафик идёт, breaker считает отказы. Перешли порог — open.
- open — fail fast. Запросы отбиваются мгновенно, до сети не доходят. Висит на cooldown.
- half-open — после cooldown пропускаем один пробный запрос. Прошёл — closed, зависимость жива. Упал — обратно в open, ждём ещё.
Чем отличается от retry: - Retry говорит "попробуй ещё раз". Breaker говорит "перестань пробовать". - Это не помощник для retry, а его противоположность по смыслу. Retry борется с единичным сбоем, breaker — с устойчивым отказом зависимости.
Грабли: - Воспринимают breaker как продвинутый retry. Его суть — перестать звонить, защитить и себя, и падающего соседа, а не долбить настойчивее. - Порог и cooldown надо подбирать. Слишком чувствительный — размыкается на случайных всплесках. Слишком вялый — поздно реагирует, деградация уже расползлась. - В half-open пускай один-два пробных запроса. Если влить полный трафик — добьёшь зависимость, которая только начала вставать.
Bulkhead
Bulkhead — это изоляция ресурсов (отдельные пулы потоков или соединений на зависимость), чтобы исчерпание или отказ в одном отсеке не утопил всю систему.
Назвал Michael Nygard в "Release It!" (2007). Метафора — водонепроницаемые переборки корабля: пробили один отсек, вода в него и набралась, корабль на плаву.
Идея простая: если все вызовы ко всем зависимостям идут через один общий пул, то одна тонущая зависимость забивает этот пул зависшими вызовами. И сервис не обслужит даже те запросы, что не трогают сломанного соседа. Один больной утащил здоровых.
Без bulkhead — общий пул на всё:
┌────────────────────────────┐
│ shared thread pool │
│ [P][P][P][P][P][P][P][P] │ payment висит → весь пул в payment
└────────────────────────────┘ запросы к catalog ждут свободный слот → лежит всё
С bulkhead — пул на зависимость:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ payment │ │ catalog │ │ shipping │
│ [P][P][P]│ │ [ ][ ][ ]│ │ [ ][ ][ ]│ payment забит, но catalog/shipping
└──────────┘ └──────────┘ └──────────┘ живут на своих слотах
Чем отличается от circuit breaker: - Breaker детектит отказ и размыкает. Bulkhead ничего не детектит — он заранее ограничивает blast radius, разделяя ресурсы. - Они дополняют друг друга. Bulkhead не даёт тонущей зависимости сожрать чужие ресурсы, breaker перестаёт к ней ходить вообще.
Грабли: - Путают с circuit breaker. Bulkhead — про изоляцию ресурсов, не про детекцию. - Думают, что нужны Hystrix-style пулы потоков. Изоляция через семафоры или квоты тоже считается bulkhead — и дешевле, без накладных на лишние потоки.
Rate Limiting
Rate Limiting — это ограничение числа запросов от клиента или к системе в окне времени, обычно через token bucket или leaky bucket, чтобы защитить сервис от перегруза.
Единого автора нет. Алгоритмы token bucket / leaky bucket — из traffic shaping (80-90-е), дальше разошлись по API-gateway и облачным докам.
Зачем: - Защита от перегруза: один шумный клиент (или баг в ретраях) не должен положить сервис всем. - Fair usage: честное распределение ёмкости между потребителями. - Защита кошелька: ограничить вызовы к платной внешней зависимости.
Где ставить: - На gateway / edge — отбиваем абьюз до того, как он дошёл до сервисов (см. Macro-Architecture про топологию входа). - Внутри — между сервисами, как защита конкретной зависимости.
Грабли:
- Путают с backpressure. Rate limiting — это статичная внешняя политика: превысил лимит — в
отказ (обычно 429 Too Many Requests). Backpressure — это сигнал обратной связи от
перегруженного потребителя, динамика, а не фиксированный порог.
- Throttling, quotas, load shedding — рядом, но не одно и то же. Quota — лимит на длинном окне
(день/месяц). Load shedding — сброс части нагрузки под давлением, чтобы выжить.
Backpressure
Backpressure — это управление потоком в асинхронной обработке, когда потребитель сигналит производителю, сколько готов принять, чтобы тот притормозил, а не завалил его безграничным буфером.
Стандартизирован инициативой Reactive Streams (Netflix, Pivotal, Lightbend и др.), спека 1.0.0 для JVM — 2015. Сама идея старше, Reactive Streams её причесали для JVM.
Идея простая: потребитель тянет (pull), а не производитель пушит сколько влезет. Потребитель выдаёт demand — "дай мне 10 штук", производитель шлёт не больше. Перегруза буфера не возникает, потому что производитель физически не отправит больше запрошенного.
Если backpressure нет: - Производитель быстрее потребителя — разница копится в буфере. - Буфер безграничный — OOM, сервис падает. - Буфер ограниченный — дропаешь сообщения или блокируешься. Оба исхода плохие.
Чем отличается от rate limiting: - Rate limiting режет вход по фиксированной политике, не глядя на состояние потребителя. - Backpressure — обратная связь: производитель замедляется ровно настолько, насколько отстаёт потребитель. Спрос управляет потоком, а не статичный cap.
Note
В async-передаче через broker (Kafka, см. Macro-Architecture) backpressure встроена самой моделью:
consumer тянет в своём темпе, producer пишет в лог независимо. Лаг копится в брокере, а не в
памяти потребителя — это и есть естественный буфер с обратным давлением.
Dead Letter Queue
Dead Letter Queue — это отдельный канал, куда messaging-система отправляет сообщения, которые не удалось доставить или обработать, чтобы их можно было разобрать, а не потерять молча.
Канонически — Dead Letter Channel у Gregor Hohpe и Bobby Woolf в "Enterprise Integration Patterns" (2003).
Зачем: - Одно ядовитое сообщение (poison message) не должно блокировать всю очередь. Падает на обработке, ретраится, снова падает — и держит partition, пока кто-то не вмешается. - DLQ убирает такое сообщение вбок после N неудачных попыток. Очередь едет дальше, проблемное лежит отдельно для разбора.
Что делает: - После порога ретраев потребитель (или сам брокер) перекладывает сообщение в DLQ. - Дальше — разбор: лог ошибки, алерт, ручной или автоматический redrive обратно в основную очередь после фикса.
Грабли: - DLQ без мониторинга — свалка. Сообщения копятся, на них никто не смотрит, данные тихо теряются — ровно то, от чего DLQ должна была спасти. - Нет redrive-механизма: после фикса бага нечем вернуть накопившееся в обработку. - Не путай "не удалось обработать" и "невалидное навсегда". Временный сбой стоит ретраить, битое по схеме — сразу в DLQ, ретраи не помогут.
Fallback и graceful degradation
Fallback — это запасной ответ, когда основной путь недоступен: вместо ошибки отдаёшь что-то осмысленное и деградируешь по функциональности, а не падаешь целиком.
Graceful degradation — это свойство системы терять функции по краям, сохраняя ядро рабочим, вместо отказа всё-или-ничего.
Идея простая: не всякий отказ зависимости должен ронять запрос. Часто можно отдать ухудшенный, но рабочий результат.
Примеры: - Сервис рекомендаций лёг — показываешь дефолтную подборку "популярное", а не пустую страницу. - Сервис цен недоступен — показываешь товар с пометкой "цена уточняется", а не 500. - Профиль не отвечает — рендеришь страницу с плейсхолдером вместо аватара.
Связка с circuit breaker: - Открытый breaker — естественное место для fallback. Раз цепь разомкнута и в сеть не идём, отдаём заранее заготовленный запасной ответ мгновенно.
Грабли: - Fallback тоже может упасть. Если запасной путь дёргает другую зависимость — она тоже отвалится в плохой день. Держи fallback максимально дёшевым и локальным (статика, кэш, дефолт). - Тихая деградация опасна. Отдал fallback — пометь это (метрика, header, лог), иначе деградация невидима и ты узнаешь о ней последним.
Cache для горячих чтений
Cache — это хранилище горячих данных ближе к потребителю (например Redis), чтобы не бить
по медленной или хрупкой зависимости на каждый запрос.
В разрезе resilience кэш — это не только про скорость. Это буфер между тобой и зависимостью, которая может отвалиться.
Зачем (в разрезе устойчивости): - Снимает нагрузку с downstream-сервиса и его БД. Меньше запросов вниз — меньше шансов перегрузить. - При отказе зависимости отдаёшь данные из кэша (stale read) как форму fallback. Лучше слегка устаревшее, чем 500.
Грабли:
- Cache stampede (он же dogpile): ключ протух, сто запросов разом промахнулись и кинулись в БД
за одним и тем же. Лечится single-flight (один идёт за данными, остальные ждут его результат)
или вероятностным ранним обновлением.
- Инвалидация — известная боль. Решаешь, что важнее: свежесть (короткий TTL, чаще ходишь вниз)
или устойчивость (длинный TTL, чаще отдаёшь stale).
- Кэш — это ещё одна зависимость, которая сама может лечь. Решай заранее, что делать на промахе
или отказе Redis: идти вниз или деградировать.
Как это собирается вместе
Паттерны не выбирают по одному — они складываются слоями вокруг каждого сетевого вызова. Типичная обёртка исходящего вызова, снаружи внутрь:
request
│
▼
[ bulkhead ] ← свой пул на эту зависимость, чужие вызовы не страдают
│
▼
[ circuit breaker ] ← разомкнут? сразу fallback, в сеть не идём
│ closed
▼
[ timeout ] ← ждём не дольше N, иначе бросаем
│
▼
[ retry+jitter ] ← на ретраебельной ошибке, с backoff, в рамках budget
│
▼
сетевой вызов ──fail──> [ fallback ] ← кэш / дефолт / деградация
Порядок важен: breaker снаружи retry (видит исчерпанные попытки как один отказ), timeout внутри (каждая попытка ограничена своим временем). В проде это обычно не пишут руками на каждый вызов — выносят в service mesh (sidecar делает retry/timeout/breaker прозрачно, см. Macro-Architecture) или в библиотеку-обёртку.