Паттерны надёжности

Предпосылки: HTTP (запрос/ответ, статус-коды), TCP (соединение, таймаут), клиент-серверная архитектура, понятие потока/треда и пула соединений.

Load Balancing | Кэширование

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

Transient vs permanent failure

Фундаментальное разделение, на котором строятся все паттерны. Разница в том, поможет ли повторная попытка.

Permanent failure — ошибка, которая не исчезнет сама по себе. Сколько раз ни повторяй запрос, результат будет тот же. 404 Not Found — ресурса не существует, повтор не создаст его. 422 Unprocessable Entity — данные невалидны (email без @), повтор с теми же данными даст ту же ошибку. Баг в коде — NoMethodError будет падать каждый раз.

Transient failure — временная ошибка, которая может исчезнуть при повторе. Сетевой таймаут — сервер был занят, но сейчас освободился. 503 Service Unavailable — сервис перегружен, но скоро справится. Потеря соединения с базой данных — PostgreSQL перезапускался, через секунду поднимется.

Retry имеет смысл только для transient failures. Повторять permanent failure — бессмысленная трата ресурсов. Более того, это может быть опасно: если внешний сервис отвечает 403 Forbidden, а ты долбишь его retry’ями — создаёшь нагрузку и рискуешь попасть в бан.

HTTP 500 — неоднозначный случай. Он может быть transient (сервер перегружен, временный сбой базы данных на стороне сервиса) или permanent (баг в чужом коде, который воспроизводится для твоего конкретного запроса). Снаружи ты не знаешь, какой это случай. Хорошие API различают: 5xx — попробуй позже, 4xx — твой запрос невалиден, не повторяй.

Cascading failure: механизм

Падение одного компонента может вывести из строя всю систему. Механизм важнее определения — паттерны надёжности разрывают именно эту цепочку.

1. Сервис B (внешний API, PostgreSQL, Redis) начинает тормозить
   Не падает полностью — просто отвечает медленно

2. Сервис A делает запросы к B и ждёт ответа
   Каждый запрос держит тред/соединение занятым

3. Треды A накапливаются в ожидании
   Новые запросы от пользователей встают в очередь

4. Пользователи видят таймауты, начинают рефрешить страницу
   Нагрузка на A растёт

5. A исчерпывает пул соединений / память / треды
   A падает или становится недоступен

6. Если от A зависит сервис C — цепочка продолжается

Ключевое наблюдение: сервис B не упал — он просто стал медленным. Но этого достаточно, чтобы обрушить цепочку. Медленный ответ часто опаснее, чем быстрая ошибка. Cache stampede — частный случай: истечение TTL популярного ключа создаёт внезапную нагрузку на origin.

Паттерны надёжности разрывают эту цепочку на разных этапах. Timeout, Retry и Circuit Breaker защищают исходящие запросы — когда мы зависим от внешних сервисов. Rate Limiting защищает входящие — когда другие зависят от нас. Bulkhead изолирует ресурсы внутри сервиса, чтобы сбой одной части не затронул остальные. Idempotency делает retry безопасным — повторный запрос не создаёт дубликатов.

Timeout: первая линия защиты

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

Два типа таймаутов:

Клиент                           Сервер
   │                                │
   │ ──── connect ─────────────────>│
   │      [connection timeout]      │
   │ <─────── TCP ACK ──────────────│
   │                                │
   │ ──── HTTP request ────────────>│
   │      [read timeout]            │
   │         ...сервер думает...    │
   │ <───── HTTP response ──────────│

Connection timeout — сколько ждать установления TCP-соединения. Если сервер недоступен (выключен, нет сети), ты узнаешь об этом быстро. Обычно короткий: 1–5 секунд.

Read timeout — сколько ждать ответа после того, как соединение установлено и запрос отправлен. Зависит от операции: быстрый API — 5–10 секунд, тяжёлый отчёт — может быть минута.

# Net::HTTP
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 2   # connection timeout
http.read_timeout = 10  # read timeout
 
# Faraday
conn = Faraday.new do |f|
  f.options.timeout = 10      # read timeout
  f.options.open_timeout = 2  # connection timeout
end

Выбор значения таймаута

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

Практическое правило: таймаут = p99 latency × 2–3. p99 (99-й перцентиль) — время, за которое укладываются 99% запросов; только 1% запросов медленнее. Если API обычно отвечает за 200ms, а p99 — 500ms, таймаут в 1–2 секунды разумен.

