Архитектурные паттерны Устойчивость: circuit breaker, retry, bulkhead, backpressure, деградация
0%

Устойчивость: circuit breaker, retry, bulkhead, backpressure, деградация

Устойчивость: circuit breaker, retry, bulkhead, backpressure, деградация

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

Устойчивость (resilience) — это не «отсутствие отказов». Это свойство системы сохранять приемлемое поведение при отказе своих частей, локализуя ущерб и восстанавливаясь без ручного вмешательства. Отсюда весь набор паттернов ниже. Каждый из них — способ ответить на один и тот же вопрос: что делать, когда вызов не отвечает так, как обещал?

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


1. Почему распределённые системы падают целиком

Классическая интуиция «сломался один компонент — потеряли одну функцию» в распределённых системах неверна. Отказ распространяется, и у распространения есть конкретный физический механизм: ограниченный ресурс, который занимает ожидание.

Представьте сервис checkout с пулом из 200 обработчиков. В нормальном режиме вызов сервиса recommendations занимает 20 мс. По закону Литтла (L = λ · W: среднее число заявок в системе равно интенсивности входа, умноженной на среднее время пребывания) при 1000 запросов/с занято 1000 × 0,02 = 20 обработчиков. Запас девятикратный, всё прекрасно.

Теперь recommendations деградирует до 5 секунд. Интенсивность входа не изменилась — пользователи не знают о вашей аварии. L = 1000 × 5 = 5000. Нужно 5000 обработчиков, есть 200. Пул исчерпан за 200 мс. И теперь checkout не отвечает вообще ни на что — даже на запросы, которые к рекомендациям не обращаются. Отказ некритичной функции стал отказом критической.

Дальше включается вторая петля. Клиенты, не дождавшись ответа, ретраят. Нагрузка становится 2000 запросов/с, потом 3000. Балансировщик, видя, что инстансы медленные, начинает считать их нездоровыми и выводить из ротации — оставшиеся получают ещё больше трафика. Это каскадный отказ, и его подробный разбор есть в 22-й главе Google SRE Book, «Addressing Cascading Failures».

Самое неприятное свойство таких отказов исследователи назвали метастабильностью: система, попав в аварийный режим, остаётся в нём даже после того, как исходная причина устранена. Ретраи и переполненные очереди сами себя поддерживают. Формально это описано в работе «Metastable Failures in Distributed Systems» (HotOS ‘21, Bronson et al.) и в её продолжении «Metastable Failures in the Wild» (OSDI ‘22), где разобраны публичные постмортемы крупных провайдеров.

Метастабильный отказ: полезная пропускная способность обрушивается после превышения ёмкости и не восстанавливается сама

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

Отсюда — карта паттернов. Все они делятся на четыре стратегии.


2. Таймаут — фундамент, без которого не работает ничего

Ни один паттерн из этой статьи не имеет смысла без таймаутов. Размыкатель не может «увидеть» отказ, если вызов висит бесконечно; переборка не освобождает слот; ретрай не запускается. Вызов без таймаута — это не вызов, это утечка ресурса.

Три правила, которые нарушают почти все.

Правило 1: таймаут задаётся на каждом уровне, и уровней больше, чем кажется. Connect timeout, TLS handshake timeout, время до первого байта, полное время запроса, время idle в пуле, время ожидания слота в пуле соединений. Библиотеки по умолчанию оставляют половину из них бесконечными. Проверьте свои — это упражнение на полчаса, которое однажды спасёт вам квартал.

Правило 2: таймаут вызывающего должен быть больше таймаута вызываемого. Если API-шлюз ждёт 1 с, а сервис за ним ждёт свою БД 3 с, то шлюз всегда сдаётся первым, а сервис ещё две секунды делает работу, результат которой некому принять. Это чистое сжигание ёмкости — та самая «работа впустую» из схемы выше.

Правило 3: таймаут не константа, а бюджет. Правильная модель — дедлайн, распространяющийся по всей цепочке вызовов (deadline propagation). Клиент говорит: «ответ нужен не позже, чем через 800 мс». Каждый следующий сервис получает не «свои 500 мс», а остаток бюджета и вычитает из него своё время. Так это устроено в gRPC — см. «gRPC and Deadlines» — и в Go через context.Context.

import time
from dataclasses import dataclass

