Паттерны надёжности
Предпосылки: 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Логика:
- Circuit breaker проверяет — если OPEN, сразу ошибка
- Если CLOSED/HALF-OPEN — пробуем запрос
- Timeout ограничивает время ожидания
- При transient failure — retry с backoff
- Если все 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
endFOR 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 + Backoff | Transient 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 | Кэширование →