Таймауты, ретраи и Circuit Breaker

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

Отказоустойчивость — способность системы продолжать работать (пусть и хуже), когда часть её компонентов уже не работает.

В монолите вызов соседнего модуля — это orderService.create(): обычный вызов функции. Он либо отработает, либо бросит исключение, и оба исхода вы контролируете. В микросервисах тот же вызов превращается в путешествие по сети: DNS, TCP-хендшейк, TLS, балансировщик, чужой процесс на чужой машине. И здесь появляется третий исход, которого в монолите не было, — «не знаю». Запрос ушёл, ответа нет. Он выполнился? Не выполнился? Выполнится через минуту? Неизвестно.

Весь этот урок — про то, как жить с исходом «не знаю» и не утащить за собой остальной продукт.

Отказ соседа — это норма, а не ЧП

Первое, что нужно принять: соседний сервис будет недоступен. Вопрос не «если», а «сколько раз в месяц». Деплой, перезапуск пода, пауза сборщика мусора, забитый пул соединений к базе, сетевой блип в облаке — сотня причин, и ни одна из них не ваша вина.

Хуже другое: доступность в цепочке синхронных вызовов перемножается. Если запрос проходит через несколько сервисов и каждый доступен на 99,9%, то итог считается умножением, а не усреднением.

Сервисов в цепочкеДоступность каждогоИтоговая доступностьПростой в месяц
199,9%99,9%~43 минуты
599,9%99,5%~3,6 часа
2099,9%98,0%~14 часов
5099,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_OPEN1–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 размыкает цепь при потоке отказов: CLOSEDOPENHALF_OPEN → и обратно. Он экономит ваши ресурсы и даёт соседу шанс подняться.
  • 4xx — не отказ соседа. Bulkhead ограничивает ущерб. Fallback превращает отказ в деградацию.
Проверьте себя
1. Сервис A вызывает сервис B без таймаута. B не падает, а начинает отвечать за 60 секунд вместо 50 мс. Что произойдёт с A?
AНичего страшного: A просто будет отвечать медленнее
BA сам вернёт 504 Gateway Timeout, так стандарт HTTP
CУ A закончатся потоки и соединения из пула, и он перестанет отвечать даже на запросы, не связанные с B
DA автоматически повторит запрос к B и получит ответ быстрее
2. Circuit Breaker перешёл из состояния OPEN в HALF_OPEN. Что это означает?
AВсе запросы снова идут насквозь, счётчик отказов обнулён
BПропускается один-два пробных запроса: успех вернёт предохранитель в CLOSED, отказ снова разомкнёт цепь
CПоловина запросов отклоняется, половина проходит — навсегда
DПредохранитель ждёт ручного вмешательства дежурного инженера