@dataclass
class Deadline:
    """Бюджет времени на весь пользовательский запрос, а не на отдельный вызов."""
    expires_at: float          # монотонное время, когда ответ станет бесполезен

    @classmethod
    def after(cls, budget_s: float) -> "Deadline":
        return cls(expires_at=time.monotonic() + budget_s)

    def remaining(self) -> float:
        return max(0.0, self.expires_at - time.monotonic())

    def expired(self) -> bool:
        return self.remaining() <= 0

    def sub(self, reserve_s: float = 0.05) -> float:
        """Таймаут для конкретного вызова: остаток минус резерв на сборку ответа.
        Резерв нужен, чтобы успеть вернуть деградированный ответ, а не 504."""
        return max(0.0, self.remaining() - reserve_s)


def handle_checkout(req, deadline: Deadline):
    # Критичный вызов: если бюджета не осталось — честно отказываем сразу,
    # не тратя ещё одну сетевую поездку.
    if deadline.expired():
        raise DeadlineExceeded()

    price = pricing.get(req.cart, timeout=deadline.sub())

    # Некритичный вызов: отдаём ему только часть остатка и никогда не более 80 мс.
    try:
        recs = recommendations.get(req.user, timeout=min(0.08, deadline.sub()))
    except (TimeoutError, DeadlineExceeded):
        recs = []                      # деградация: страница без рекомендаций

    return render(price, recs)

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

Как выбрать значение таймаута? Не «по ощущению» и не «на всякий случай побольше». Отправная точка — p99.9 нормальной латентности зависимости плюс запас, но не больше, чем время, за которое пользователь ещё готов ждать. Если p99.9 равен 300 мс, а вы ставите таймаут 10 с, вы не «страхуетесь» — вы гарантируете, что при аварии ваш пул будет держать мёртвые соединения 10 секунд. Слишком длинный таймаут опаснее слишком короткого: короткий даёт быстрый отказ, который можно обработать, длинный — исчерпание ресурсов, которое обработать нельзя.


3. Ретраи: как повторить и не устроить лавину

Ретрай — самый простой паттерн и самый частый источник аварий. Причина в том, что он увеличивает нагрузку ровно тогда, когда система её меньше всего выдерживает.

3.1. Что вообще можно ретраить

Прежде чем говорить о backoff, нужно ответить на два вопроса.

Идемпотентна ли операция? Ретрай GET безопасен. Ретрай POST /payments без ключа идемпотентности — это второе списание с карты. Механика ключей идемпотентности, дедупликации и outbox подробно разобрана в статье «Saga, распределённые транзакции, outbox и идемпотентность»; здесь достаточно правила: если операция не идемпотентна, ретрай запрещён, пока вы не сделаете её идемпотентной.

Ретраебельна ли ошибка? Повторять имеет смысл только то, что может пройти со второй попытки:

Ошибка Ретраить? Почему
Connection refused, reset Да Инстанс перезапускался, попадём в другой
Таймаут Осторожно Запрос мог выполниться — нужна идемпотентность
429 Too Many Requests Да, по Retry-After Явное указание сервера
503 Service Unavailable Да, с backoff Временная перегрузка
500 Internal Server Error Обычно нет Скорее всего, детерминированный баг
400, 404, 422 Никогда Повтор даст тот же ответ, это трата ёмкости
401, 403 Нет (кроме обновления токена) Повтор ничего не изменит

3.2. Backoff и джиттер

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

Канонический разбор вариантов джиттера — заметка AWS «Exponential Backoff and Jitter» и глава AWS Builders’ Library «Timeouts, retries, and backoff with jitter». Их вывод, подтверждённый симуляцией: full jitter — задержка равномерно случайна на отрезке [0, cap] — даёт меньше всего конкуренции и завершает работу не медленнее «умных» вариантов.

import random, time

def retry(call, *, attempts=3, base=0.05, cap=2.0, budget=None, deadline=None):
    """Ретрай с экспоненциальным backoff и full jitter.

    base   — базовая задержка (50 мс)
    cap    — потолок задержки (2 с), чтобы не улететь в минуты
    budget — общий бюджет ретраев сервиса (см. 3.3)
    """
    last = None
    for attempt in range(attempts):
        if deadline is not None and deadline.expired():
            raise DeadlineExceeded() from last
        try:
            result = call(timeout=deadline.sub() if deadline else None)
            if budget:
                budget.record_success()
            return result
        except RetryableError as e:
            last = e
            if attempt == attempts - 1:
                raise
            # Бюджет исчерпан — не ретраим вообще, отдаём ошибку сразу.
            if budget and not budget.allow_retry():
                raise
            # full jitter: равномерно на [0, min(cap, base * 2^attempt)]
            delay = random.uniform(0, min(cap, base * (2 ** attempt)))
            if deadline is not None and delay > deadline.remaining():
                raise DeadlineExceeded() from e
            time.sleep(delay)
    raise last

