Один медленный эндпоинт тормозит все параллельные запросы — найдите причину
Сервис обслуживает один воркер uvicorn. Под нагрузкой p99 растёт у всех маршрутов сразу — включая /health, который не делает ничего, — а пропускная способность падает примерно до одного запроса на каждый вызов /report. Процессор при этом почти простаивает.
Оба эндпоинта приведены ниже.
Требования к ответу:
- назовите неисправность в каждом эндпоинте, а не только симптом
- объясните, почему
/healthзамедляется, хотя не делает работы - скажите, что вы измените и почему это уберёт затор
import requests
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.post("/report")
async def report(tasks: BackgroundTasks) -> dict:
rows = requests.get("https://upstream.example/rows", timeout=30).json()
tasks.add_task(render_pdf, rows) # чистый CPU, ~4 с
return {"rows": len(rows)}Определите причину.
requests — синхронный клиент, поэтому вызов блокирует единственный цикл событий воркера на весь поход к внешнему сервису, и /health в это время обслужить нечем. Затем render_pdf, упирающийся в процессор, борется за интерпретатор в том же процессе уже после ответа. Лечение: асинхронный клиент для вызова и очередь на брокере для отрисовки.
- ✗Считать, что FastAPI сам уводит синхронный клиент в пул потоков внутри
async def - ✗Принимать рост задержек на посторонних маршрутах за сетевую проблему, а не за заблокированный цикл
- ✗Считать, что фоновые задачи идут в отдельном процессе и работа под процессор там безопасна
- →Почему добавление процессов-воркеров прячет симптом, но не устраняет причину?
- →Что вы измерите, чтобы доказать, что узкое место — цикл событий, а не внешний сервис?
Задача
Один воркер, растущий p99 у всех маршрутов сразу и простаивающий процессор. Найдите обе неисправности в /report и объясните, почему страдает /health.
Разбор
@app.post("/report")
async def report(tasks: BackgroundTasks) -> dict:
rows = requests.get(...).json() # ❌ синхронный клиент в async def
tasks.add_task(render_pdf, rows) # ❌ работа под процессор в том же процессе
return {"rows": len(rows)}async def означает, что корутину ждут прямо в цикле событий. requests.get не отдаёт управление циклу — он держит поток, в котором цикл и крутится, все 200-400 мс похода к внешнему сервису. Пока это происходит, цикл не может обслужить ни один другой запрос, и /health встаёт в очередь, хотя сам не делает ничего. Процессор при этом простаивает: воркер не считает, а ждёт сокет.
Вторая неисправность проявляется позже. Фоновая задача выполняется в том же процессе после отправки ответа, а render_pdf упирается в процессор — на свои ~4 с она удерживает интерпретатор и снова тормозит обработку запросов.
Решение
import httpx
from fastapi import FastAPI
app = FastAPI()
client = httpx.AsyncClient(timeout=30)
@app.post("/report")
async def report() -> dict:
rows = (await client.get("https://upstream.example/rows")).json() # ✅ цикл свободен
enqueue_render(rows) # ✅ очередь на брокере, вне процесса запросов
return {"rows": len(rows)}Ключевые моменты
- Освободить цикл можно двумя способами: асинхронным клиентом (
httpx.AsyncClient) или переводом эндпоинта в обычныйdef, чтобы Starlette увёл его в пул потоков. Первый вариант масштабируется лучше — пул потоков ограничен. - ⚠️ Добавление воркеров лишь размазывает симптом: каждый по-прежнему встаёт целиком на время своего блокирующего вызова.
- Работа под процессор в фоне — не «бесплатно после ответа»: это тот же процесс и тот же интерпретатор.
- Признак именно заблокированного цикла — задержки растут у посторонних маршрутов при простаивающем процессоре.