Алексей Меленчук
вся записка
узел 03 · Обмен
дата
объём 4 мин чтения

Таймаут по умолчанию — это отсутствие таймаута

Незаданный таймаут не «разумный», а чаще всего бесконечный. Разбор того, как один медленный сосед выедает пул соединений и утаскивает за собой сервис.

Коротко. Дефицитный ресурс при отказе — не процессор и не память, а место в пуле. Запрос без таймаута занимает его столько, сколько захочет чужой сервис.

Проверьте прямо сейчас, чему равен таймаут у вашего HTTP-клиента. Не тот, который вы задали, — тот, который стоит, если не задать.

У многих клиентов ответ будет «бесконечность». Не большое число. Бесконечность.

Почему это не абстрактная проблема

Сервис держит пул соединений с базой. Скажем, двадцать. Не «мало» — столько, сколько имеет смысл при восьми ядрах.

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

Внешний API начинает отвечать за тридцать секунд вместо двухсот миллисекунд. Не отказывает — просто тормозит.

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

Через минуту сервис не отвечает ни на что. Не только на запросы, которые ходят во внешний API, — вообще ни на что, потому что пул исчерпан. Проверка живости тоже не отвечает, и оркестратор начинает перезапускать здоровые экземпляры.

Внешний сервис при этом жив. Он просто медленный.

Это и есть цена отсутствия таймаута: один медленный сосед забирает весь сервис целиком.

Три ошибки, которые я вижу чаще всего

Один таймаут на всё. «Ставим тридцать секунд везде» — уже лучше бесконечности, но плохо. Тридцать секунд для запроса, который обычно идёт двести миллисекунд, — это не защита, это отложенная катастрофа на тридцать секунд.

Таймаут выводится из наблюдаемого поведения зависимости, а не из круглого числа. Разумная отправная точка — верхний перцентиль плюс запас. Если девяносто девятый перцентиль — четыреста миллисекунд, таймаут в секунду осмыслен, а в тридцать — нет.

Таймаут только на соединение. У HTTP-клиентов их обычно несколько: на установку соединения, на чтение, на весь запрос целиком. Задать первый и забыть про остальные — частая история. Соединение установилось за десять миллисекунд, а потом сервер отдаёт ответ по байту в минуту, и клиент терпеливо ждёт.

Нужен предельный таймаут на всю операцию. Он один по-настоящему ограничивает.

Таймаут больше, чем у вызывающего. Если ваш API обещает ответ за две секунды, а внутри ждёт внешний сервис пять — эти лишние три секунды никому не нужны. Вызывающий уже ушёл по своему таймауту. Вы держите соединение и делаете работу, результат которой некому принять.

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

Отдельно про базу

Про HTTP помнят чаще. Про базу — почти никогда.

statement_timeout в PostgreSQL по умолчанию не ограничен. Один запрос с плохим планом на большой таблице выполняется час, держит соединение и, если он в транзакции, мешает очистке старых версий строк во всей базе.

Ставить его стоит на уровне роли или соединения, а не в каждом запросе. И отдельно — idle_in_transaction_session_timeout: транзакция, открытая приложением и забытая, блокирует очистку так же надёжно.

Бюджет времени, а не таймаут на вызов

Более общий способ смотреть на это, чем отдельные числа для каждой зависимости.

Запрос приходит с бюджетом: сколько всего у нас есть времени. Каждый следующий вызов получает не свой фиксированный таймаут, а остаток бюджета минус запас на собственную работу. Потратили восемьсот миллисекунд из двух секунд — на всё остальное осталось одна двести.

Так работает контекст с крайним сроком в Go и CompletionStage с таймаутом в Java; в других языках это делается руками, и сделать стоит.

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

Побочный эффект, который важнее основного: бюджет заставляет явно ответить на вопрос «сколько мы вообще обещаем». Обычно на него не отвечали никогда.

Что делать с истёкшим таймаутом

Здесь ошибаются на последнем шаге. Таймаут истёк — и это трактуют как «операция не выполнилась».

Неправда. Таймаут значит: мы не знаем, чем всё кончилось. Запрос мог дойти, выполниться и не успеть вернуть ответ. Повтор в этом случае создаёт второй эффект.

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

Когда можно не думать. Скрипт, запускаемый вручную, вполне может ждать сколько угодно: у него нет пула, нет соседей и нет вызывающего, который ушёл. Всё написанное — про сервис, который обслуживает параллельные запросы.