Сложность и границы. Число попыток k, максимальная задержка cap. Худшее время вызова ограничено сверху k · timeout + Σ delay ≈ k · timeout + k · cap. Это число обязано быть меньше дедлайна пользователя — иначе ретраи гарантированно бесполезны: пользователь уже ушёл, а вы всё ещё греете чужой сервис. Память O(1). Практический ориентир: 2–3 попытки, не больше. Идея «поставлю 10 ретраев, надёжнее будет» — прямой путь к десятикратному усилению нагрузки.

3.3. Усиление ретраев и бюджет

Самая коварная особенность ретраев — они перемножаются по слоям. Мобильный клиент ретраит 3 раза, шлюз ретраит 3 раза, сервис ретраит вызов БД 3 раза. Нижний уровень получает 3 × 3 × 3 = 27 запросов на один пользовательский. Ваша БД, лежащая под нагрузкой, получает 27-кратный удар в момент, когда ей нужно ровно обратное.

Два лечения, применять оба.

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

Бюджет ретраев. Ретраи разрешены, только пока их доля не превышает X % от основного трафика (типично 10 %). Реализуется token bucket: успешный запрос кладёт токены, ретрай их тратит. Когда система здорова — ретраев мало, токенов хватает. Когда система лежит — успехов нет, токены кончились, ретраи автоматически выключились. Так работает adaptive режим в AWS SDK и retry budget в gRPC/Envoy.

class RetryBudget:
    """Token bucket: ретраи не превышают ratio от успешного трафика.
    Все операции O(1) по времени и памяти."""

    def __init__(self, ratio=0.1, capacity=100.0, min_per_sec=1.0):
        self.ratio = ratio            # 10 % ретраев от успехов
        self.capacity = capacity
        self.tokens = capacity
        self.min_per_sec = min_per_sec

    def record_success(self):
        self.tokens = min(self.capacity, self.tokens + self.ratio)

    def allow_retry(self) -> bool:
        if self.tokens < 1.0:
            return False              # бюджет исчерпан — глушим все ретраи
        self.tokens -= 1.0
        return True

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


4. Circuit breaker: перестать стучаться в закрытую дверь

Ретраи борются с временными сбоями. Но если зависимость лежит уже минуту, каждый вызов к ней — это гарантированно потраченный таймаут, занятый слот пула и задержанный ответ пользователю. Размыкатель (circuit breaker) превращает медленный отказ в быстрый.

Аналогия из электрики (её и использовал Майкл Найгард, введя паттерн в книге «Release It!»): предохранитель не чинит короткое замыкание, он отсекает повреждённый участок, чтобы не сгорел дом. Канонический разбор с кодом — заметка Мартина Фаулера «CircuitBreaker».

Три состояния и переходы между ними:

Ключевые детали, без которых размыкатель приносит вред.

Минимальный объём выборки. Порог «50 % ошибок» на выборке из двух запросов срабатывает от одной случайной ошибки. Всегда задавайте minimumNumberOfCalls (в resilience4j — именно так): не размыкать, пока не набрано хотя бы 20–100 вызовов в окне. Иначе низкотрафиковый эндпоинт будет мигать размыкателем без причины.

Медленные вызовы считаются ошибками. Зависимость, отвечающая 200-ми за 5 секунд, вреднее, чем отдающая 503 за 5 мс. В resilience4j для этого есть slowCallRateThreshold — учитывайте его, иначе размыкатель проспит самый опасный вид деградации.

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

Половина открытого состояния должна быть ограничена. Если в Half-Open пропустить весь трафик, только что восстановившаяся зависимость мгновенно получит полную нагрузку и снова ляжет. Пропускайте единицы запросов.

import time, threading
from enum import Enum

class State(Enum):
    CLOSED = "closed"; OPEN = "open"; HALF_OPEN = "half_open"

