Пул соединений в Django: как устроено внутри
Django долго жил без встроенного пула соединений к базе данных и открывал новое подключение на каждый запрос. В версии 5.1 появился встроенный connection pool на основе пакета, который раньше приходилось ставить отдельно. Разбираем, как он работает на уровне кода, где экономит время, а где создаёт проблемы.

Зачем вообще пул и чего стоит его отсутствие
Установка TCP-соединения с PostgreSQL занимает от 3 до 15 мс в зависимости от сети и настроек SSL. Если сервер обрабатывает 500 запросов в секунду, а каждый запрос открывает и закрывает соединение — это до 7,5 секунд чистого ожидания на сеть каждую секунду. База при этом тратит ресурсы на форк нового backend-процесса, аутентификацию и инициализацию. При пике нагрузки число соединений упирается в max_connections PostgreSQL (по умолчанию 100), и новые запросы получают отказ.
Пул решает эту задачу: он держит набор уже открытых соединений и раздаёт их по запросу, возвращая обратно после использования. Вместо 500 коннектов в секунду база видит 10–20 постоянных.
Как было до Django 5.1
До версии 5.1 Django управлял соединениями через объект django.db.connections, привязанный к потоку. Каждый поток хранил своё соединение в thread-local переменной. Параметр CONN_MAX_AGE в настройках базы определял, сколько секунд соединение переиспользуется внутри одного потока:
CONN_MAX_AGE = 0(по умолчанию) — соединение закрывается после каждого HTTP-запроса.CONN_MAX_AGE = None— соединение живёт, пока жив поток.- Любое положительное число — соединение закрывается, если его возраст превысил заданное значение.
Это не пул. Соединение не передаётся между потоками. При использовании Gunicorn с prefork-моделью каждый воркер держит своё соединение, и общее число коннектов равно числу воркеров. Проблемы начинались в двух сценариях:
- Асинхронные серверы (ASGI, Daphne, Uvicorn) — корутины не привязаны к потокам, и thread-local хранилище не работает предсказуемо.
- Высокий параллелизм с короткоживущими потоками — каждый поток создавал новое соединение, и
CONN_MAX_AGEне спасал.
Внешним решением был PgBouncer или пакет django-db-connection-pool, обёртывающий SQLAlchemy QueuePool. Оба работают, но добавляют компонент в инфраструктуру или зависимость в код.
Что появилось в Django 5.1
В Django 5.1 для бэкенда PostgreSQL добавлен встроенный пул на основе psycopg (версия 3) и его пула psycopg_pool.ConnectionPool. Ключевое: пул работает только с бэкендом django.db.backends.postgresql и только когда установлен psycopg[pool] (то есть пакет psycopg_pool).
Настройка
В DATABASES появился параметр "CONN_MAX_AGE" в связке с новым ключом "OPTIONS":
# settings.py — Django 5.1+, psycopg 3.x
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mydb",
"USER": "myuser",
"PASSWORD": "YOUR_DB_PASSWORD",
"HOST": "db.example.com",
"PORT": "5432",
"CONN_MAX_AGE": None, # соединения живут долго
"CONN_HEALTH_CHECKS": True, # проверка перед выдачей
"OPTIONS": {
"pool": { # параметры psycopg_pool
"min_size": 2, # минимальное число соединений
"max_size": 10, # потолок
"timeout": 5, # секунд ожидания свободного коннекта
}
},
}
}
Если ключ "pool" отсутствует или psycopg_pool не установлен, Django работает по старой схеме — никакого пула, только thread-local соединение.
Пул в Django 5.1 — это не собственная реализация, а тонкая обёртка вокруг psycopg_pool.ConnectionPool. Django отвечает за жизненный цикл (когда брать / когда возвращать), а за очередь, healthcheck и переподключение — psycopg_pool.
Жизненный цикл соединения с пулом
При каждом обращении к базе Django вызывает ensure_connection() на объекте DatabaseWrapper. Вот что происходит на уровне кода (упрощённо, без обработки ошибок):
DatabaseWrapper.connect()вызываетpool.getconn()вместо прямогоpsycopg.connect().psycopg_pool.ConnectionPoolпроверяет очередь свободных соединений. Если свободное есть — отдаёт. Если нет и текущее число соединений меньшеmax_size— создаёт новое. Если число равноmax_size— блокирует вызов доtimeoutсекунд.- Если включён
CONN_HEALTH_CHECKS, перед выдачей пул отправляет простой запрос (по умолчаниюSELECT 1или пинг на уровне протокола), чтобы убедиться, что соединение живое. Это добавляет один round-trip, но спасает от ошибок на мёртвом соединении. - Django выполняет запросы приложения.
- В
close()вместо реального закрытия TCP-соединения вызываетсяpool.putconn(conn). Соединение возвращается в пул.
Шаг 5 происходит в сигнале request_finished — по завершении каждого HTTP-запроса. Для ASGI — после каждой корутины, обслужившей запрос.
Что происходит внутри psycopg_pool
Чтобы понять поведение Django-пула, нужно знать, как работает psycopg_pool.ConnectionPool (версия 3.2+):
Структура очереди
Пул хранит свободные соединения в списке. При запросе соединения — берёт последнее добавленное (LIFO). LIFO важна: недавно использованное соединение с большей вероятностью живое, и TCP-сессия ещё «прогрета».
Фоновый поток
Пул запускает фоновый поток (или asyncio-задачу в асинхронном режиме), который:
- Поддерживает
min_size— если свободных соединений меньше минимума, создаёт новые в фоне. - Удаляет соединения старше
max_lifetime(по умолчанию 3600 секунд) — это защита от ситуации, когда PostgreSQL разрывает долгоживущие соединения поidle_session_timeoutили после рестарта. - Выполняет healthcheck на idle-соединениях с интервалом
check.
Таймаут и переполнение
Если все max_size соединений заняты, запрос ставится в очередь ожидания. Через timeout секунд — исключение pool.PoolTimeout. Django оборачивает его в OperationalError.
| Параметр | Что регулирует | Значение по умолчанию (psycopg_pool) | На что влияет |
|---|---|---|---|
| min_size | Минимум соединений в пуле | 4 | Потребление памяти PostgreSQL в простое |
| max_size | Потолок соединений | min_size (нет роста) | Пиковая нагрузка на базу |
| timeout | Ожидание свободного коннекта | 30 с | Время до ошибки при перегрузке |
| max_lifetime | Максимальный возраст соединения | 3600 с | Балансировка после failover, защита от idle-kill |
| max_idle | Сколько idle-соединений хранить | min_size | Освобождение ресурсов после пика |
Значения по умолчанию актуальны для psycopg_pool 3.2. Django может задавать свои значения через ключ pool в OPTIONS — сверяйте с документацией psycopg_pool.
Где подход ломается
Несоответствие max_size и числа воркеров
Если Gunicorn запускает 8 воркеров, а max_size = 10, суммарный потолок — 80 соединений. PostgreSQL с max_connections = 100 оставит 20 на суперпользователя, мониторинг и миграции. При автоскейлинге (Kubernetes с HPA) число подов растёт, и каждый под приносит свои 10 соединений. Формула для расчёта:
max_connections ≥ (число подов × число воркеров × max_size) + запас
Если не сходится — ставьте PgBouncer между Django и PostgreSQL. Пул внутри Django и PgBouncer снаружи не конфликтуют, но нужно понимать, что вы получаете два уровня пулинга, и max_size в Django можно уменьшить.
Утечка соединений в долгих задачах
Celery-воркер берёт соединение из пула, запускает задачу на 30 секунд — и всё это время соединение занято. Если 10 задач параллельно используют базу, пул исчерпан. Решение: в тяжёлых задачах явно вызывайте django.db.close_old_connections() после завершения блока работы с базой, чтобы вернуть соединение в пул раньше.
ASGI и потокобезопасность
psycopg_pool.ConnectionPool потокобезопасен. Но в ASGI-режиме Django оборачивает синхронные ORM-вызовы в sync_to_async, и каждый из них выполняется в потоке из пула потоков. Если пул потоков больше max_size соединений — часть потоков будет ждать. Убедитесь, что размер thread pool (например, ASGI_THREADS в Uvicorn) не сильно превышает max_size.
Healthcheck — не бесплатный
CONN_HEALTH_CHECKS = True добавляет один round-trip при каждом извлечении соединения из пула. На локальной базе это десятые доли миллисекунды, через сеть — 1–3 мс. На 500 rps это 0,5–1,5 секунды в секунду. Если база рядом и стабильна, можно отключить healthcheck и полагаться на max_lifetime для ротации.
Как мерить эффект
Голословно утверждать, что пул ускорит приложение, — некорректно. Эффект зависит от соотношения времени запроса к базе и времени установки соединения. Вот что измерять:
- Время установки соединения —
pg_stat_activityпокажет время подключения; или замерьтеpsycopg.connect()вручную черезtime.perf_counter(). - Число активных соединений —
SELECT count(*) FROM pg_stat_activity WHERE state = 'active'. С пулом пиковое число должно упасть доmax_size × число воркеров. - Latency на уровне приложения — медианное и p99 время ответа до и после включения пула. Разница будет заметна на коротких запросах (< 10 мс), где накладные расходы на connect были сопоставимы с самим запросом.
- Ошибки PoolTimeout — мониторьте исключения. Если появляются — увеличивайте
max_sizeили оптимизируйте время удержания соединений.
Что делать дальше
Если вы на Django 5.1+ с PostgreSQL и psycopg 3 — включите пул, задайте min_size и max_size исходя из числа воркеров, и замерьте latency до и после. Если вы на более ранней версии Django или используете MySQL — встроенного пула нет; рассмотрите внешний пулер (PgBouncer, ProxySQL) или пакет django-db-connection-pool, но сверьте его совместимость с вашей версией Django. Отдельная тема — пулинг в асинхронном ORM, который Django развивает параллельно; за его состоянием стоит следить в release notes.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Django 5.1 Release Notes — Connection Pooling, psycopg_pool — Connection Pool API documentation
Частые вопросы
Пул соединений работает только с PostgreSQL?
Можно ли использовать пул Django одновременно с PgBouncer?
Что будет, если psycopg_pool не установлен, а в settings указан ключ pool?
Что произойдёт, если пул исчерпан и все соединения заняты?
У меня Django 4.2 LTS — как получить пул?
Как подобрать max_size для продакшена?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.