lenec ru

← все посты

Connection pool в asyncpg и SQLAlchemy: как не положить базу

10K

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 запросов, каждый просит сессию.

  1. Первые 10 берут соединения из пула.
  2. Следующие 5 открывают overflow-соединения.
  3. Оставшиеся 5 встают в очередь и ждут до pool_timeout.
  4. Если кто-то освободил соединение — следующий из очереди его получает.
  5. Если за 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.

Комментарии 1

  • Ирина Лисицына

    На стороне аналитики один и тот же эффект ловила, когда Metabase открывал десятки соединений к Postgres под одно расписание дашборд. Уперлись в max_connections, а после введения PgBouncer в transaction-pooling режиме потребление коннектов упало в 6-7 раз. Вопрос практический: вы PgBouncer перед asyncpg-пулом ставите или живёте только с пулом приложения? У asyncpg в session-pooling всё прекрасно, но как раз в transaction-pooling он плохо переваривает SET LOCAL и prepared statements.

Войдите, чтобы оставить комментарий.