class CircuitBreaker:
    """Размыкатель со скользящим окном по числу вызовов.

    Сложность: allow()/record() — O(1) по времени, O(window) по памяти
    (кольцевой буфер булевых исходов). Потокобезопасен через один мьютекс —
    для типичных нагрузок это не бутылочное горлышко, но на очень горячем пути
    имеет смысл шардировать счётчики по потокам.
    """

    def __init__(self, window=100, min_calls=20, failure_rate=0.5,
                 open_timeout=15.0, half_open_probes=3):
        self.window = window
        self.min_calls = min_calls
        self.failure_rate = failure_rate
        self.open_timeout = open_timeout
        self.half_open_probes = half_open_probes

        self._ring = [None] * window     # None | True (ошибка) | False (успех)
        self._idx = 0
        self._failures = 0
        self._filled = 0
        self._state = State.CLOSED
        self._opened_at = 0.0
        self._probes_left = 0
        self._consecutive_opens = 0
        self._lock = threading.Lock()

    def allow(self) -> bool:
        with self._lock:
            if self._state is State.OPEN:
                # Экспоненциальный рост паузы: не долбим зависимость,
                # если она падает раз за разом.
                pause = min(self.open_timeout * (2 ** self._consecutive_opens), 300)
                if time.monotonic() - self._opened_at >= pause:
                    self._state = State.HALF_OPEN
                    self._probes_left = self.half_open_probes
                else:
                    return False
            if self._state is State.HALF_OPEN:
                if self._probes_left <= 0:
                    return False          # квота пробников исчерпана
                self._probes_left -= 1
            return True

    def record(self, failed: bool):
        with self._lock:
            if self._state is State.HALF_OPEN:
                if failed:
                    self._trip()          # один провал пробника — снова размыкаем
                elif self._probes_left == 0:
                    self._close()         # все пробники прошли — восстановились
                return

            old = self._ring[self._idx]
            if old is True:
                self._failures -= 1
            elif old is None:
                self._filled += 1
            self._ring[self._idx] = failed
            if failed:
                self._failures += 1
            self._idx = (self._idx + 1) % self.window

            if (self._filled >= self.min_calls
                    and self._failures / self._filled >= self.failure_rate):
                self._trip()

    def _trip(self):
        self._state = State.OPEN
        self._opened_at = time.monotonic()
        self._consecutive_opens = min(self._consecutive_opens + 1, 5)

    def _close(self):
        self._state = State.CLOSED
        self._ring = [None] * self.window
        self._idx = self._failures = self._filled = 0
        self._consecutive_opens = 0


def call_with_breaker(cb: CircuitBreaker, fn, fallback):
    if not cb.allow():
        return fallback()                 # быстрый отказ, пул не занимаем
    try:
        result = fn()
    except Exception:
        cb.record(failed=True)
        return fallback()
    cb.record(failed=False)
    return result

Чего размыкатель НЕ делает. Он не делает систему доступнее сам по себе — он лишь превращает «долго ждать и упасть» в «сразу упасть». Полезным это становится только вместе с fallback: кэш, значение по умолчанию, урезанный ответ. Размыкатель без продуманного fallback просто быстрее показывает пользователю ошибку. Это тоже полезно (ресурсы освобождаются), но недостаточно.

Trade-off честно. Размыкатель добавляет состояние на клиенте, а значит — ложные срабатывания (разомкнули здоровую зависимость из-за всплеска сетевых ошибок), рассинхронизацию между инстансами (у каждого своё окно) и новый класс инцидентов «почему трафик не идёт». В инфраструктурных решениях вроде Envoy эту роль выполняет outlier detection — исключение конкретного плохого хоста из балансировки, что часто точнее «размыкателя на весь апстрим».


5. Bulkhead: переборки против затопления пула

Название — из судостроения: корпус делят водонепроницаемыми переборками, чтобы пробоина затопила один отсек, а не судно. В софте отсек — это выделенная квота ресурса (потоков, соединений, слотов параллелизма) для конкретной зависимости или класса трафика.

Вернёмся к сценарию из первого раздела. Если у вызовов recommendations есть отдельный лимит в 10 одновременных вызовов, то деградация рекомендаций занимает 10 слотов и упирается в потолок. Остальные 190 обработчиков продолжают обслуживать оплату и каталог. Отказ локализован.

Переборки: общий пул против разделённого

Две реализации, разные по цене:

Semaphore bulkhead Thread-pool bulkhead
Механика счётчик разрешений, вызов в текущем потоке отдельный пул потоков на зависимость
Накладные расходы почти нулевые переключение контекста, память на стеки
Изоляция от «зависания» слабее: поток вызывающего всё равно занят полная: вызывающий может отвалиться по таймауту
Когда применять по умолчанию, для быстрых вызовов для клиентов без нормальной поддержки таймаутов

Именно эти два варианта предлагал Netflix Hystrix; его наследник resilience4j реализует оба. Сам Hystrix с 2018 года в режиме поддержки — в репозитории Netflix прямо рекомендует адаптивные решения вместо фиксированных лимитов, о чём ниже.