Пример деградации: Puma с 16 тредами, внешний API с таймаутом 30 секунд. API начал отвечать за 25 секунд вместо обычных 200ms. Таймаут не срабатывает — запросы укладываются в 25 секунд. Каждый тред обрабатывает 2.4 запроса в минуту, весь сервер — около 40 запросов в минуту (0.6 RPS, requests per second). При нормальной работе API те же 16 тредов обрабатывали бы ~4800 запросов в минуту (80 RPS). Пропускная способность упала в 120 раз, но ошибок нет — сервис «работает».

Retry with backoff: повторная попытка

Таймаут сработал, ты получил ошибку. Или API вернул 503. Это transient failure — есть смысл попробовать ещё раз. Но как повторять — важно.

Проблема наивного retry

# ❌ Опасно
3.times do
  response = api.call(request)
  break if response.success?
end

Если API перегружен и отвечает 503, мгновенные retry от всех клиентов создают ещё большую нагрузку. API не успевает восстановиться, потому что его долбят повторами. Это retry storm.

Exponential backoff

Между попытками ждать, и каждая следующая пауза длиннее предыдущей:

Попытка 1: сразу
Попытка 2: через 1 секунду
Попытка 3: через 2 секунды
Попытка 4: через 4 секунды
Попытка 5: через 8 секунд

Формула: delay = base × 2^attempt, где base — начальная задержка (обычно 1 секунда).

Jitter: случайный разброс

Даже с backoff есть проблема: если 1000 клиентов получили ошибку одновременно, они все сделают retry через 1 секунду, потом через 2, потом через 4 — синхронно. Волны нагрузки.

Jitter добавляет случайность: delay = base × 2^attempt × rand(0.5..1.5). Теперь retry’и размазаны во времени.

def with_retry(max_attempts: 5, base_delay: 1)
  attempt = 0
  begin
    attempt += 1
    yield
  rescue Timeout::Error, Net::OpenTimeout, Faraday::TimeoutError => e
    raise if attempt >= max_attempts
 
    delay = base_delay * (2 ** attempt) * rand(0.5..1.5)
    sleep(delay)
    retry
  end
end

Что retry’ить, а что нет

Retry только для transient failures. Список должен быть явным:

Retry: таймауты, 503 Service Unavailable, 429 Too Many Requests (с Retry-After), connection refused.

Не retry: 400 Bad Request, 401/403 Unauthorized/Forbidden, 404 Not Found, 422 Unprocessable Entity.

Когда retry вредит

При permanent failure каждый клиент делает N запросов вместо одного. Если у тебя 1000 клиентов в секунду и сервис сломан:

  • Без retry: 1000 запросов/сек
  • С retry (5 попыток): 5000 запросов/сек

Ты в 5 раз увеличиваешь нагрузку на сервис, который и так сломан. Retry при permanent failure — не просто бесполезно, это вредно.

Circuit Breaker: прекратить бесполезные попытки

Retry с backoff работает для единичных transient failures. Но если сервис стабильно недоступен, нужен другой подход. Circuit Breaker — «автоматический выключатель» (как в электрощитке): при перегрузке размыкает цепь.

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

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

stateDiagram-v2
    CLOSED --> OPEN: N ошибок подряд
    OPEN --> HALF_OPEN: прошло M секунд
    HALF_OPEN --> CLOSED: пробный запрос успешен
    HALF_OPEN --> OPEN: пробный запрос — ошибка

    CLOSED: CLOSED (нормальная работа)
    OPEN: OPEN (запросы не пропускаем)
    HALF_OPEN: HALF-OPEN (пробуем один запрос)

CLOSED — нормальная работа. Запросы проходят. Circuit breaker считает ошибки.

OPEN — цепь разомкнута. Запросы не отправляются вообще. Сразу возвращается ошибка (или fallback). Сервис не получает нагрузки от нас. Наши треды не висят в ожидании.

HALF-OPEN — проверяем, ожил ли сервис. Пропускаем один пробный запрос. Если успех — возвращаемся в CLOSED. Если ошибка — обратно в OPEN.

Параметры

CircuitBreaker.new(
  failure_threshold: 5,      # сколько ошибок до OPEN
  reset_timeout: 30,         # сколько секунд в OPEN до HALF-OPEN
  success_threshold: 2       # сколько успехов в HALF-OPEN до CLOSED
)

Что это даёт

Fail fast — вместо 30 секунд retry + таймаутов получаешь ошибку мгновенно.

Защита сломанного сервиса — не долбишь его бесполезными запросами, даёшь время восстановиться.

Автоматическое восстановление — когда сервис починится, circuit breaker обнаружит это через пробный запрос в HALF-OPEN.

Пример поведения

Payment API упал в 12:00:00. Circuit breaker с failure_threshold: 5, reset_timeout: 60. Пришло 100 запросов на оплату за следующие 2 минуты.

