Когда два сервиса общаются в одном процессе, вызов метода либо возвращает результат, либо кидает исключение — третьего не дано. Как только между ними появляется сеть, добавляется третий исход, самый неприятный: вы не знаете, что произошло. Запрос мог не дойти, дойти и потеряться на обратном пути, или сервер честно всё сделал, но ответ застрял в дороге. Сеть — это не «медленный вызов метода», это принципиально другая история, и надёжный backend строится на признании этого факта.
Разберём по шагам, почему сеть ненадёжна, зачем на каждый вызов нужен таймаут, как безопасно повторять запросы и что делать, когда сосед явно упал. Всё это — не абстракция, а набор привычек, без которых сервис под нагрузкой рано или поздно «зависает» без единой ошибки в логах.
Сеть ненадёжна по природе
У начинающих есть тихое допущение: «вызвал сервис — он ответит». На нём разбиваются целые системы. Ещё в 90-х инженеры сформулировали список «заблуждений о распределённых системах» — вещей, которые кажутся очевидно верными, но на деле ложны. Первые три самые важные:
- Сеть надёжна. Нет. Кабель дёргают, коммутатор перезагружают, Wi-Fi моргает, пакеты теряются. Любой вызов может не дойти.
- Задержка нулевая. Нет. Ответ из соседней стойки — миллисекунды, из другого дата-центра — десятки и сотни. Это не бесплатно.
- Полоса бесконечна. Нет. Канал не резиновый; большой ответ или всплеск трафика упрутся в потолок.
Аналогия — телефонный звонок вместо разговора в одной комнате. В комнате собеседник вас слышит гарантированно. По телефону связь может оборваться на полуслове, и вы не знаете, услышал он вашу последнюю фразу или нет. Распределённая система живёт именно в режиме «телефонного звонка», и код должен быть к этому готов. Подробнее о самих каналах связи — в статьях про TCP и UDP и сетевые соединения.
Отдельный тяжёлый случай — network partition: связь между частями системы пропала, но каждая часть жива и думает, что права другая сторона умерла. Полностью это не «решается», к этому готовятся.
Таймаут на каждый сетевой вызов
Главное правило надёжности звучит скучно, но спасает больше всего систем: у каждого сетевого вызова должен быть таймаут. Всегда. По умолчанию многие HTTP-клиенты ждут ответа бесконечно — и это ловушка.
Представьте: ваш сервис дёрнул соседа, а тот завис. Без таймаута поток, обслуживающий запрос, встаёт и ждёт. Приходит второй такой запрос — встаёт второй поток. Пул потоков и пул соединений исчерпываются, и вот уже весь ваш сервис не отвечает — хотя сам он здоров, просто все его ресурсы заняты ожиданием мёртвого соседа. Это и есть то самое «оно зависло»: не упало с ошибкой, а тихо перестало отвечать.
Таймаут превращает бесконечное ожидание в честную, быструю ошибку, которую можно обработать. Полезно различать два таймаута:
- Connect timeout — сколько ждём установления соединения (обычно короткий, сотни миллисекунд).
- Read/response timeout — сколько ждём ответа после того, как соединение установлено.
Значения выбирают по реальному поведению зависимости, а не «на глаз побольше, чтобы не срабатывал». Таймаут, выставленный в 60 секунд «на всякий случай», не защищает — он просто откладывает катастрофу.
Ретраи — только для идемпотентных операций
Раз вызов может не дойти, логично его повторить. Ретраи — мощный инструмент, но у него острый край.
Повторять можно только идемпотентные операции. Идемпотентность — это когда повтор операции даёт тот же результат, что и один вызов. Прочитать заказ (GET) можно хоть десять раз — ничего не изменится. А вот «списать деньги» повторять вслепую нельзя: если первый запрос на самом деле дошёл и деньги списались, а ответ потерялся, ретрай спишет второй раз.
Второй острый край — retry storm (лавина повторов). Сосед притормозил, все клиенты дружно словили таймаут и все дружно повторили запрос — нагрузка удвоилась. Сосед лёг окончательно, клиенты повторяют ещё агрессивнее, и повторы добивают уже упавший сервис, не давая ему подняться. Чтобы этого избежать, повторяют не сразу и не в лоб, а с exponential backoff (растущая пауза) и jitter (случайный разброс, чтобы клиенты не били в унисон):
attempt = 0
while attempt < MAX_ATTEMPTS:
try:
return call() # один сетевой вызов с таймаутом
except RetriableError:
attempt += 1
if attempt == MAX_ATTEMPTS:
raise # сдаёмся, отдаём ошибку наверх
base = MIN_DELAY * (2 ** attempt) # 0.2s, 0.4s, 0.8s, ...
sleep(base + random(0, base)) # backoff + jitter
Три правила разумного ретрая: конечное число попыток, растущая пауза с jitter, и повтор только тех ошибок, которые имеет смысл повторять (таймаут, 503 — да; 400 или 404 — нет, второй раз будет так же).
Идемпотентность и ключи идемпотентности
С идемпотентными операциями всё просто — их можно повторять. Проблема с теми, что меняют состояние и «не должны» повторяться: платёж, создание заказа, отправка письма. Как безопасно повторить перевод денег, если непонятно, дошёл первый запрос или нет?
Решение — ключ идемпотентности. Клиент генерирует уникальный идентификатор операции (например, UUID) и прикладывает его к запросу, обычно в заголовке. Сервер запоминает обработанные ключи. Приходит запрос с новым ключом — выполняем и записываем результат. Приходит повтор с тем же ключом — не выполняем заново, а возвращаем сохранённый результат первого раза.
Аналогия — номерок в гардеробе. Отдали пальто, получили жетон №17. Придёте с жетоном №17 ещё раз — вам не выдадут второе пальто, вам вернут то же самое. Ключ идемпотентности делает «списать деньги» безопасным для повтора: сколько бы раз клиент ни повторил запрос с одним ключом, деньги спишутся один раз.
Именно так устроены платёжные API: клиент присылает Idempotency-Key, и повторная отправка того же платежа — при обрыве, таймауте, ретрае — не создаёт второй платёж.
Circuit breaker: перестать долбить упавшего
Ретраи с backoff помогают при коротких сбоях. Но если сосед лежит основательно — минуту, пять, — продолжать в него стучать бессмысленно и вредно: каждый вызов упрётся в таймаут, займёт поток и замедлит ваш сервис ради заведомо провального запроса.
Здесь помогает circuit breaker — «предохранитель», по аналогии с электрическим. У него три состояния:
- Closed (замкнут) — всё нормально, запросы идут насквозь. Breaker считает ошибки.
- Open (разомкнут) — ошибок стало слишком много; breaker размыкается и на время сразу отклоняет запросы к этой зависимости, не тратя таймаут. Ваш сервис быстро отдаёт ошибку или запасной ответ вместо того, чтобы висеть.
- Half-open (полуоткрыт) — спустя паузу breaker пропускает пробный запрос. Прошёл — закрывается, трафик восстанавливается; снова ошибка — опять размыкается.
Смысл в том, чтобы дать упавшему соседу передышку и не превратить его сбой в свой собственный. Circuit breaker и грамотные ретраи — две стороны одной медали и обычно работают в паре.
Бюджет таймаутов по цепочке вызовов
Последний кусок пазла проявляется, когда вызовы выстраиваются в цепочку: API-шлюз зовёт сервис заказов, тот — сервис оплаты, тот — банк. У каждого свой таймаут, и их нельзя назначать независимо.
Если у внешнего вызова таймаут 2 секунды, а внутренний сервис в цепочке ждёт своего соседа 5 секунд, то клиент снаружи уже сдался и ушёл, а внутри цепочка всё ещё трудится над ответом, который никому не нужен — и держит ресурсы. Это называется бюджет таймаутов: у всей операции есть общий лимит времени, и каждый следующий вызов вглубь получает остаток от него, а не свой собственный с потолка.
Практическое правило: чем глубже вызов в цепочке, тем короче его таймаут. Внешний слой — самый терпеливый, внутренние — всё менее, чтобы к моменту, когда клиент теряет надежду, вся цепочка уже успела аккуратно свернуться. Ретраи внутри цепочки тоже едят бюджет: три попытки по секунде — это уже три секунды, которые нужно уместить в общий лимит.
Где это применяется
Всё перечисленное — не теория, а ежедневный инструментарий backend-разработчика. Как только сервис ходит по сети (а он всегда ходит — в базу, в очередь, к соседнему сервису, во внешний API), эти привычки определяют, переживёт ли он чужой сбой или ляжет вместе с ним. Инженерные правила — таймауты, retry, circuit breaker, bulkhead — собраны в стандарте устойчивость к сбоям; чтобы вообще увидеть, что вызов тормозит или breaker разомкнулся, нужна наблюдаемость.
Где спотыкаются начинающие:
- Забывают таймаут и удивляются, почему здоровый сервис «завис». Клиент по умолчанию часто ждёт бесконечно — это первое, что нужно проверить.
- Повторяют неидемпотентные операции без ключа идемпотентности — и получают двойные платежи и дубли заказов.
- Ретраят в лоб, без backoff и jitter, устраивая упавшему соседу retry storm вместо передышки.
- Ставят один огромный таймаут «чтобы точно хватило» — это не защита, а отложенная деградация: ресурсы всё равно висят, просто дольше.
- Ретраят
400/404— ошибки, которые при повторе дадут ровно то же самое. Повторять стоит только преходящие сбои.
Что учить дальше
Дальше полезно посмотреть, на каких именно каналах всё это разыгрывается: TCP и UDP — что гарантирует доставку, а что нет, и как ведёт себя таймаут на разных транспортах; сетевые соединения — почему установка соединения не бесплатна и как пул соединений связан с таймаутами. Со стороны прикладного слоя — HTTP: какие коды статуса стоит повторять, а какие нет, и как заголовки несут ключ идемпотентности. А когда вызовов много и их надо распределять — балансировщики нагрузки, которые сами по себе часть картины надёжности. Инженерную конкретику по всему этому держит стандарт устойчивость к сбоям.