package resilience

import (
	"context"
	"errors"
	"time"
)

var ErrBulkheadFull = errors.New("отсек переполнен: быстрый отказ")

// Bulkhead ограничивает одновременные вызовы одной зависимости.
// Acquire — O(1); память — O(1) плюс буфер каналом на maxConcurrent.
type Bulkhead struct {
	slots   chan struct{} // ёмкость = максимум одновременных вызовов
	maxWait time.Duration // сколько ждать слот, прежде чем отказать
}

func NewBulkhead(maxConcurrent int, maxWait time.Duration) *Bulkhead {
	return &Bulkhead{slots: make(chan struct{}, maxConcurrent), maxWait: maxWait}
}

func (b *Bulkhead) Do(ctx context.Context, fn func(context.Context) error) error {
	// Ограниченное ожидание слота. Неограниченная очередь здесь —
	// главная ошибка: она просто переносит затопление на уровень выше.
	waitCtx, cancel := context.WithTimeout(ctx, b.maxWait)
	defer cancel()

	select {
	case b.slots <- struct{}{}:
		defer func() { <-b.slots }()
	case <-waitCtx.Done():
		return ErrBulkheadFull // быстрый отказ вместо накопления заявок
	}

	// Проверяем дедлайн ещё раз: пока ждали слот, ответ мог стать ненужным.
	if deadline, ok := ctx.Deadline(); ok && time.Now().After(deadline) {
		return context.DeadlineExceeded
	}
	return fn(ctx)
}

Как выбрать размер отсека. Снова закон Литтла: слоты = целевой RPS × нормальная латентность × запас. Для 200 rps и 50 мс это 200 × 0,05 = 10 слотов, с двукратным запасом — 20. Ставить «побольше на всякий случай» бессмысленно: слишком большой отсек не изолирует (при деградации он всё равно съест общий CPU и память), слишком маленький режет здоровый трафик на пиках. Проверяется нагрузочным тестом, а не интуицией.

Уровни переборок. Изолировать можно не только вызовы, но и трафик: отдельные инстансы (или отдельные пулы) для критичного и фонового; отдельные пулы соединений к БД для чтения и записи; шардирование клиентов по группам инстансов (cell-based architecture), чтобы «ядовитый» запрос одного клиента ронял одну ячейку, а не платформу. Это уже граница с темой масштабирования — см. «Кэширование и масштабирование».


6. Backpressure и отбрасывание нагрузки

Переборки и размыкатели защищают вас от зависимостей. Backpressure и load shedding защищают вас от ваших клиентов.

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

Backpressure — это сигнал вверх по потоку: «замедлись». В синхронном мире он выражается кодом 429/503 с Retry-After, в TCP — размером окна, в реактивных библиотеках (Reactive Streams, request(n)) — явным запросом порции элементов, в событийных системах — ростом лага консьюмера, на который реагирует автоскейлер (см. «Событийная архитектура»). Ключевое свойство: обратное давление работает, только если верхний уровень его слушает. Если клиент на 429 отвечает немедленным ретраем, вы получили не backpressure, а усиление.

Load shedding — отбрасывание части нагрузки, когда замедлиться уже некому. Классика — 21-я глава SRE Book, «Handling Overload», и глава AWS Builders’ Library «Using load shedding to avoid overload».

Практические приёмы, которые стоит знать по именам.

Приоритизация трафика. Не весь трафик равноценен. Разметьте запросы на классы (критичные пользовательские, обычные, фоновые/батчевые) и отбрасывайте снизу вверх. Метка приоритета должна распространяться по цепочке вызовов вместе с дедлайном — иначе нижние сервисы не смогут различать. В SRE Book это criticality.

Отбрасывание по возрасту заявки. Если запрос пролежал в очереди дольше, чем клиент готов ждать, выполнять его нельзя — только выбросить. Это принцип CoDel: следим не за длиной очереди, а за временем пребывания в ней. Длина очереди зависит от скорости обслуживания, время — нет; поэтому CoDel самонастраивается.

Adaptive LIFO. Контринтуитивный, но рабочий приём Facebook, описанный в «Fail at Scale»: в нормальном режиме очередь работает как FIFO (справедливо), а при перегрузке переключается на LIFO. Логика: при перегрузке старые заявки в начале очереди почти наверняка уже брошены клиентом, а свежие — ещё нет. LIFO спасает тех, кого ещё можно спасти.

