Пул соединений к базе в Python: где он ломается
Приложение упирается в лимит соединений PostgreSQL, а нагрузка на нём смешная. Разбираем, откуда берутся утечки, почему pre_ping не спасает и что мерить, чтобы найти дырявый пул.

Типичная картина: FastAPI-сервис держит 40 воркеров, PostgreSQL настроен на max_connections = 200, а в логах внезапно FATAL: sorry, too many clients already. При том, что реальная нагрузка — сотни две запросов в секунду, база не должна была задохнуться. Проблема почти всегда не в базе и не в железе, а в том, как приложение берёт и возвращает соединения. Разберём, где именно пул течёт, почему это долго не видно и как это поймать на замерах, а не на проде в три часа ночи.
Откуда вообще берётся лимит
Каждое соединение к PostgreSQL — это отдельный процесс на стороне сервера, со своим куском памяти (порядка нескольких мегабайт под work_mem и служебные буферы). Поэтому max_connections не растят до бесконечности: 100–300 — обычный диапазон. Пул соединений на стороне приложения существует ровно для того, чтобы держать небольшое число живых соединений и переиспользовать их между запросами, а не открывать новое на каждый SELECT.
Ломается это в трёх точках: соединение взяли и не вернули, пул создали не там, где думали, и пул настроен так, что суммарно приложение хочет больше соединений, чем база готова дать. Дальше — по каждой.
Лимит соединений исчерпывают не пиковые нагрузки, а забытые соединения, которые висят открытыми, пока их не соберёт таймаут. Пик просто проявляет то, что копилось часами.
Ошибка первая: соединение не возвращается в пул
С SQLAlchemy 2.x самый частый источник утечки — работа с Engine.connect() или Session без контекстного менеджера. Если между взятием соединения и его возвратом случается исключение, а close() вызывается вручную ниже по коду, соединение не вернётся в пул никогда.
# SQLAlchemy 2.x — так соединение утечёт при исключении в do_work()
conn = engine.connect()
do_work(conn)
conn.close() # не выполнится, если do_work бросил исключение
Правильно — отдать управление возвратом контекстному менеджеру. Тогда соединение вернётся в пул при любом выходе из блока, включая исключение:
# SQLAlchemy 2.x — соединение вернётся в пул при любом выходе из with
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
conn.commit()
# здесь conn уже возвращён в пул
То же с сессиями: with Session(engine) as session: либо, при работе с фабрикой, with session_factory() as session:. Ручной session.close() в теле функции без try/finally — красный флаг на ревью.
Как поймать: включите логирование пула. В SQLAlchemy у QueuePool есть параметр echo_pool при создании движка — он печатает события checkout/checkin. Если в логах есть checkout без соответствующего checkin, у вас утечка.
Ошибка вторая: пул рождается на каждый запрос
Движок SQLAlchemy (create_engine) — это владелец пула. Он должен создаваться один раз на процесс, а не внутри обработчика запроса. Классическая ошибка во фреймворках — вызов create_engine() внутри вью или зависимости, которая пересоздаётся на каждый запрос:
# Антипаттерн: новый движок и новый пул на каждый вызов
def get_user(user_id: int):
engine = create_engine(DATABASE_URL) # свежий пул каждый раз
with engine.connect() as conn:
...
Каждый такой движок открывает свои соединения и держит их до сборки мусора. Под нагрузкой это моментально выедает max_connections. Движок создаётся на старте приложения и живёт как синглтон модуля или элемент состояния приложения.
Отдельная ловушка: форк процессов
Если движок создан до форка (Gunicorn с preload_app, Celery с prefork), дочерние процессы наследуют файловые дескрипторы уже открытых соединений. Два процесса будут писать в один сокет — получите битые ответы и загадочные ошибки протокола. Лечится вызовом engine.dispose() в хуке после форка, чтобы каждый воркер открыл свои соединения заново. У Gunicorn это post_fork, у Celery — сигнал worker_process_init.
Ошибка третья: арифметика пула не сходится с базой
Здесь считают на пальцах и ошибаются. Суммарное число соединений, которое приложение может захотеть, — это
(pool_size + max_overflow) × число_процессов
При pool_size=10, max_overflow=20 и 16 воркерах Gunicorn получаем до 480 соединений против max_connections=200. База начнёт отказывать задолго до того, как приложение выйдет на проектную нагрузку. Параметры pool_size и max_overflow задаются при создании движка; в текущих версиях SQLAlchemy их актуальные значения по умолчанию и семантику проще сверить в разделе документации по QueuePool, чем держать в голове.
Что означают ключевые параметры
- pool_size — сколько соединений пул держит открытыми постоянно.
- max_overflow — сколько сверх
pool_sizeможно временно открыть под пиком; после возврата они закрываются. - pool_timeout — сколько секунд ждать свободного соединения, прежде чем бросить исключение. Если упираетесь в него — пул мал или соединения не возвращаются.
- pool_recycle — принудительно закрывать соединение старше N секунд. Нужен, когда между приложением и базой стоит балансировщик или файрвол, рвущий простаивающие TCP-сессии.
- pool_pre_ping — перед выдачей соединения проверить его живым лёгким запросом. Спасает от «мёртвых» соединений после рестарта базы, но не лечит утечки: он проверяет, а не возвращает.
Async: свой пул и своё правило
С asyncpg напрямую вы работаете не через QueuePool, а через asyncpg.create_pool(). Логика та же — пул создаётся один раз, соединение берётся через async with pool.acquire() as conn: и возвращается на выходе из блока. Ручной acquire() без гарантированного release() течёт так же, как синхронный вариант.
# asyncpg — соединение возвращается в пул на выходе из async with
import asyncpg
pool = await asyncpg.create_pool(dsn, min_size=5, max_size=20)
async def get_count():
async with pool.acquire() as conn:
return await conn.fetchval("SELECT count(*) FROM orders")
Отдельная ошибка в async — блокирующий вызов внутри корутины, который надолго занимает соединение и не даёт event loop освободить его. Соединение формально в пуле, но фактически занято одной висящей задачей.
Как измерить, а не гадать
Не полагайтесь на ощущение «вроде течёт». Смотрите на две стороны одновременно.
Со стороны базы — реальное число соединений от вашего приложения:
-- PostgreSQL: соединения в разбивке по состоянию
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'your_db'
GROUP BY state;
Если растёт число строк в состоянии idle in transaction — у вас открытые транзакции, которые не закоммичены и не откачены. Это худший вид утечки: такое соединение держит блокировки. Ищите места, где начали транзакцию и не завершили её.
Со стороны приложения — статус пула SQLAlchemy. У пула есть метод, возвращающий строку с числом занятых, свободных и overflow-соединений; точное имя метода для вашей версии проверьте в документации по API пула. Снимайте эти цифры периодически и отправляйте в метрики. График «занятые соединения растёт и не падает при снятии нагрузки» — прямая улика утечки.
| Симптом | Что смотреть первым | Вероятная причина |
|---|---|---|
| too many clients already | (pool_size+max_overflow)×воркеры vs max_connections | Арифметика пула больше лимита базы |
| Число соединений в pg_stat_activity растёт без нагрузки | echo_pool, checkout без checkin | Соединения не возвращаются |
| Много idle in transaction | Незакрытые транзакции | Нет commit/rollback |
| pool_timeout срабатывает под пиком | pool_size и длительность запросов | Пул мал или запросы долгие |
| Битые ответы после старта воркеров | Момент создания движка vs форк | Пул создан до fork, нет dispose |
Что делать дальше
Начните с наблюдаемости, а не с подкрутки параметров. Поднимите два дашборда: pg_stat_activity по состояниям и статус пула приложения, снимаемый раз в 10–15 секунд. Прогоните нагрузочный тест (locust, k6) и смотрите, падает ли число занятых соединений после снятия нагрузки — если не падает, ищите утечку по echo_pool, прежде чем трогать pool_size. И проверьте на ревью главное правило: каждое взятие соединения или сессии обёрнуто в with, а движок создаётся один раз на процесс с корректным dispose() после форка. Инвалидация соединений после падения реплики — отдельная тема, здесь мы разбирали только логику самого пула.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: SQLAlchemy Engine and Connection Pooling, PostgreSQL: pg_stat_activity
Частые вопросы
У меня другая версия SQLAlchemy — 1.4, не 2.x. Что изменится?
Я всё обернул в with, но соединения всё равно копятся. Где ещё смотреть?
Грозит ли утечка пула падением всего приложения?
pool_pre_ping включён — почему соединения всё равно мёртвые после рестарта базы?
Какой pool_size ставить в проде?
Нужен ли отдельный пулер вроде PgBouncer, если пул уже есть в SQLAlchemy?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.