12:00:00 — API падает, circuit в CLOSED

Запросы 1–5: проходят к API, все возвращают ошибку
             → после 5-й ошибки circuit переходит в OPEN

12:00:01–12:01:00 — circuit в OPEN
Запросы 6–N: мгновенно получают CircuitOpenError (к API не уходят)

12:01:00 — прошло 60 секунд, circuit переходит в HALF-OPEN
Запрос N+1: пробный запрос проходит к API → ошибка → circuit обратно в OPEN

За 2 минуты к Payment API ушло ~6 запросов вместо 100.

Timeout + Retry + Circuit Breaker: комбинация

Три паттерна работают вместе:

circuit_breaker.call do
  with_retry(max_attempts: 3) do
    with_timeout(5) do
      PaymentAPI.charge(amount: 100)
    end
  end
end

Логика:

  1. Circuit breaker проверяет — если OPEN, сразу ошибка
  2. Если CLOSED/HALF-OPEN — пробуем запрос
  3. Timeout ограничивает время ожидания
  4. При transient failure — retry с backoff
  5. Если все retry провалились — circuit breaker считает это как failure

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

Rate Limiting: ограничение входящей нагрузки

До сих пор мы защищали свой сервис от сбоев чужого. Rate limiting — защита в обратную сторону: ограничить входящую нагрузку, чтобы сервис не захлебнулся.

Две стороны:

  • Ты как клиент — внешний API ограничивает тебя («не больше 100 запросов в минуту»)
  • Ты как сервер — ты ограничиваешь своих клиентов

Зачем ограничивать:

  • Один клиент не должен положить сервис для всех остальных
  • Защита от DDoS и abuse
  • Предсказуемая производительность

Основные алгоритмы

Fixed Window — считаем запросы в фиксированном окне (минута). Просто, но проблема на границе окон: 100 запросов в конце первой минуты + 100 в начале второй = 200 за 2 секунды.

Sliding Window — окно скользит за текущим временем. Точнее, но сложнее в реализации.

Token Bucket — есть «ведро» с токенами, токены добавляются с постоянной скоростью, каждый запрос забирает токен. Нет токена — запрос отклонён. Позволяет короткие всплески (burst), если токены накопились.

Обработка 429 на клиенте

При превышении лимита сервер возвращает:

HTTP 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0

429 — это не сбой сервиса. Сервис работает нормально, он намеренно тебя ограничивает. Circuit breaker здесь не нужен — он для ситуации «сервис сломан». 429 означает «сервис в порядке, но ты превысил свою квоту».

Правильная обработка: прочитать Retry-After и не делать запросов до этого момента. Если постоянно упираешься в 429, проблема не в сервисе — проблема в твоём паттерне использования.

Bulkhead: изоляция ресурсов

Rate limiting ограничивает общий поток запросов. Но даже при нормальной нагрузке один медленный внешний сервис может занять все ресурсы и заблокировать работу с другими сервисами. Bulkhead решает эту проблему через изоляцию.

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

Проблема общего пула ресурсов

Rails-сервис вызывает три внешних API:
- Payment API (критичный)
- Email API (важный)
- Analytics API (некритичный)

Puma с 16 тредами — общий пул на всё.

Analytics API завис (отвечает по 30 секунд).
Все 16 тредов постепенно застревают на Analytics.
Результат: Payment и Email тоже не работают,
хотя с ними всё в порядке.

Один некритичный компонент положил всю систему.

Решение: изолированные пулы

graph TD
    Puma["Puma (16 тредов)"] --> Payment["Payment Pool (8 conn)"]
    Puma --> Email["Email Pool (4 conn)"]
    Puma --> Analytics["Analytics Pool (2 conn)"]
    Payment --> PayAPI["Payment API"]
    Email --> EmailAPI["Email API"]
    Analytics --> AnalyticsAPI["Analytics API"]

Каждый внешний сервис получает свой изолированный пул соединений. Analytics завис — занял свои 2 соединения. Payment и Email продолжают работать в своих пулах.

Варианты реализации

Отдельные connection pools — как на схеме выше.

Отдельные процессы/сервисы — максимальная изоляция.

Отдельные очереди в Sidekiq:

# sidekiq.yml
:queues:
  - critical      # платежи
  - default
  - low           # аналитика
# Процесс 1: только критичные jobs
bundle exec sidekiq -q critical -c 10
 
# Процесс 2: всё остальное
bundle exec sidekiq -q default -q low -c 5

Если очередь low забита тяжёлыми jobs или jobs, которые висят на зависшем API — они занимают только 5 тредов процесса 2. Процесс 1 с critical работает независимо.

Bulkhead внутри одного job

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