Адаптивные лимиты параллелизма. Фиксированный лимит («200 одновременных») устаревает при каждом изменении кода и железа. Альтернатива — вычислять лимит по наблюдаемой латентности алгоритмом вроде AIMD или Gradient, как в библиотеке Netflix concurrency-limits: это ровно тот же TCP congestion control, применённый к RPC.

class AIMDLimiter:
    """Адаптивный лимит параллелизма: аддитивный рост, мультипликативное падение.
    Каждое обновление — O(1). Никаких магических констант в конфиге:
    лимит сам находит ёмкость сервиса и следует за ней при деплоях."""

    def __init__(self, limit=20, min_limit=4, max_limit=400,
                 rtt_threshold=0.25, backoff=0.9):
        self.limit = float(limit)
        self.min_limit, self.max_limit = min_limit, max_limit
        self.rtt_threshold = rtt_threshold   # признак перегрузки — рост латентности
        self.backoff = backoff

    def on_sample(self, rtt: float, in_flight: int, dropped: bool):
        if dropped or rtt > self.rtt_threshold:
            # Перегрузка: режем лимит мультипликативно — быстро отступаем.
            self.limit = max(self.min_limit, self.limit * self.backoff)
        elif in_flight >= self.limit * 0.9:
            # Упираемся в лимит, а латентность нормальная — пробуем дать больше.
            self.limit = min(self.max_limit, self.limit + 1.0 / self.limit)

Обратите внимание на асимметрию роста и падения: растём медленно (+1/limit за успешную выборку — то есть примерно +1 за полный «круг»), падаем быстро (×0,9 сразу). Это осознанный выбор: ошибка в сторону осторожности дешевле ошибки в сторону перегрузки.


7. Деградация: что именно показать пользователю

Все предыдущие паттерны отвечают на вопрос «как не сломаться». Деградация отвечает на вопрос «что показать, пока сломано» — и это единственная часть, которую видит бизнес.

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

Функция Зависимость Деградация Кто решил
Карточка товара Сервис цен Отказ страницы: цена обязательна Продукт
Карточка товара Отзывы Скрыть блок, страница остаётся Продукт
Карточка товара Рекомендации Показать статичную подборку из кэша Продукт
Оформление заказа Антифрод Принять заказ, пометить на ручную проверку Риски
Оформление заказа Склад Принять заказ с оговоркой о сроке Продукт
Оформление заказа Платёж Отказ: нельзя оформить без оплаты Финансы
Личный кабинет История Показать данные из реплики, баннер «данные могут отставать» Продукт

Заметьте: почти каждое решение здесь — не техническое. Инженер не имеет права в одиночку решить, можно ли принять заказ без проверки антифродом. Задача архитектора — вытащить этот вопрос на поверхность заранее, а не в момент аварии. Такие решения — хороший кандидат в ADR, о чём подробно в статье «Архитектурные решения: ADR, trade-offs, ATAM».

Иерархия fallback’ов — от лучшего к худшему:

  1. Свежие данные — нормальный ответ.
  2. Устаревшие данные из кэша (stale-while-error). Самый ценный fallback: пользователь чаще всего не заметит разницы. Требует, чтобы кэш умел отдавать просроченное значение при ошибке источника — в HTTP это stale-if-error (RFC 5861), в CDN и Nginx — proxy_cache_use_stale.
  3. Обобщённые данные — вместо персональных рекомендаций общий топ продаж; вместо точного остатка «в наличии».
  4. Пустота с честной пометкой — блок скрыт или показан плейсхолдер. Никогда не показывайте пустоту так, будто это настоящий ответ: «0 товаров в корзине» вместо «не удалось загрузить корзину» — это баг, а не деградация.
  5. Отказ функции — явная ошибка с понятным текстом и возможностью повторить.
  6. Отказ системы — то, чего мы избегаем всеми предыдущими пунктами.

Fallback тоже может упасть, и это классическая ловушка. Если fallback ходит в тот же кэш, который лежит, или запускает тяжёлый расчёт, вы получаете вторую волну отказа поверх первой. Правила: fallback должен быть дешевле основного пути, не должен делить с ним ресурсы и обязан быть протестирован под нагрузкой. Netflix формулировал это как «fallback must be a static-ish, local, cheap answer».

Ручки для человека. В аварии нужны переключатели, которые работают без деплоя: выключить некритичную функцию, включить агрессивное кэширование, снизить лимиты, перевести в режим «только чтение». Флаги должны читаться из хранилища, доступного при частичной аварии, и иметь безопасное значение по умолчанию на случай, если хранилище флагов тоже недоступно.


