В цепочке из пяти синхронных сервисов один медленно отвечает. Как спроектировать тайм-ауты, чтобы задержка не распространялась по всей цепочке?
Передавайте по цепочке единый дедлайн запроса, а не назначайте каждому сервису независимый полный тайм-аут. Каждый вызов должен завершаться раньше оставшегося бюджета времени, отменять дальнейшую работу после исчерпания дедлайна и быстро возвращать контролируемую ошибку.
Так задержка одного зависимого сервиса не превращается в суммарное ожидание на каждом уровне. Независимые ветви можно выполнять параллельно, но общий дедлайн всё равно должен ограничивать их совокупное влияние на пользовательский запрос.
При декомпозиции монолита на сервисы одна операция стала проходить через несколько сетевых вызовов. Сеть, очереди, планировщик и зависимые хранилища могут задерживаться независимо, поэтому локальный тайм-аут одного сервиса не даёт сквозной гарантии для всего запроса.
Подход с дедлайнами появился как практический способ перенести ограничение времени через границы компонентов. Он решает исходную проблему отсутствия общего знания о том, сколько времени ещё допустимо тратить на обработку.
Предположим, внешний запрос должен завершиться за 500 миллисекунд, а цепочка содержит пять последовательных вызовов. Если каждый сервис ждёт зависимость по 500 миллисекунд, фактическое ожидание может многократно превысить требование клиента.
Даже если локальные тайм-ауты меньше, независимые значения не учитывают уже потраченное время. В результате сервис продолжает обрабатывать запрос, который уже не может быть успешно возвращён пользователю, расходует потоки и соединения и увеличивает очередь для других запросов.
Неверное решение особенно опасно при повторных попытках. Тайм-аут может запустить ретрай, а несколько уровней одновременно начнут повторять один и тот же вызов, усиливая перегрузку медленной зависимости.
На входе устанавливается дедлайн — абсолютный момент, к которому ответ должен быть готов. При каждом исходящем вызове передаётся оставшееся время или эквивалентный контекст отмены; вызывающий сервис резервирует небольшой запас на сериализацию, сетевую передачу и формирование ответа.
Каждый сервис должен:
Бюджет не обязательно делить поровну. Для критического вызова можно выделить больше времени, а быстрые проверки выполнить с меньшим лимитом. Однако такие лимиты должны быть согласованы с общим дедлайном: сумма последовательных бюджетов и накладных расходов не должна превышать пользовательский предел.
Параллельные вызовы уменьшают задержку критического пути: время ожидания становится близким к самому медленному обязательному вызову, а не к сумме всех вызовов. Но параллелизм увеличивает одновременную нагрузку, поэтому его нужно ограничивать и отменять необязательные ветви, когда результат уже нельзя использовать.
Ретраи допустимы только для безопасных или идемпотентных операций, при наличии оставшегося времени и ограниченного числа попыток. Каждый ретрай должен использовать тот же общий дедлайн; независимый новый тайм-аут фактически позволяет запросу обойти установленное ограничение.
Дедлайн не устраняет первопричину медленной зависимости. Для защиты системы дополнительно применяют ограничение конкуренции, circuit breaker, кэширование или деградацию результата, но эти механизмы дополняют сквозной тайм-аут, а не заменяют его.
Сервис формирования страницы заказа синхронно обращается к сервисам корзины, цен, доставки и рекомендаций. Рекомендации не обязательны, но при медленном ответе занимают рабочие потоки до истечения локального тайм-аута и ухудшают время ответа всей страницы.
Рассматривались три варианта. Увеличить общий тайм-аут проще всего, но это ухудшает пользовательскую задержку и повышает расход ресурсов. Полностью убрать рекомендации надёжно, но теряет полезную функциональность. Оставить независимые тайм-ауты сохраняет текущую архитектуру, однако не ограничивает суммарное ожидание.
Выбран единый дедлайн: обязательным сервисам выделяется бюджет критического пути, а рекомендациям — остаток времени как необязательной ветви. При его исчерпании ветвь отменяется, страница возвращается без рекомендаций, а трассировка показывает, какой вызов израсходовал бюджет.
Такое решение сохраняет основную функциональность, ограничивает задержку пользовательского запроса и освобождает ресурсы раньше. Компромисс — возможная неполнота ответа и необходимость корректно обрабатывать отмену во всех зависимых сервисах.
Нет. Локальный тайм-аут должен быть вычислен из оставшегося времени до общего дедлайна. Если сервис получил запрос с остатком 80 миллисекунд, он не может безопасно ждать зависимость ещё 200 миллисекунд, даже если его стандартный лимит равен 200 миллисекундам.
Локальный лимит может быть немного меньше остатка, чтобы оставить время на обработку ошибки и возврат ответа. Важно также учитывать погрешность часов и задержку передачи метаданных, поэтому дедлайн должен трактоваться как практический бюджет, а не как математически точная гарантия.
Возникает скрытая утечка ресурсов: запрос уже не нужен клиенту, но зависимость продолжает расходовать поток, соединение, память или вычислительное время. При массовых тайм-аутах такие фоновые операции могут перегрузить систему сильнее, чем исходная задержка.
Поэтому отмена должна распространяться вниз, а обработчики должны корректно реагировать на неё и освобождать ресурсы. Если конкретная операция не поддерживает отмену, нужно хотя бы ограничивать её конкуренцию и не создавать новые ретраи после истечения дедлайна.
При перегрузке зависимость отвечает медленно именно потому, что у неё не хватает ресурсов. Ретрай добавляет новый запрос к уже перегруженной системе, а при ретраях на нескольких уровнях один пользовательский запрос может породить много внутренних вызовов.
Безопаснее ограничивать ретраи общим бюджетом, числом попыток и условиями применимости. Для операций, изменяющих состояние, нужна идемпотентность или иной механизм защиты от повторного эффекта; иначе попытка снизить задержку может привести не только к перегрузке, но и к некорректным данным.