Таймауты, ретраи и Circuit Breaker
Сеть врёт, соседний сервис падает, а ваш всё равно обязан отвечать. Разбираем три приёма, которые это обеспечивают: таймаут, ретрай и предохранитель.
Отказоустойчивость — способность системы продолжать работать (пусть и хуже), когда часть её компонентов уже не работает.
В монолите вызов соседнего модуля — это orderService.create(): обычный вызов функции. Он либо отработает, либо бросит исключение, и оба исхода вы контролируете. В микросервисах тот же вызов превращается в путешествие по сети: DNS, TCP-хендшейк, TLS, балансировщик, чужой процесс на чужой машине. И здесь появляется третий исход, которого в монолите не было, — «не знаю». Запрос ушёл, ответа нет. Он выполнился? Не выполнился? Выполнится через минуту? Неизвестно.
Весь этот урок — про то, как жить с исходом «не знаю» и не утащить за собой остальной продукт.
Отказ соседа — это норма, а не ЧП
Первое, что нужно принять: соседний сервис будет недоступен. Вопрос не «если», а «сколько раз в месяц». Деплой, перезапуск пода, пауза сборщика мусора, забитый пул соединений к базе, сетевой блип в облаке — сотня причин, и ни одна из них не ваша вина.
Хуже другое: доступность в цепочке синхронных вызовов перемножается. Если запрос проходит через несколько сервисов и каждый доступен на 99,9%, то итог считается умножением, а не усреднением.
| Сервисов в цепочке | Доступность каждого | Итоговая доступность | Простой в месяц |
| 1 | 99,9% | 99,9% | ~43 минуты |
| 5 | 99,9% | 99,5% | ~3,6 часа |
| 20 | 99,9% | 98,0% | ~14 часов |
| 50 | 99,9% | 95,1% | ~35 часов |
Пятьдесят надёжных сервисов складываются в одну ненадёжную систему. Отсюда вывод, который стоит повесить на стену: надёжность цепочки нельзя купить, повышая надёжность звеньев. Её получают, разрывая цепочку — асинхронностью, кэшами, запасными ответами и тем, что каждый сервис умеет пережить смерть соседа.
Таймаут: то, что вы обязаны настроить всегда
Запрос без таймаута — не запрос, а обещание ждать вечно. Причём ждать будет не абстрактный «код», а конкретный поток или соединение из пула. Их конечное число.
Классический сценарий смерти. Сервис платежей залип — не упал, а именно залип: отвечает за 60 секунд вместо 50 миллисекунд. Сервис заказов вызывает его без таймаута. Через минуту все 200 рабочих потоков заказов висят в ожидании платежей. Заказы перестают отвечать даже на GET /orders, который к платежам вообще не ходит. Следом залипает витрина, которая ходит в заказы. Через пять минут лежит весь сайт — из-за одного медленного сервиса, который даже не упал. Это каскадный отказ.
Таймаут — это заявление: «дольше N миллисекунд ответ мне уже не нужен». Он не чинит соседа. Он спасает вас.
Таймаутов три, а не один
| Таймаут | Что ограничивает | Порядок значений |
| connect | установку TCP-соединения | 100–500 мс: сосед либо рядом в кластере, либо его нет |
| read (socket) | ожидание очередного куска ответа | обычно равен ожидаемому времени ответа × 3 |
| total (request) | всю операцию целиком, включая редиректы и ретраи | жёсткий потолок, привязанный к бюджету пользователя |
Самая частая ошибка — настроить connect и забыть про read: соединение установится за 5 мс, а потом вы будете вечно ждать байты ответа. Вторая по частоте — задать таймаут HTTP-клиента и не задать его пулу соединений: поток будет вечно ждать не ответа, а свободного слота в пуле.
Бюджет времени вместо набора магических чисел
Вместо того чтобы вписывать в каждый клиент случайные «3000 мс», задайте дедлайн (deadline) — бюджет времени, который передаётся вниз по цепочке и уменьшается на каждом шаге.
клиент --( бюджет 3000 мс )--> api-gateway
| потратил 20 мс на аутентификацию
v
orders ( осталось 2980 мс )
| потратил 300 мс на свою базу
v
payments ( осталось 2680 мс )
<- ждать дольше бессмысленно:
клиент всё равно уже ушёл
Из этого следует правило, которое нарушают постоянно: таймаут сервиса должен быть меньше таймаута того, кто его вызвал. Иначе вызывающий сдастся первым, а вы продолжите жечь потоки и базу ради ответа, который никто не прочитает.
Ретрай: лекарство, которое легко превращается в яд
Половина сетевых ошибок — мусор: потерянный пакет, перезапуск пода, мгновенный блип балансировщика. Повторить запрос — здравая идея. Но именно здесь делают самую дорогую ошибку в этой теме.
Что можно повторять, а что нельзя
| Что случилось | Повторять? | Почему |
| Таймаут соединения, connection refused | Да | Соединение даже не установилось — сосед точно ничего не сделал |
| Таймаут ответа (read timeout) | Только если операция идемпотентна | Запрос мог дойти и выполниться! Вы просто не увидели ответ |
| 502, 503, 429 | Да, с задержкой | Сосед перегружен или перезапускается. При 429 — уважайте заголовок Retry-After |
| 400, 404, 422 | Никогда | Повтор даст ту же ошибку. Вы просто удвоите нагрузку из вежливости |
| 500 | Осторожно | Ошибка может быть детерминированной — тогда ретраи бесполезны и вредны |
Выдержка и джиттер
Повторять сразу — плохо: сосед и так перегружен, а вы бьёте его снова. Правильный шаг — экспоненциальная выдержка (exponential backoff): 100 мс, 200, 400, 800.
И обязательно джиттер (jitter) — случайный разброс задержки. Без него тысяча клиентов, которых сосед отверг в одну и ту же миллисекунду, ровно через 100 мс придут к нему снова все разом. Сервис, который только начал вставать, немедленно ляжет обратно. Это «гремящее стадо» (thundering herd). Джиттер размазывает толпу по времени: спим не 2^n × 100 мс, а случайное число от нуля до 2^n × 100 мс.
Лавина ретраев
А теперь то, что реально убивает продакшены. Три уровня: витрина → заказы → платежи → база. На каждом уровне разработчик, начитавшись про надёжность, поставил «всего три попытки».
1 запрос пользователя
витрина: 3 попытки в заказы
заказы: на каждую — 3 попытки в платежи = 9
платежи: на каждую — 3 попытки в базу = 27
Итог: одна кнопка пользователя = 27 запросов в базу,
которая и без того еле дышит.
База начала отвечать медленно — и получила в 27 раз больше нагрузки, чем обычно. Вы не помогли системе восстановиться, вы её добили. Это усиление ретраев (retry amplification), и лечится оно тремя правилами:
- Ретраить только на одном уровне. Обычно на самом верхнем (в шлюзе) или самом нижнем (клиент базы), но не на всех сразу.
- Бюджет ретраев (retry budget). Сервису разрешено тратить на повторы не больше, скажем, 10% от общего числа запросов. Бюджет кончился — ретраи автоматически выключились.
- Не повторять безнадёжное — см. таблицу выше.
Circuit Breaker — предохранитель в щитке
Circuit Breaker (предохранитель, автоматический выключатель) — обёртка вокруг вызова, которая считает отказы и, набрав их достаточно, начинает отклонять запросы сразу, вообще не обращаясь к сети.
Логика бытовая. Если сосед отказал двадцать раз подряд за последние десять секунд, то двадцать первый запрос почти наверняка тоже отвалится — но перед этим сожжёт ваш поток на три секунды таймаута. Так зачем ходить? Лучше мгновенно вернуть ошибку (или запасной ответ) и дать соседу спокойно подняться.
Три состояния
| Состояние | Что делает | Когда меняется |
CLOSED (замкнут) | Всё нормально, запросы идут насквозь. Предохранитель просто считает отказы | Доля отказов превысила порог → OPEN |
OPEN (разомкнут) | Запросы отклоняются мгновенно, в сеть не уходят. Это и есть «быстрый отказ» | Прошло время выдержки → HALF_OPEN |
HALF_OPEN (полуоткрыт) | Пропускается один-два пробных запроса — проверить, ожил ли сосед | Проба успешна → CLOSED. Проба провалилась → снова OPEN |
Смотрим на предохранитель в динамике
Ниже — учебная реализация. Время в ней не настоящее, а передаётся параметром, чтобы результат был предсказуемым. Сервис платежей «лежит» первые семь секунд, потом чинится.
class CircuitBreaker:
def __init__(self, threshold=3, reset_after=5):
self.threshold = threshold # сколько отказов подряд размыкают цепь
self.reset_after = reset_after # через сколько секунд пустить пробный запрос
self.state = "CLOSED"
self.failures = 0
self.opened_at = 0
def call(self, action, now):
if self.state == "OPEN":
if now - self.opened_at >= self.reset_after:
self.state = "HALF_OPEN" # пропускаем ОДИН пробный запрос
else:
return self.state, "отказ мгновенно, запрос даже не ушёл в сеть"
was = self.state
try:
result = action()
except Exception as e:
self.failures += 1
if was == "HALF_OPEN" or self.failures >= self.threshold:
self.state = "OPEN" # проба не удалась — снова размыкаем
self.opened_at = now
return was, "ошибка: %s" % e
self.failures = 0
self.state = "CLOSED" # сосед жив — замыкаем цепь
return was, "успех: %s" % result
def payment_service(now):
def action():
if now < 7: # платежи лежат первые 7 секунд
raise RuntimeError("connection timed out")
return "payment ok"
return action
cb = CircuitBreaker(threshold=3, reset_after=5)
for now in range(0, 12):
was, answer = cb.call(payment_service(now), now)
print("t=%2ds %-9s -> %-45s стало: %s" % (now, was, answer, cb.state))
Результат:
t= 0s CLOSED -> ошибка: connection timed out стало: CLOSED
t= 1s CLOSED -> ошибка: connection timed out стало: CLOSED
t= 2s CLOSED -> ошибка: connection timed out стало: OPEN
t= 3s OPEN -> отказ мгновенно, запрос даже не ушёл в сеть стало: OPEN
t= 4s OPEN -> отказ мгновенно, запрос даже не ушёл в сеть стало: OPEN
t= 5s OPEN -> отказ мгновенно, запрос даже не ушёл в сеть стало: OPEN
t= 6s OPEN -> отказ мгновенно, запрос даже не ушёл в сеть стало: OPEN
t= 7s HALF_OPEN -> успех: payment ok стало: CLOSED
t= 8s CLOSED -> успех: payment ok стало: CLOSED
t= 9s CLOSED -> успех: payment ok стало: CLOSED
t=10s CLOSED -> успех: payment ok стало: CLOSED
t=11s CLOSED -> успех: payment ok стало: CLOSED
Обратите внимание на две вещи. С третьей по шестую секунду сервис заказов вообще не трогает сеть: он отвечает мгновенно, экономит свои потоки и не мешает платежам подниматься. А на седьмой секунде предохранитель сам, без человека и без перезапуска, пустил пробный запрос (состояние HALF_OPEN), увидел успех и вернулся в норму. Провались проба — цепь разомкнулась бы снова, и следующая попытка была бы ещё через пять секунд.
Что показывать, пока предохранитель разомкнут
Быстрая ошибка лучше медленной, но запасной ответ (fallback) лучше обеих:
- Кэш. Отдать курс валют пятиминутной давности лучше, чем не отдать ничего.
- Значение по умолчанию. Сервис рекомендаций лёг — покажите просто популярные товары.
- Отложить. Не можем отправить письмо сейчас — кладём в очередь, отправим потом.
- Честная деградация UI. «Бонусные баллы временно недоступны» рядом с рабочей кнопкой «Оплатить» — это нормальный продукт. Белый экран с «500» — нет.
Ключевой навык здесь — заранее ответить на вопрос «что показать пользователю, если этих данных нет». Почти всегда ответ есть, и это не «500».
Как это работает
Внутри предохранитель — это счётчик в скользящем окне: последние N вызовов или последние N секунд. Порог задаётся не «пять ошибок», а в процентах плюс минимальное число вызовов — иначе одна-единственная ошибка на пустом сервисе (1 из 1 = 100% отказов) разомкнула бы цепь на ровном месте.
| Параметр | Типичное значение | Зачем |
| Порог отказов | 50% в окне | Реагировать на систематическую поломку, а не на единичный сбой |
| Минимум вызовов в окне | 20 | Не делать выводов по одному запросу |
| Окно | 10 секунд или 100 вызовов | «Свежесть» статистики |
| Выдержка до пробы | 5–30 секунд | Дать соседу время подняться |
Пробных запросов в HALF_OPEN | 1–3 | Не обрушить едва вставший сервис волной трафика |
| Что считать отказом | таймаут, 5xx, отказ соединения | 4xx — не отказ соседа, это ваша ошибка. Считать её отказом = размыкать цепь из-за собственных багов |
Состояние предохранителя живёт в памяти конкретного экземпляра сервиса. Десять подов — десять независимых предохранителей, и это сделано намеренно: каждый под общается со своим набором соединений и видит свою реальность. Общее состояние на весь кластер потребовало бы синхронизации — то есть ещё одной точки отказа в самом механизме защиты от отказов.
Писать всё это руками не нужно. Готовые реализации: Resilience4j (Java), Polly (.NET), gobreaker (Go), pybreaker (Python). А можно вообще вынести защиту из кода — в сервис-меш: Envoy и Istio умеют outlier detection, то есть сами выбрасывают больной экземпляр из балансировки, и приложение об этом даже не знает.
Рядом стоит переборка (bulkhead) — отдельный пул потоков или семафор на каждого соседа. Тогда залипший сервис платежей съест только выделенные ему 20 потоков из 200, а остальные 180 продолжат обслуживать всё прочее. Название взято у корабельных переборок: пробоина затапливает один отсек, а не весь корпус.
Частые ошибки
- Таймаут по умолчанию. У многих HTTP-клиентов он бесконечный. «Мы не настраивали» означает «мы согласились ждать вечно».
- Ретраи на каждом уровне. Три уровня по три попытки — это 27 запросов вниз. Ретрай — не «полезная добавка», а нагрузка, и её надо считать.
- Ретрай неидемпотентной операции по read timeout. Ответ не пришёл — но платёж мог пройти. Повторили — списали дважды. Про это следующий урок.
- Ретраи без джиттера. Синхронное «гремящее стадо» добивает сервис ровно в момент восстановления.
- 4xx считается отказом соседа. Ваш код шлёт кривой запрос, предохранитель размыкается, и вы обвиняете чужой сервис.
- Таймаут больше, чем у вызывающего. Клиент уже отвалился, а вы ещё десять секунд героически считаете ответ в никуда.
- Предохранитель без fallback. Быстрый «500» лучше медленного «500», но это всё ещё «500».
- Никто не тестировал отказ. Пока вы не выключили сервис платежей на стенде и не посмотрели, что стало с заказами, у вас нет отказоустойчивости — у вас есть надежда.
Итоги
- Отказ соседа — рабочий режим. Доступность цепочки синхронных вызовов перемножается, поэтому надёжность добывают разрывом связей, а не полировкой звеньев.
- Таймаут обязателен всегда и везде. Он защищает не соседа, а ваши потоки и соединения от каскадного отказа.
- Таймауты выводятся из бюджета времени пользователя и уменьшаются вниз по цепочке; таймаут вызываемого меньше таймаута вызывающего.
- Ретраи — только для идемпотентных операций, с экспоненциальной выдержкой, джиттером, бюджетом и на одном уровне, иначе получите лавину.
- Circuit Breaker размыкает цепь при потоке отказов:
CLOSED→OPEN→HALF_OPEN→ и обратно. Он экономит ваши ресурсы и даёт соседу шанс подняться. - 4xx — не отказ соседа. Bulkhead ограничивает ущерб. Fallback превращает отказ в деградацию.