8. Как паттерны собираются вместе

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

приоритет / отбрасывание нагрузки   ← защищаем себя от входа
  └─ переборка (лимит параллелизма) ← защищаем свой пул
      └─ размыкатель                ← не стучимся в лежащую зависимость
          └─ ретрай                 ← повторяем временные сбои
              └─ таймаут            ← ограничиваем одну попытку
                  └─ сам вызов

Почему именно так:

  • Переборка снаружи размыкателя. Иначе очередь ожидающих слот копится ещё до того, как размыкатель успел разомкнуться.
  • Размыкатель снаружи ретрая. Если наоборот, размыкатель увидит одну «ошибку» вместо трёх неудачных попыток и будет размыкаться в три раза медленнее; а главное — ретраи будут идти даже при разомкнутой цепи.
  • Таймаут — самый внутренний. Он ограничивает попытку, а не всю операцию; общую операцию ограничивает дедлайн, который проверяется на каждом уровне.

И критично важная деталь: отказ размыкателя (Open) не должен считаться ретраебельной ошибкой. Иначе ретрай будет крутиться вхолостую по разомкнутой цепи, сжигая бюджет времени.

Отдельно стоит помнить, где эти паттерны уже реализованы, чтобы не писать их руками: service mesh и sidecar-прокси. Envoy даёт лимиты параллелизма (в терминологии Envoy это circuit_breakers, но по сути — переборка), outlier detection, ретраи с бюджетом и таймауты декларативно:

# Envoy: переборка + исключение больных хостов, без единой строки в коде сервиса
clusters:
  - name: recommendations
    connect_timeout: 0.1s
    circuit_breakers:                    # это bulkhead, не circuit breaker
      thresholds:
        - priority: DEFAULT
          max_connections: 200
          max_pending_requests: 50       # ограниченная очередь — не бесконечная
          max_requests: 200
          max_retries: 10                # потолок одновременных ретраев на кластер
    outlier_detection:                   # а вот это — настоящий размыкатель
      consecutive_5xx: 5
      interval: 10s
      base_ejection_time: 30s
      max_ejection_percent: 50           # не выбить больше половины кластера

Строка max_ejection_percent: 50 — важная защита от самоубийства: если «больными» выглядят все хосты, проблема, скорее всего, не в них, и выбивать весь кластер нельзя.


9. Наблюдаемость: устойчивость, которую не видно, не работает

Каждый паттерн обязан излучать метрики — иначе вы не отличите «защита сработала» от «сервис сломан».

Паттерн Что измерять Тревожный признак
Таймаут доля вызовов, завершённых по таймауту рост до единиц процентов
Ретрай доля ретраев от общего трафика, исчерпание бюджета ретраи > 10 % трафика
Размыкатель состояние, число переходов в Open, время в Open частые мигания = плохие пороги
Переборка утилизация отсека, число отказов «отсек полон» утилизация стабильно > 80 %
Load shedding доля отброшенных по классам приоритета отбрасывание критичного класса
Деградация доля ответов, отданных через fallback fallback стал нормой и никто не заметил

Последняя строка — самая недооценённая. Если сервис рекомендаций лежит третью неделю, а вы отдаёте кэш и никто не жалуется, у вас не устойчивость, а тихий отказ. Алертить надо и на «fallback используется дольше N минут».

Второе правило: отделяйте отказ зависимости от отказа вашего сервиса. Ответ «200 с деградацией» не должен выглядеть в метриках как обычный успех — иначе SLO покажет 99,99 % в момент, когда половина функциональности недоступна.


10. Проверка: устойчивость, которую не тестировали, не существует

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

Минимальный набор проверок:

  1. Юнит-тесты состояний размыкателя. Порог, переход в Open, окно ожидания, Half-Open, возврат. Быстро, дёшево, ловит большинство ошибок конфигурации.
  2. Тесты с инжекцией отказов зависимостей. Замокать зависимость так, чтобы она отвечала медленно, отдавала 503, рвала соединение. Проверить, что ответ пользователю — деградированный, а не 500.
  3. Нагрузочный тест до отказа, а не до целевого RPS. Вам нужно знать не «держим ли мы 1000 rps», а как именно система ломается за пределом ёмкости: goodput выходит на плато (хорошо) или обрушивается (плохо, см. первую схему).
  4. Chaos engineering. Управляемые эксперименты в проде или в проде-подобной среде: убить инстанс, добавить 300 мс латентности, отрезать зону доступности. Методология — principlesofchaos.org, инструменты — Chaos Toolkit, Gremlin, встроенные средства mesh (Istio fault injection).
  5. Game days. Учения, где команда отрабатывает сценарий аварии по рантбуку. Проверяют не код, а людей и процессы: находится ли ручка отключения функции, кто её дёргает, за сколько минут.