# ❌ Некритичная ошибка роняет весь job
def perform(order_id)
  PaymentAPI.charge(order_id)     # успех
  EmailService.send_receipt(order_id)  # успех
  AnalyticsAPI.track(order_id)    # упал → job failed → retry
end
# При retry: PaymentAPI.charge вызывается снова
 
# ✅ Некритичное изолировано
def perform(order_id)
  PaymentAPI.charge(order_id)
  EmailService.send_receipt(order_id)
 
  begin
    AnalyticsAPI.track(order_id)
  rescue => e
    Rails.logger.error("Analytics failed: #{e}")
    # Не reraise — job успешен
  end
end

Ещё лучше — разделить на отдельные jobs:

def perform(order_id)
  charge_payment(order_id)
  SendReceiptJob.perform_async(order_id)
  TrackAnalyticsJob.perform_async(order_id)  # отдельная очередь, отдельные retry
end

Теперь каждая часть имеет свой retry-цикл, и падение Analytics не затрагивает Payment.

Idempotency: безопасность повторных запросов

Retry создаёт проблему: что если запрос выполнился, но ответ не дошёл? При retry операция выполнится дважды.

Проблема дублирования

def perform(order_id)
  PaymentAPI.charge(order_id)        # деньги списались
  order.update!(paid: true)          # таймаут PostgreSQL
end
# Job failed → retry → PaymentAPI.charge снова → двойное списание

Idempotency key

Идемпотентная операция — операция, которую можно выполнить многократно с тем же результатом. GET /user/123 идемпотентен — хоть 100 раз вызови, результат один. POST /payments не идемпотентен — каждый вызов создаёт новый платёж.

Idempotency key превращает неидемпотентную операцию в идемпотентную:

idempotency_key = "payment-order-#{order_id}"
 
PaymentAPI.charge(
  amount: order.amount,
  idempotency_key: idempotency_key
)

Как это работает на стороне API:

Первый вызов:
  API проверяет: ключ "payment-order-42" видел раньше? Нет.
  Списывает деньги, сохраняет ключ.
  Возвращает 200.

Retry (тот же ключ):
  API проверяет: ключ видел? Да!
  Деньги НЕ списывает.
  Возвращает тот же ответ, что и в первый раз.

Stripe, PayPal, Braintree — все серьёзные платёжные API поддерживают idempotency key именно для этого сценария.

Если API не поддерживает idempotency key

Приходится реализовывать на своей стороне:

def perform(order_id)
  Order.transaction do
    order = Order.lock.find(order_id)  # SELECT ... FOR UPDATE
    return if order.paid? || order.processing?
    order.update!(status: 'processing')
  end
 
  PaymentAPI.charge(order_id)
  order.update!(status: 'paid')
rescue => e
  order.update!(status: 'failed')
  raise
end

FOR UPDATE блокирует строку — параллельный retry будет ждать. Но это не гарантирует защиту от дубликатов, только снижает вероятность.

In-doubt transaction

Job вызвал PaymentAPI.charge(...)
Сеть оборвалась до получения ответа
Job упал

Вопрос: деньги списались или нет?

Ответ: неизвестно. Это in-doubt transaction — неопределённое состояние.

Варианты выхода:

  • Reconciliation — периодически запрашивать у API список транзакций и сверять со своей базой
  • Статус unknown — не retry’ить автоматически, разбирать отдельным процессом
  • Запрос статуса перед retry — если API позволяет узнать статус операции по внешнему идентификатору

Без idempotency key на стороне платёжного API нельзя гарантировать отсутствие дубликатов. Можно только минимизировать риск и иметь процесс reconciliation.

Практический пример идемпотентности в фоновых задачах — гарантии Sidekiq: at-least-once означает повторное выполнение, и каждый job должен быть идемпотентным.

Сводка паттернов

ПаттернПроблемаРешение
TimeoutЗапрос висит бесконечноОграничить время ожидания
Retry + BackoffTransient failureПовторить с нарастающей задержкой + jitter
Circuit BreakerСервис стабильно сломанПрекратить попытки, fail fast
Rate LimitingПерегрузка от слишком большого числа запросовОграничить RPS
BulkheadСбой в одном компоненте роняет всёИзолировать ресурсы
IdempotencyПовторный запрос создаёт дубликатIdempotency key

Sources

  • Nygard, 2018, Release It! Design and Deploy Production-Ready Software, 2nd ed. — circuit breaker, bulkhead, timeouts
  • Kleppmann, 2017, Designing Data-Intensive Applications, Chapter 8: The Trouble with Distributed Systems — failure modes
  • AWS Architecture Blog: Exponential Backoff And Jitter. https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/

Load Balancing | Кэширование