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

Зачем пул и почему FastAPI тут ни при чём
FastAPI — ASGI-фреймворк. Он принимает HTTP-запросы и передаёт управление вашим корутинам. Ни одной строчки, отвечающей за TCP-соединения с PostgreSQL или MySQL, в его коде нет. За это отвечает пул соединений — компонент, который живёт уровнем ниже: в SQLAlchemy (через AsyncEngine), в asyncpg (Pool), в databases, в encode/databases и так далее.
Это важное различие. Когда приложение начинает тормозить на трёхстах одновременных запросах, проблема почти никогда не в FastAPI. Она — в том, как пул раздаёт и забирает соединения. Чтобы это чинить, нужно понимать механизм изнутри.
Материал написан для связки FastAPI + SQLAlchemy 2.x (async) + asyncpg + PostgreSQL — самого распространённого стека. Если вы используете другой драйвер, общая логика та же, но имена параметров и поведение по умолчанию отличаются — сверяйтесь с документацией своего драйвера.
Анатомия пула: что происходит на каждом запросе
Структура пула в SQLAlchemy async
Когда вы создаёте create_async_engine, SQLAlchemy инициализирует объект AsyncAdaptedQueuePool — асинхронную обёртку над QueuePool. Внутри это очередь (asyncio.Queue), в которой лежат свободные соединения, плюс набор счётчиков, контролирующих лимиты.
Жизненный цикл одного запроса к базе выглядит так:
- Checkout — корутина запрашивает соединение. Пул смотрит, есть ли свободное в очереди. Если есть, отдаёт. Если нет и лимит не исчерпан — создаёт новое TCP-соединение через asyncpg. Если лимит исчерпан — корутина ждёт, пока кто-то вернёт своё.
- Использование — корутина выполняет запросы. TCP-соединение закреплено за ней.
- Checkin — корутина завершает работу с сессией. Соединение возвращается в очередь, а не закрывается. Это и есть главный смысл пула: не платить за TCP-хендшейк и аутентификацию на каждый запрос.
Ключевые параметры
| Параметр | Значение по умолчанию (SQLAlchemy 2.x) | Что контролирует |
|---|---|---|
pool_size | 5 | Количество соединений, которые пул держит постоянно |
max_overflow | 10 | Сколько дополнительных соединений можно создать сверх pool_size |
pool_timeout | 30 (секунд) | Сколько корутина ждёт свободное соединение, прежде чем получить исключение |
pool_recycle | -1 (выключен) | Через сколько секунд соединение считается устаревшим и пересоздаётся |
pool_pre_ping | False | Отправлять ли тестовый запрос перед выдачей соединения |
Итого, по умолчанию пул способен обслуживать до 15 одновременных корутин (5 + 10). Шестнадцатая будет ждать до 30 секунд и, не дождавшись, получит TimeoutError.
Пул не ограничивает количество запросов в секунду — он ограничивает количество одновременных запросов к базе. Если каждый запрос выполняется за 2 мс, 15 соединений обслужат тысячи RPS. Если за 200 мс — сотню, и начнётся очередь.
Где начинаются проблемы
Утечка соединений: checkout без checkin
Самая частая причина «зависания» FastAPI-приложения — соединение, которое не вернули в пул. Это случается, когда сессию получают вручную, а не через зависимость с yield.
Правильный паттерн в FastAPI — зависимость-генератор:
# Python 3.11+, SQLAlchemy 2.x, asyncpg
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_session():
async with async_session() as session:
yield session
# checkin происходит здесь — при выходе из контекстного менеджера,
# даже если эндпоинт упал с исключением
Если вместо этого написать session = async_session() в теле эндпоинта и забыть await session.close(), при любом исключении соединение останется «занятым». Пять таких случаев — и пул пуст, приложение стоит.
pool_timeout как скрытый дедлайн
30 секунд ожидания — значение по умолчанию. В продакшене это почти всегда слишком много. Пользователь видит белый экран 30 секунд, затем получает 500. Реальный тайм-аут стоит выбирать исходя из SLA: если эндпоинт должен ответить за 500 мс, нет смысла ждать соединение 30 секунд — лучше отдать 503 через 1–2 секунды и позволить балансировщику повторить попытку.
pool_pre_ping: защита от «ушедших» соединений
PostgreSQL по умолчанию разрывает idle-соединения при перезапуске, failover или по настройке idle_session_timeout. Если пул отдаёт корутине соединение, которое сервер уже закрыл, запрос падает с ошибкой вида connection was closed или SSL connection has been closed unexpectedly.
pool_pre_ping=True решает проблему: перед каждой выдачей пул отправляет лёгкий запрос (SELECT 1 или его аналог через протокол). Цена — один дополнительный round-trip на каждый checkout. На локальной сети это доли миллисекунды, но при пуле в 5 соединений и 2 000 RPS вы добавите 2 000 лишних round-trip в секунду. Решайте, что дороже: редкие ошибки или постоянная микро-задержка.
pool_recycle и граничные случаи
Параметр pool_recycle задаёт максимальное время жизни соединения в секундах. После истечения при следующем checkin соединение закрывается, а не возвращается в очередь. Новое будет создано при следующем checkout.
Это нужно, когда между приложением и базой стоит PgBouncer, HAProxy или облачный прокси с собственным тайм-аутом на idle. Если прокси закрывает соединения через 300 секунд, а у вас pool_recycle=-1 (отключён), пул будет хранить «мёртвые» TCP-сокеты и отдавать их корутинам.
Типичная ошибка: установить pool_recycle больше, чем тайм-аут прокси. Соединение «протухнет» в прокси, но SQLAlchemy ещё считает его живым. Устанавливайте pool_recycle на 10–30 секунд меньше, чем тайм-аут промежуточного звена.
asyncpg под капотом: два уровня пулинга
Если вы используете SQLAlchemy async с asyncpg, работают два пула одновременно. SQLAlchemy создаёт свой QueuePool и оборачивает им нижележащий asyncpg. При этом asyncpg тоже умеет создавать пул через asyncpg.create_pool() — но SQLAlchemy его не использует, а создаёт одиночные соединения (asyncpg.connect()) и управляет ими сам.
Это значит, что параметры пула asyncpg (min_size, max_size) в этой связке не действуют. Настраивать нужно именно pool_size и max_overflow SQLAlchemy. Если вы обращаетесь к asyncpg напрямую, без SQLAlchemy, то управление пулом целиком на стороне asyncpg.Pool, и параметры другие — сверяйтесь с документацией asyncpg1.
1 Overflow — «переполнение» пула: соединения сверх pool_size, которые создаются по требованию и закрываются сразу после checkin, а не возвращаются в очередь. Это позволяет пулу быть компактным в среднем и расширяться при пиках.
Как подобрать размер пула
Универсальной формулы нет, но есть ограничения, от которых стоит отталкиваться:
- Сверху —
max_connectionsв PostgreSQL (по умолчанию 100). Если у вас четыре воркера Uvicorn с пулом 5 + 10, это уже 60 потенциальных соединений. Добавьте миграции, cron-задачи, мониторинг — и сотня исчерпана. - Снизу — ожидаемая параллельность. Если средний запрос к базе занимает 5 мс, а вам нужно 500 RPS, достаточно трёх соединений (
500 × 0.005 = 2.5). На практике распределение неравномерно, поэтому закладывают двух-трёхкратный запас. - PgBouncer меняет картину. Если перед базой стоит PgBouncer в режиме transaction, каждый воркер может держать больший пул — PgBouncer мультиплексирует их в меньшее число серверных соединений. Но и добавляет свой слой граничных случаев: prepared statements не работают в transaction mode,
SET-команды теряются между транзакциями.
Что мерить и куда смотреть
SQLAlchemy предоставляет события пула: checkout, checkin, connect, invalidate. Подписавшись на них, можно считать метрики и отправлять в Prometheus или любой другой коллектор.
Три метрики, без которых слепой полёт:
- checkedout — сколько соединений сейчас занято. Если стабильно равно
pool_size + max_overflow, пул исчерпан. - checkout_wait_time — сколько корутина ждала соединение. Если больше 50 мс — уже сигнал.
- invalidated — сколько соединений пул выбросил как нерабочие. Рост означает проблемы на уровне сети или базы.
Объект пула доступен через engine.pool. У синхронного QueuePool есть метод status(), возвращающий текстовую сводку. В асинхронной обёртке он тоже доступен через engine.pool.status() — используйте его в отладочном эндпоинте, но не выставляйте наружу в продакшене.
Что делать дальше
Если вы дочитали до сюда и ни разу не смотрели на pool.status() своего приложения — начните с этого. Добавьте вывод статуса пула в health-check эндпоинт (внутренний, не публичный) и понаблюдайте под реальной нагрузкой. Дальше — подключите события пула к метрикам. Тема инвалидации соединений и работы через PgBouncer заслуживает отдельного разбора: здесь мы её только обозначили, не разворачивая.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: SQLAlchemy — Connection Pooling (официальная документация), asyncpg — Connection Pool (официальная документация)
Частые вопросы
Что произойдёт, если все соединения в пуле заняты?
Нужно ли менять pool_size, если я использую PgBouncer?
Как понять, что происходит утечка соединений?
Работают ли настройки asyncpg.create_pool(), если я использую SQLAlchemy async?
Приложение падает с ошибкой 'connection was closed' — это проблема пула?
Если у меня другая версия SQLAlchemy (1.4), всё то же самое?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.