Здоровая практика — начинать не с прода, а со стенда, и с гипотезы: «мы считаем, что при отказе рекомендаций страница товара продолжит открываться за p99 < 400 мс». Эксперимент либо подтверждает гипотезу, либо находит дыру. Оба исхода полезны, второй — полезнее.


11. Типичные ошибки

Ошибка Чем оборачивается Как правильно
Вызов без таймаута Утечка потоков, каскадный отказ Таймаут на каждом уровне, включая пул соединений
Таймаут «10 секунд на всякий случай» Ресурсы держатся 10 с при аварии p99.9 + запас, но в рамках дедлайна пользователя
Ретраи на каждом уровне Усиление нагрузки в разы Ретрай на одном уровне + бюджет
Ретрай без джиттера Синхронизированные волны Full jitter
Ретрай неидемпотентной операции Двойные списания, дубликаты заказов Ключ идемпотентности
Размыкатель без минимального объёма Мигание на низком трафике minimumNumberOfCalls 20–100
Размыкатель не видит медленные вызовы Проспали худший вид деградации Порог по доле медленных вызовов
Fallback ходит в ту же БД Вторая волна отказа Fallback дешевле и на других ресурсах
Неограниченная очередь Отложенный отказ + рост латентности Ограниченная очередь + отбрасывание по возрасту
Одинаковый приоритет у всего трафика Батч вытесняет пользователей Классы критичности, распространяемые по цепочке
Health check проверяет только «процесс жив» Балансировщик шлёт трафик в мёртвый инстанс Разделять liveness и readiness, не каскадировать проверки зависимостей
Health check падает при отказе зависимости Весь кластер помечается мёртвым разом Readiness не должен зависеть от некритичных зависимостей
Устойчивость только в коде сервисов Дублирование логики, разные настройки Часть — в mesh/шлюзе декларативно
Fallback не протестирован Падает вместе с основным путём Инжекция отказов в CI

Отдельно про предпоследние строки таблицы: health check, который проверяет доступность зависимостей, — это способ превратить частичный отказ в полный. Если сервис A отдаёт «нездоров» из-за недоступности B, балансировщик выведет все инстансы A разом, и вместо деградации вы получите полное исчезновение сервиса. Readiness-проба должна отвечать на вопрос «могу ли я обслуживать трафик хотя бы частично», а не «всё ли в мире хорошо».


12. Мини-итог и чек-лист

Устойчивость складывается из четырёх решений, принятых заранее.

Ограничить ожидание. Таймаут на каждом сетевом вызове; дедлайн, распространяемый по всей цепочке; отмена работы, ставшей бесполезной.

Ограничить повторы. Ретрай только идемпотентного и только ретраебельного; full jitter; бюджет в процентах трафика; ретрай на одном уровне цепочки.

Изолировать ресурсы. Переборки на зависимость и на класс трафика; размыкатель с адекватными порогами и ограниченным Half-Open; отдельные пулы для критичного и некритичного.

Управлять входом и выходом. Ограниченные очереди, отбрасывание по возрасту и приоритету, адаптивные лимиты параллелизма — и заранее согласованная с бизнесом матрица деградации, которую видно в метриках.

Чек-лист для ревью нового сервиса:

  • У каждого исходящего вызова есть таймаут; ни один не бесконечен по умолчанию.
  • Дедлайн пользователя передаётся вниз и проверяется перед каждым вызовом.
  • Известно, какие зависимости критичны, а какие — нет; у некритичных урезанный бюджет времени.
  • Для каждой некритичной зависимости описан и протестирован fallback.
  • Ретраи есть только там, где операция идемпотентна, и только на одном уровне.
  • У ретраев есть бюджет и джиттер.
  • Размыкатель настроен с минимальным объёмом выборки и учётом медленных вызовов.
  • Все очереди ограничены; при переполнении — быстрый отказ, а не рост.
  • Есть ручка отключения некритичной функциональности без деплоя.
  • Readiness-проба не зависит от некритичных зависимостей.
  • Метрики покрывают все паттерны; есть алерт на «долго живём на fallback».
  • Проведён нагрузочный тест за пределом ёмкости, и известно, как система ломается.

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


Источники


Что дальше

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

Serverless и edge-архитектуры

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов