Connection pool в asyncpg и SQLAlchemy: как не положить базу
Connection pool — одна из тех вещей, которые работают, пока всё хорошо, и взрываются разом, когда плохо. Сервис тихо обслуживает 200 RPS, а потом приходит пик в 800, и Postgres начинает отдавать FATAL: too many clients already. Или сложнее: пул не отдаёт соединения, висит в ожидании, p99 уходит в космос, мониторинг ругается, ты ничего не понимаешь.
Я разберу, как устроен пул в asyncpg и SQLAlchemy 2 (async), какие настройки реально влияют на стабильность, и какие ошибки приводят к тому, что одна фоновая таска кладёт боевую базу.
Краткая модель: что такое пул
Пул — это набор открытых TCP-соединений к Postgres, которые приложение переиспользует. Открыть соединение — операция дорогая (TLS handshake, аутентификация, выделение бэкенда на стороне Postgres). Делать это на каждый запрос — самоубийство для latency.
В мире Python для асинхронной работы есть два уровня:
- asyncpg — чистый драйвер, у него свой пул. Используется, когда ты пишешь сырые SQL-запросы.
- SQLAlchemy 2 + asyncpg — SQLAlchemy управляет своим пулом поверх asyncpg-соединений. Это то, что ты получаешь в FastAPI приложении с ORM.
Параметры у обоих, но называются по-разному, и их часто путают. Это причина 80% проблем.
Параметры пула в SQLAlchemy
Когда создаёшь движок:
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
"postgresql+asyncpg://user:pass@host:5432/db",
pool_size=20,
max_overflow=10,
pool_timeout=10,
pool_recycle=1800,
pool_pre_ping=True,
)
Что значат эти параметры:
pool_size— базовый размер пула. Соединений всегда столько и не больше в нормальном режиме.max_overflow— сколько дополнительных соединений можно открыть на пиках. Они закрываются после возврата.pool_timeout— сколько секунд ждать свободное соединение, прежде чем кинутьTimeoutError.pool_recycle— через сколько секунд пересоздавать соединение. Полезно против пограничных таймаутов и мёртвых коннекшнов.pool_pre_ping— пинговать соединение перед использованием. Дешёвая страховка от мёртвых сокетов после файловера БД.
Максимум одновременных соединений к базе с одного инстанса = pool_size + max_overflow. Это то число, которое надо умножать на количество подов.
Считаем, не угадываем
Самая частая ошибка — поставить pool_size=100 на каждом из 10 подов «на всякий случай» и удивиться, что Postgres падает.
Postgres держит фиксированное число клиентов, обычно max_connections=100 или 200. Каждое соединение — это процесс на стороне БД с памятью. Если у тебя 10 подов по 100 коннекшнов — ты уже превысил лимит, и часть инстансов получит ошибку при подключении.
Считать надо так:
total_app_connections = pods * (pool_size + max_overflow)
total_app_connections + system_overhead < postgres.max_connections
Где system_overhead — это все остальные клиенты: миграционный воркер, фоновые скрипты, ваши инструменты, репликации. У нас рабочая формула: 0.7 * max_connections отдаём приложению, 0.3 резерв.
Если приложение реально упирается в пул — лучше масштабировать через PgBouncer (об этом дальше), а не наращивать pool_size на подах.
Как пул себя ведёт под нагрузкой
Допустим, pool_size=10, max_overflow=5. Параллельно прилетает 20 запросов, каждый просит сессию.
- Первые 10 берут соединения из пула.
- Следующие 5 открывают overflow-соединения.
- Оставшиеся 5 встают в очередь и ждут до
pool_timeout. - Если кто-то освободил соединение — следующий из очереди его получает.
- Если за
pool_timeoutникто не освободил — кидаетсяTimeoutErrorи запрос падает с 500.
Симптомы перегрузки пула: всплески 500-х в районе одного эндпоинта, рост p99 на других, метрика «active connections» прижата к потолку.
Главный антипаттерн: соединение на весь жизненный цикл
Самая частая причина утечек — длинные удержания сессии. Видел такое в реальном коде:
@app.get("/report")
async def long_report(session: AsyncSession = Depends(get_session)):
data = await session.execute(big_query)
pdf = await render_pdf(data) # 5 секунд в CPU
await s3_client.upload(pdf) # 3 секунды в сеть
await session.execute(write_log)
return {"ok": True}
Сессия (а значит и соединение к БД) живёт всё это время — 8 секунд. На пике в 100 RPS такие запросы съедают весь пул. Решение — закрывать сессию сразу после завершения работы с БД.
@app.get("/report")
async def long_report():
async with async_session_factory() as session:
data = await session.execute(big_query)
pdf = await render_pdf(data)
await s3_client.upload(pdf)
async with async_session_factory() as session:
await session.execute(write_log)
await session.commit()
return {"ok": True}
Соединение возвращается в пул сразу, как закончилось дело с БД. Внешние операции идут вне сессии.
Транзакции и автокоммит
В SQLAlchemy 2 модель транзакций строгая: каждая AsyncSession по умолчанию открывает транзакцию при первом запросе и держит её до commit или rollback. Если ты забыл закоммитить и просто закрыл сессию — Postgres получит ROLLBACK.
В сценарии «открыл сессию, прочитал, закрыл» это работает нормально. Но если ты держишь сессию долго (см. предыдущий раздел), у тебя долгая транзакция, что хуже долгого соединения: блокирует VACUUM, держит снэпшоты, бьёт по производительности БД.
Лайфхак для read-only эндпоинтов:
async with async_session_factory() as session:
async with session.begin():
result = await session.execute(query)
# транзакция закрылась, соединение всё ещё за нами
# но если нам больше ничего не надо, пускаем session out of scope
PgBouncer и его подводные камни
Когда нужно много инстансов приложения, добавляют PgBouncer — пулер на стороне Postgres, который мультиплексирует подключения. Приложение подключается к PgBouncer, а тот держит ограниченный пул реальных соединений к БД.
Это работает, но есть нюанс: PgBouncer в режиме transaction pooling переиспользует соединение между разными клиентами на границе транзакций. А значит, если ты используешь:
- prepared statements (а asyncpg их использует автоматически),
- session-level настройки (
SET), LISTEN/NOTIFY,- advisory locks между транзакциями,
они не будут работать или будут вести себя неожиданно. Решение для asyncpg — отключить кэш prepared statements в SQLAlchemy:
engine = create_async_engine(
DSN,
connect_args={"prepared_statement_cache_size": 0, "statement_cache_size": 0},
)
Без этого через PgBouncer ты получаешь периодические InvalidSQLStatementName: prepared statement, созданный на одном бэкенде, отсутствует на другом.
Мониторинг, без которого не жить
Базовый набор метрик, которые надо снимать с пула:
- Размер пула (текущий).
- Используемые соединения.
- Соединения в очереди на получение (если > 0 — пора смотреть).
- Количество overflow-соединений.
- Среднее время удержания соединения.
В SQLAlchemy метрики доступны через engine.pool.status(), но удобнее подцепить events и слать в Prometheus. На стороне Postgres снимай pg_stat_activity: сколько коннекшнов, сколько в состоянии idle in transaction, сколько active.
Идеальная картинка: active — небольшое число, idle — около размера пула, idle in transaction — нулёвый или пренебрежимо малый. Если у тебя массивный idle in transaction — это утечка транзакций где-то в коде.
Чек-лист «не положить базу»
- Считай
pool_size * pods + overflow, держи под лимитом Postgres. - Не делай тяжёлые операции внутри сессии БД.
- Включи
pool_pre_pingиpool_recycle. - Если ставишь PgBouncer — отключи prepared statement cache.
- Снимай метрики пула и Postgres-side активности.
- Поставь алерт на «соединения ждут в очереди > 0 секунд более минуты».
База падает не от того, что приложение делает много запросов. Она падает от того, что приложение неаккуратно обращается с соединениями. Пул — это твой бюджет, и расходовать его надо так же серьёзно, как память или CPU.