Прямой ответ
Таймаут-бюджет — это общее время, выделенное на обработку запроса, распределённое между всеми шагами цепочки. Без него каждый сервис задаёт таймаут независимо, и сумма превышает ожидание клиента: клиент уже получил ошибку, а цепочка продолжает работать.
Типовая ошибка:
| Уровень | Таймаут |
|---|---|
| Клиент | 2 с |
| Шлюз | 5 с |
| Сервис A | 10 с |
| Сервис B (вызов из A) | 8 с |
Клиент ждёт две секунды, сервис A — десять. Запрос, который не успел, продолжает потреблять ресурсы после того, как результат стал бесполезен.
Правильный подход — задавать бюджет на входе и передавать остаток вниз:
клиент: 2000 мс
└─ шлюз: 1800 мс (резерв на сеть)
└─ сервис A: 1500 мс
├─ сервис B: 400 мс
├─ база: 300 мс
└─ кэш: 50 мс
Сумма параллельных вызовов определяется самым долгим, сумма последовательных — сложением. Резерв на каждом уровне учитывает сериализацию и сеть.
Распространение дедлайна в Python:
import asyncio, time
@app.get("/orders/{order_id}/card")
async def order_card(order_id: str, request: Request):
deadline = request.state.deadline # пришёл от вызывающей стороны
remaining = deadline - time.monotonic()
if remaining <= 0:
raise HTTPException(504, "Бюджет времени исчерпан")
async with asyncio.timeout(remaining):
order = await orders.get(order_id, timeout=remaining * 0.4)
payment = await payments.get(order_id, timeout=remaining * 0.3)
return {"order": order, "payment": payment}
gRPC поддерживает дедлайны штатно: клиент задаёт срок, и он автоматически передаётся по всей цепочке вызовов. При исчерпании сервер прекращает работу и возвращает DEADLINE_EXCEEDED — о механизме в блоге gRPC.
Ключевое правило: сервис обязан прекратить работу, если бюджет исчерпан. Продолжение обработки после отказа клиента расходует ресурсы впустую и усиливает перегрузку.
Практика: как задавать и поддерживать бюджет
1. Начинайте от требования к пользователю. Если страница должна открыться за две секунды, бюджет API — около полутора, остальное уходит на сеть и отрисовку.
2. Оставляйте резерв на каждом уровне. Сериализация, сеть и постановка в очередь занимают время. Резерв в 10–20 % предотвращает ситуацию, когда вложенный вызов формально успел, а ответ не дошёл.
3. Проверяйте сумму последовательных вызовов. Если в сервисе три последовательных вызова по 400 миллисекунд, бюджет должен быть не меньше 1200 плюс резерв. Иначе последний вызов никогда не успеет.
4. Различайте таймаут соединения и таймаут ответа. Время на установку соединения и время на получение ответа ограничиваются отдельно; общий таймаут скрывает, какая фаза медленная.
5. Отменяйте работу при исчерпании бюджета. Если клиент ушёл, продолжение записи в базу или вычисления бесполезно. Отмена должна распространяться на дочерние задачи.
6. Не повторяйте запрос, если бюджет исчерпан. Повтор при отсутствии времени только увеличивает нагрузку; о правилах повторов — в статье про retry storm.
7. Измеряйте распределение времени. Без метрики по каждому вызову невозможно понять, куда уходит бюджет; о процентилях — в статье про гистограммы метрик.
Рекомендации:
- Передавайте дедлайн, а не только таймаут. Таймаут отсчитывается с момента получения запроса каждым сервисом, поэтому накапливает задержку очереди. Абсолютный дедлайн этого недостатка лишён.
- Учитывайте очередь перед обработкой. Время, проведённое в очереди, расходует бюджет; запрос, ожидавший полторы секунды из двух, лучше отклонить сразу.
- Согласуйте таймауты в контракте. Клиент должен знать, сколько сервер будет работать, иначе политики не совпадут.
- Тестируйте медленные зависимости. Сценарий «зависимость отвечает на границе таймаута» выявляет накопление задержки; о приёмах — в статье про Locust.
- Сочетайте с частичным ответом. Если необязательный вызов не успел, лучше вернуть частичный результат, чем ошибку; о подходе — в статье про композицию API.
Практический сценарий: запрос продолжался после того, как клиент ушёл
Контекст: сервис формирования отчёта выполнялся до 25 секунд. Клиент обрывал соединение через 5 секунд, но сервер продолжал работу, нагружая базу.
- Диагностика. В логах базы были видны запросы, начатые после того, как клиент получил таймаут. Ресурсы расходовались на результат, который никто не получит.
- Причина. Таймауты задавались независимо на каждом уровне: клиент — 5 секунд, сервис — 30, запрос к базе — без ограничения. Дедлайн не передавался.
- Действия. (а) Установили бюджет 4 секунды на входе и передавали остаток вложенным вызовам; (б) запрос к базе получил ограничение 2 секунды и отменялся при исчерпании бюджета; (в) проверку остатка добавили перед каждым этапом, чтобы не начинать заведомо безуспешную работу; (г) формирование тяжёлых отчётов перевели в асинхронную задачу с последующим получением результата, поскольку в синхронный бюджет операция не укладывалась; (д) добавили метрику доли запросов, прерванных по бюджету.
- Результат. Нагрузка на базу от бесполезных запросов исчезла; тяжёлые отчёты выполняются фоново, а синхронные запросы укладываются в четыре секунды.
Вывод: таймаут, заданный независимо на каждом уровне, не защищает систему. Бюджет должен определяться один раз на входе и распространяться вниз, а работа — прекращаться при его исчерпании.
FAQ
Чем дедлайн отличается от таймаута? Таймаут — длительность ожидания, отсчитываемая с момента начала операции в каждом сервисе. Дедлайн — абсолютный момент времени, общий для всей цепочки, поэтому не накапливает задержки очередей.
Что возвращать при исчерпании бюджета?
504 Gateway Timeout для промежуточного сервиса и отмену работы для конечного. В gRPC используется код DEADLINE_EXCEEDED.
Нужно ли уменьшать бюджет при повторных попытках? Да. Повтор выполняется в пределах того же бюджета, поэтому на каждую следующую попытку остаётся меньше времени; о стратегии — в статье про circuit breaker.
Как быть с фоновыми задачами? Для них бюджет задаётся как максимальная длительность выполнения, а не как время ответа. Задача, превысившая лимит, прерывается и повторяется либо уходит в мёртвую очередь.
Источники: gRPC (deadlines), Google SRE Book, Python asyncio — ссылки в шапке.