Перейти к содержанию

Устойчивость

Устойчивость (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) или в библиотеку-обёртку.