FastAPI под нагрузкой: uvicorn, воркеры, пул БД изнутри
Сервис на FastAPI начинает отвечать 502 и терять запросы при нескольких сотнях одновременных соединений. Разбираем, как связаны между собой event loop uvicorn, количество воркеров и пул соединений к базе — и где именно ломается каждое из звеньев.

Почему три сотни запросов роняют сервис
Типичная картина: FastAPI-приложение на одном процессе uvicorn уверенно держит пятьдесят параллельных клиентов. При трёхстах начинаются таймауты, при пятистах — 502. Логи молчат или выдают невнятное asyncpg.exceptions.TooManyConnectionsError. Проблема почти никогда не в самом FastAPI — фреймворк по сути тонкая обёртка над Starlette и Pydantic. Узкие места находятся ниже: в том, как uvicorn обрабатывает соединения, сколько воркеров реально утилизируют ядра, и как пул соединений к PostgreSQL делится между всеми этими процессами.
Uvicorn: один процесс, один event loop
Uvicorn — ASGI-сервер. В режиме по умолчанию он запускает один процесс с одним event loop на базе asyncio (или uvloop, если установлен). Все входящие TCP-соединения обслуживаются этим единственным циклом. Пока обработчик маршрута делает await, event loop переключается на следующий корутин — классическая кооперативная многозадачность.
Проблема возникает в двух случаях:
- Синхронный код в обработчике — вызов
requests.get(), тяжёлая сериализация, CPU-bound вычисление. Event loop блокируется, все остальные корутины встают в очередь. - Слишком много одновременных корутинов ждут ресурс с ограниченным количеством слотов — типично это пул соединений к БД.
Uvicorn сам не ставит жёсткий лимит на число одновременных соединений. Лимит фактически определяется ОС (backlog сокета, по умолчанию 2048 на Linux) и ресурсами event loop. Но «принять соединение» и «обработать запрос» — разные вещи: принятых, но зависших запросов может быть тысяча, а реально обрабатываемых — десять, потому что все остальные ждут свободное соединение к базе.
uvloop vs asyncio
Uvicorn поддерживает uvloop как замену стандартного event loop. На бенчмарках uvloop стабильно показывает прирост пропускной способности — особенно заметный при большом количестве мелких I/O-операций. Но uvloop не снимает проблему блокирующего кода: если корутина заняла поток на 200 мс синхронной работой, uvloop не может её прервать.
Воркеры: масштабирование по процессам
Один процесс — одно ядро CPU. На четырёхъядерной машине три из четырёх ядер простаивают. Uvicorn умеет запускать несколько рабочих процессов двумя способами:
- Встроенный флаг
--workers N— uvicorn создаёт N дочерних процессов, каждый с собственным event loop. - Gunicorn с классом воркера
uvicorn.workers.UvicornWorker— даёт больше контроля: предзагрузка приложения, graceful restart, привязка к сокету.
Классическая рекомендация — 2 × количество_ядер + 1 воркер — пришла из мира синхронных WSGI-серверов, где каждый воркер обслуживает один запрос. Для асинхронного ASGI-сервера она не работает буквально: один асинхронный воркер уже обрабатывает сотни запросов. Здесь число воркеров привязано к ядрам, а не к ожидаемому RPS.
Практическое правило для ASGI: число воркеров равно числу ядер CPU. Если приложение в основном I/O-bound (ходит в базу, ждёт внешние API), выгода от лишних воркеров минимальна — она появляется, только если есть CPU-bound участки (валидация крупных Pydantic-моделей, рендеринг шаблонов, сериализация больших JSON).
Что происходит в памяти
Каждый воркер — отдельный процесс со своей копией приложения, своими импортами и своим пулом соединений. При четырёх воркерах и пуле на 20 соединений к базе сервер открывает до 80 соединений. PostgreSQL по умолчанию разрешает 100 (max_connections). Арифметика не сходится — и это главный источник TooManyConnectionsError.
Пул соединений к БД: где всё ломается
Асинхронные драйверы PostgreSQL — asyncpg, databases, SQLAlchemy с create_async_engine — создают пул на уровне процесса. Ключевые параметры пула:
| Параметр | Что делает | На что влияет |
|---|---|---|
pool_size (min_size) | Количество соединений, которые пул держит открытыми постоянно | Время первого запроса после простоя |
max_overflow (max_size) | Сколько дополнительных соединений пул создаст сверх pool_size | Потолок параллельных запросов к БД от одного воркера |
pool_timeout | Сколько секунд корутина ждёт свободное соединение, прежде чем получить ошибку | Время ответа при перегрузке |
pool_recycle | Через сколько секунд соединение считается устаревшим и закрывается | Устойчивость к разрывам на уровне сети / PgBouncer |
Формула для расчёта: max_connections_postgresql >= workers × (pool_size + max_overflow) + запас на миграции и мониторинг. Если неравенство не выполняется, под нагрузкой часть воркеров получит отказ.
PgBouncer как буфер
Прямое подключение каждого воркера к PostgreSQL плохо масштабируется. PgBouncer решает это, мультиплексируя десятки «клиентских» соединений от воркеров в небольшое число реальных серверных соединений.
Режимы PgBouncer и их совместимость с asyncpg:
- transaction — соединение закрепляется за клиентом на время транзакции. Основной режим для FastAPI: подходит для коротких запросов.
- statement — соединение отдаётся после каждого SQL-оператора. Не совместим с
asyncpg, который использует prepared statements (серверная фича, привязанная к соединению). - session — соединение держится, пока клиент не отключится. Фактически отменяет пользу от пулера.
При использовании asyncpg через PgBouncer в режиме transaction необходимо отключить подготовленные выражения на стороне драйвера. В SQLAlchemy 2.x это делается параметром prepared_statement_cache_size=0 при создании движка. Без этого — случайные ошибки вида prepared statement "__asyncpg_stmt_..." does not exist.
Как звенья связаны: путь запроса
- Клиент открывает TCP-соединение. Nginx (или балансировщик) передаёт его в один из воркеров uvicorn через unix-сокет или порт.
- Event loop воркера принимает соединение, парсит HTTP-заголовки (это делает
httptoolsилиh11). - Starlette маршрутизирует запрос, Pydantic валидирует входящие данные.
- Обработчик запрашивает соединение из пула БД. Если все заняты — корутина висит до
pool_timeout. - Запрос к PostgreSQL выполняется. Результат возвращается через пул, сериализуется в JSON, отправляется клиенту.
Каждый шаг — потенциальное бутылочное горлышко. Но чаще всего ломается шаг 4: пул исчерпан, корутины копятся, event loop перегружается таймерами ожидания, новые соединения принимаются, но не обрабатываются.
Граничные случаи и где подход ломается
Синхронные зависимости
FastAPI автоматически оборачивает синхронные обработчики (без async def) в run_in_executor — это пул потоков, по умолчанию ThreadPoolExecutor с лимитом в 40 потоков (значение по умолчанию для anyio). Если синхронных обработчиков много и каждый работает долго, 40 потоков заканчиваются быстрее, чем кажется. Увеличить лимит можно, но это маскирует проблему.
Долгие транзакции
Если бизнес-логика держит транзакцию открытой десятки секунд — для генерации отчёта, пакетного обновления — она держит и соединение из пула. Десять таких запросов заберут весь пул, остальные встанут.
Lifespan и инициализация пула
Пул соединений нужно создавать в lifespan-контексте FastAPI (или в событиях startup / shutdown). Если пул создаётся на уровне модуля до форка воркеров, все воркеры пытаются использовать один и тот же объект пула, созданный в родительском процессе. С asyncpg это приводит к InterfaceError: event loop дочернего процесса не совпадает с тем, в котором создавались сокеты. Gunicorn с --preload усугубляет ситуацию.
Горизонтальное масштабирование без PgBouncer
Два контейнера по четыре воркера с пулом 20 — уже 160 потенциальных соединений к одной базе. Третий контейнер — 240. Без PgBouncer или аналога (Odyssey, Supavisor) вы упрётесь в max_connections PostgreSQL, а его повышение выше нескольких сотен бьёт по памяти: каждое соединение — около 5–10 МБ RSS на стороне сервера БД.
Сноска: ASGI (Asynchronous Server Gateway Interface) — спецификация интерфейса между асинхронным Python-приложением и веб-сервером. Пришла на смену WSGI, чтобы поддержать WebSocket и потоковую передачу данных. Uvicorn, Hypercorn, Daphne — реализации ASGI-серверов.
Что мерить и что делать дальше
Прежде чем добавлять воркеры или увеличивать пул, соберите данные:
- pool_checked_out — сколько соединений из пула в использовании прямо сейчас (SQLAlchemy отдаёт через
pool.status()). Если значение стабильно около максимума — пул слишком мал или запросы слишком долгие. - event loop lag — задержка между запланированным и фактическим запуском колбэка. Растёт, когда loop перегружен. Библиотека
aiomonitorили кастомный periodic callback с замеромtime.monotonic()покажут картину. - active connections в PostgreSQL —
SELECT count(*) FROM pg_stat_activity WHERE state = 'active'. Сравните сmax_connections. - p99 latency — если p50 в норме, а p99 в десять раз хуже, дело почти наверняка в ожидании пула.
Следующий шаг после диагностики — не «больше воркеров», а правильный расчёт формулы workers × pool_size под ваш max_connections. Если соединений не хватает — ставьте PgBouncer и пересчитывайте. Тема настройки PgBouncer под asyncpg и prepared statements — отдельный разбор, здесь мы ограничились механикой взаимодействия.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Uvicorn — официальная документация (Deployment), SQLAlchemy — Asynchronous I/O (asyncio) Engine
Частые вопросы
Сколько воркеров uvicorn ставить на продакшене?
Формула 2N+1 для воркеров не подходит?
Что будет, если pool_size слишком большой?
Можно ли использовать asyncpg через PgBouncer?
Как понять, что проблема именно в пуле соединений, а не в чём-то другом?
А если у меня другая версия Python или uvicorn — что может отличаться?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.