Когда API долго ждёт зависимость, хочется сократить таймаут. Запрос быстрее получит ошибку, освободит ресурсы и перестанет висеть. Но сразу возникает следующий вопрос: что приложение сделает после этой ошибки? Если оно без паузы повторит тот же вызов, короткое ожидание может превратиться в большую очередь на другой стороне.

В моём проекте Prod is Down есть учебный кейс с такой цепочкой: первое исправление вскрывает проблему с повторами. Для этого разбора я собрал отдельный локальный стенд и сравнил четыре варианта настроек.

Условия простые: шесть клиентов обращаются к небольшому TCP-сервису на 127.0.0.1. Одновременно сервис обрабатывает три задания. Первые две секунды обработка занимает 250 мс, затем возвращается к 20 мс. Каждый клиент начинает следующую операцию после завершения предыдущей и небольшой паузы. Каждый вариант запускается на шесть секунд с пустой очередью.

Важное условие: принятое задание продолжает выполняться, даже если клиент уже закрыл соединение. Так устроена наша тестовая зависимость - отмену принятой работы мы здесь не реализуем. В конкретном приложении это поведение нужно проверять отдельно.

Сначала оставляем таймаут в одну секунду. Медленный вызов успевает завершиться: первая операция получает ответ примерно через 256 мс. За весь прогон клиенты выполнили 513 успешных операций. Таймаутов не было, а максимум одновременно обрабатываемых и ожидающих заданий составил шесть.

Теперь меняем только время ожидания: вместо 1000 мс ставим 50 мс. Повторы по-прежнему бесконечные, паузы между ними нет. В настройках нашего стенда это MAX_ATTEMPTS=0 и BACKOFF_BASE=0. Ноль здесь означает отсутствие ограничения, у других библиотек такое значение может иметь другой смысл.

Клиент получает таймаут раньше, чем сервис заканчивает обработку, и сразу отправляет новую попытку. В журнале повторяется один идентификатор операции, а номер попытки растёт. Уже через 322 мс у одной операции набралось шесть попыток.

За шесть секунд получилось 690 обращений к зависимости и ни одного успешного ответа клиентам. Все шесть исходных операций остались в цикле повторов. При этом сама зависимость успела обработать 573 задания, результаты которых клиенты уже не дождались. Максимум одновременно выполняемых и ожидающих заданий достиг 213. В конце прогона незавершёнными оставались 117.

Искусственная задержка закончилась через две секунды, но накопленная работа продолжала занимать сервис. Первоначальное замедление уже прошло, а очередь ещё мешала получать ответы вовремя.

В третьем варианте оставляем таймаут 50 мс, но ограничиваем число попыток тремя, добавляем растущую случайную паузу и общий бюджет операции в 1,5 секунды. Получаем 362 успешные операции, 53 завершившиеся ошибкой и максимум 91 задание у зависимости. Это уже лучше бесконечного цикла, но медленные обращения всё ещё не помещаются в выбранный таймаут.

Для последнего прогона увеличиваем таймаут до 600 мс и сохраняем ограничения. Три попытки включают первый вызов. Бюджет в 1,5 секунды общий: в него входят и вызовы, и паузы между ними. Перед очередной попыткой проверяется оставшееся время.

В результате - 519 успешных операций, ни одного таймаута и максимум шесть заданий у зависимости. Повторы в этом прогоне вообще не понадобились. Поэтому он не доказывает отдельный эффект случайной задержки: прежде всего мы перестали обрывать нормально завершающиеся вызовы.

Эти числа относятся к небольшому учебному стенду. Здесь постоянна численность клиентов, но не входящий RPS: новую операцию клиент начинает после предыдущей. Очередь состоит из заданий приложения; это не число соединений PostgreSQL и не размер TCP backlog. Сам таймаут ограничивает весь клиентский вызов, поэтому переносить его значение в параметр подключения к базе без проверки семантики нельзя.

Что я проверял бы в приложении? Сначала выяснил бы, какое именно ожидание ограничивает таймаут. Затем посмотрел бы, сколько попыток создаёт одна операция и что происходит с принятой работой после отмены ожидания. После этого согласовал бы время ожидания, число попыток, паузы и общий бюджет операции.

Повторять стоит только те ошибки и операции, для которых это имеет смысл. Если вызов создаёт заказ или списывает деньги, отдельно нужен ответ на вопрос об идемпотентности. Клиент мог не получить ответ, хотя действие уже произошло.

После изменения настроек я проверял бы долю успешных операций, частоту повторов и очередь, а затем снова временно замедлял зависимость. Одного красивого графика времени ответа недостаточно. Например, в третьем прогоне p95 успешных операций оказался около 32 мс, но 53 операции закончились ошибкой. Они в эту метрику не вошли.

Такие задачи я собираю в Prod is Down: сначала симптомы и диагностика, затем проверка исправления. В этом кейсе полезный вопрос возникает сразу после первого изменения: что приложение сделает, когда таймаут сработает?

Комментарии (0)