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

Сервис на FastAPI держал 200 RPS без единой ошибки, а на 450 начал отдавать TimeoutError: QueuePool limit of size 20 overflow 10 reached. База при этом простаивала на 30% CPU. Классическая картина: узкое место не в PostgreSQL, а в пуле соединений на стороне приложения. Ниже — как размер пула, его время жизни и стратегия переполнения меняют поведение всей системы под нагрузкой, и почему цифру pool_size нельзя ставить наугад.
Речь про синхронный SQLAlchemy 2.x с драйвером psycopg2/psycopg3 и про асинхронный стек (asyncpg, SQLAlchemy async, asyncpg.create_pool). Инвалидация кеша и репликация — отдельные темы, здесь рассматриваем только пул соединений с базой и его последствия для латентности, памяти и отказоустойчивости.
Что вообще делает пул
Открытие TCP-соединения с PostgreSQL плюс аутентификация и запуск backend-процесса на сервере — это единицы, а иногда десятки миллисекунд. При тысячах запросов в секунду открывать соединение на каждый запрос нельзя: латентность рукопожатия начнёт доминировать, а сервер захлебнётся форками. Пул держит открытые соединения наготове и раздаёт их запросам.
Важная деталь, которую упускают: одно соединение PostgreSQL — это один процесс на сервере базы.1 Пул на стороне приложения не создаёт "виртуальных" соединений — каждое занятое соединение это реальный процесс, который ест память и слот в лимите max_connections.
Размер пула — это не настройка приложения. Это контракт с базой данных о том, сколько её процессов вы готовы занять. Один процесс в приложении умножается на число воркеров и число реплик приложения.
Арифметика, которая ломает прод
Пул задаётся на процесс. Если у вас Gunicorn с 8 воркерами, pool_size=20 и max_overflow=10, то один инстанс приложения при пике держит до 8 × 30 = 240 соединений. Три пода в Kubernetes — до 720. А max_connections в PostgreSQL по умолчанию 100.
Результат: база отвергает новые соединения с FATAL: sorry, too many clients already раньше, чем приложение упрётся в собственные лимиты. Причём отвалится не тот под, который создал нагрузку, а любой, кому не хватило слота.
Три рычага и чем каждый платит
У пула SQLAlchemy три параметра, которые реально меняют поведение: pool_size, max_overflow, pool_timeout. Плюс pool_recycle и pool_pre_ping — они про живучесть, а не про пропускную способность.
| Параметр | Что даёт | Чем платите |
|---|---|---|
Большой pool_size | Меньше ожидания соединения на пике | Больше процессов на базе, больше RAM, риск упереться в max_connections |
Большой max_overflow | Пул терпит всплески | Всплеск нагрузки бьёт по базе без предела, деградация незаметна до отказа |
Малый pool_timeout | Быстрый отказ вместо зависших запросов | Пользователь видит 500 раньше, но система не копит очередь |
pool_pre_ping=True | Не отдаёт мёртвое соединение после разрыва | Лишний round-trip перед каждым запросом (обычно приемлемо) |
Ключевой контринтуитивный момент: больший пул не всегда даёт больше пропускной способности. Если база — узкое место (диск, CPU, блокировки), рост пула лишь увеличивает число параллельных запросов, которые конкурируют за один ресурс. Латентность каждого растёт, суммарный throughput не двигается или падает.
Как задать пул явно
# SQLAlchemy 2.x, синхронный движок с psycopg2
from sqlalchemy import create_engine
engine = create_engine(
"postgresql+psycopg2://app:YOUR_PASSWORD@db:5432/shop",
pool_size=10, # постоянные соединения на процесс
max_overflow=5, # временные сверх pool_size
pool_timeout=3, # секунды ожидания свободного соединения
pool_recycle=1800, # пересоздавать соединение старше 30 минут
pool_pre_ping=True, # проверять живость перед выдачей
)
При 4 воркерах Gunicorn этот движок займёт до 4 × 15 = 60 соединений на инстанс. Считайте это число до деплоя, а не после инцидента.
Асинхронный стек: та же арифметика, другие грабли
С asyncpg соблазн больше: один event loop обрабатывает тысячи корутин, и хочется дать пулу простор. Но пул asyncpg (asyncpg.create_pool) имеет параметры min_size и max_size, и max_size — это по-прежнему потолок реальных процессов на базе.
# asyncpg 0.29.x — создание пула
import asyncpg
pool = await asyncpg.create_pool(
dsn="postgresql://app:YOUR_PASSWORD@db:5432/shop",
min_size=5,
max_size=20,
command_timeout=5, # таймаут на выполнение запроса
)
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT id, title FROM product WHERE active = TRUE")
Опасность асинхронного кода в другом: корутина, которая забыла вернуть соединение (забытый async with или удержание соединения на время внешнего HTTP-запроса), выводит слот из оборота молча. Пул не переполняется мгновенно — деградация растянута во времени, и связать её с конкретным местом кода тяжело.
Держите соединение как можно короче
Антипаттерн — взять соединение в начале обработки запроса и держать до конца, пока внутри идёт вызов платёжного API на 800 мс. Всё это время слот занят и никому не доступен. Правильно: брать соединение непосредственно перед запросом к базе и отпускать сразу после, а долгие внешние вызовы делать вне транзакции.
Где подход ломается
- PgBouncer в режиме transaction pooling. Если между приложением и базой стоит PgBouncer, ваш пул SQLAlchemy общается с ним, а не с PostgreSQL напрямую. Сессионные фичи (prepared statements у psycopg2,
SETна сессию, advisory locks на сессию) в transaction-режиме ломаются. Для asyncpg через PgBouncer нужно отключать кеш подготовленных выражений — иначе получитеprepared statement "__asyncpg_..." already exists. - Долгие транзакции. Соединение с открытой транзакцией держит слот и блокирует VACUUM. Пул тут ни при чём, но именно он маскирует проблему: слоты кончаются, а причина — забытый
commit. - Форк после создания движка. Если создать
engineдо форка воркеров (частая ошибка с Gunicorn и preload), дочерние процессы унаследуют одни и те же сокеты. Движок нужно создавать после форка или вызыватьengine.dispose()в хуке инициализации воркера. - Serverless и autoscaling. Каждый новый инстанс поднимает свой пул. При агрессивном масштабировании база получает лавину соединений. Здесь пул на стороне приложения бесполезен — нужен внешний пулер (PgBouncer, RDS Proxy).
Что и как мерить
Не подбирайте pool_size на глаз. Соберите цифры до и после изменения на реалистичной нагрузке:
- Число активных соединений на базе. Запрос
SELECT count(*) FROM pg_stat_activity WHERE datname = 'shop';под нагрузкой. Сравните сSHOW max_connections;. - Время ожидания соединения из пула. Если пул исчерпан, оно растёт. В SQLAlchemy можно вешать слушатели событий на
checkout/checkinи писать длительность удержания. - p95/p99 латентности запросов при разных
pool_size. Постройте кривую: часто throughput выходит на плато задолго до того, как пул перестаёт помогать. - Соотношение занятых и idle соединений на базе. Много
idle in transaction— сигнал забытых транзакций, а не маленького пула.
Условия замера фиксируйте всегда: версия PostgreSQL, число воркеров и подов, характер запросов (чтение против записи), железо базы. Цифра pool_size=10, которая идеальна для одного профиля, будет вредна для другого.
Что делать дальше
Начните с ревизии реального числа соединений: перемножьте pool_size + max_overflow на воркеров и на поды, сравните с max_connections. Если сумма близка к лимиту базы — либо снижайте пул, либо ставьте PgBouncer и делайте пул приложения маленьким, отдав управление внешнему пулеру. Дальше настройте мониторинг pg_stat_activity и время удержания соединения, чтобы ловить забытые транзакции до того, как они выест слоты. Инвалидация кеша, реплики для чтения и шардинг — следующие уровни, но пул считают первым.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: SQLAlchemy 2.0 — Connection Pooling, asyncpg documentation — Connection Pools
Частые вопросы
Какой pool_size ставить по умолчанию?
А если у меня asyncpg вместо SQLAlchemy?
Что делать, если появляется QueuePool limit reached?
Нужен ли PgBouncer, если у меня уже есть пул в приложении?
Увеличит ли большой пул пропускную способность?
Почему после форка Gunicorn соединения ведут себя странно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.