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

Симптом: FATAL: sorry, too many clients already
Приложение на Django 5.x под Gunicorn с 8 воркерами. При росте трафика до нескольких сотен одновременных запросов PostgreSQL начинает отдавать FATAL: sorry, too many clients already, а часть запросов падает с OperationalError. При этом CPU и на бэкенде, и на базе далеки от потолка. Проблема не в вычислениях — в том, как Django обращается с соединениями.
Разбираем именно этот угол: почему Django не даёт пула из коробки, какой лимит вы упираетесь первым и чем это закрывать в 2024–2025 году. Тюнинг медленных запросов и индексов — отдельная тема, здесь про соединения.
Как Django работает с соединениями на самом деле
Модель простая и в этом её проблема под нагрузкой. Django открывает соединение к базе при первом обращении в рамках запроса и по умолчанию закрывает его в конце обработки. Настройка CONN_MAX_AGE в блоке DATABASES меняет поведение: соединение переиспользуется внутри одного воркера в течение указанного числа секунд.
Ключевое слово — внутри одного воркера. Django-соединение живёт в threading.local: оно привязано к процессу или потоку, который его открыл. Общего пула, который раздаёт соединения между воркерами, в самом Django нет. Никогда не было и по дизайну не предполагается.
Persistent connections в Django — это не пул. Это «каждый воркер держит своё соединение открытым». Число живых соединений к базе равно числу воркеров, помноженному на число потоков в каждом, а не регулируется отдельным лимитом.
Почему из коробки нет пула
Django исторически проектировался вокруг модели «процесс на запрос»: WSGI-сервер поднимает N процессов, каждый обрабатывает запросы последовательно. Пул внутри процесса при такой модели почти бесполезен — процесс всё равно делает один запрос за раз. А пул между процессами Python-процессы разделить не могут: у них раздельная память.
Пока приложение синхронное и воркеров десятки — это работает. Ломается на трёх вещах: рост числа воркеров, переход на async и появление внешнего балансировщика типа PgBouncer, с которым CONN_MAX_AGE конфликтует.
Sub-note
max_connections — параметр PostgreSQL, задающий максимальное число одновременных клиентских соединений. По умолчанию около 100. Каждое соединение в PostgreSQL — это отдельный процесс ОС со своей памятью, поэтому поднять лимит до тысяч нельзя без деградации самой базы.
Считаем, где рвётся первым
Возьмём типичную раскладку: Gunicorn с sync-воркерами. Число одновременных соединений к базе в пике равно числу воркеров — каждый занят одним запросом и держит одно соединение. С gthread-воркерами умножайте на число потоков.
| Конфигурация | Воркеров × потоков | Соединений к БД в пике | Упрётся в max_connections=100? |
|---|---|---|---|
| Gunicorn sync | 16 × 1 | 16 | Нет |
| Gunicorn gthread | 8 × 4 | 32 | Нет |
| Несколько инстансов sync | 4 × 16 × 1 | 64 | Близко |
| Gunicorn gevent / async | 4 × 1000 корутин | сотни–тысячи | Да, мгновенно |
Вывод из таблицы: пока вы на sync-воркерах и их не сотни — упираетесь в базу не сразу. Настоящий обрыв наступает при переходе на асинхронные воркеры (gevent, eventlet) или на Django async views. Каждая корутина, дошедшая до запроса в БД, требует своё соединение, а корутин на воркере могут быть тысячи. Тут max_connections заканчивается за секунды.
Три способа закрыть проблему
1. CONN_MAX_AGE — persistent connections
Самое простое и встроенное. Убирает оверхед на открытие соединения в каждом запросе, но не ограничивает их число.
# settings.py, Django 5.x
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "appdb",
"USER": "appuser",
"PASSWORD": "YOUR_DB_PASSWORD",
"HOST": "127.0.0.1",
"PORT": "5432",
# держать соединение открытым 60 секунд внутри воркера
"CONN_MAX_AGE": 60,
# проверять живость соединения перед использованием (Django 4.1+)
"CONN_HEALTH_CHECKS": True,
}
}Что выигрываете: меньше задержка, база не тратит ресурсы на постоянные хендшейки. Чем платите: число открытых соединений равно числу воркеров и не падает в простое. Если воркеров много, вы держите базу занятой соединениями, которые ничего не делают.
2. Встроенный пул psycopg 3 (Django 5.1+)
Начиная с Django 5.1 бэкенд django.db.backends.postgresql поддерживает пул соединений на уровне драйвера psycopg 3 через ключ "pool" в OPTIONS. Это пул внутри процесса — полезен для async, где на один воркер приходится много корутин.
# settings.py, Django 5.1+, требуется psycopg[pool]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "appdb",
"USER": "appuser",
"PASSWORD": "YOUR_DB_PASSWORD",
"HOST": "127.0.0.1",
"PORT": "5432",
"OPTIONS": {
# True — параметры пула по умолчанию,
# или словарь с настройками psycopg_pool
"pool": {
"min_size": 2,
"max_size": 8,
},
},
}
}Важное ограничение: pool и CONN_MAX_AGE взаимоисключающи — при использовании пула CONN_MAX_AGE должно оставаться значением по умолчанию (0). Точные допустимые ключи внутри pool сверяйте с документацией psycopg_pool вашей версии, они соответствуют аргументам ConnectionPool.
3. PgBouncer — внешний пул перед базой
Отдельный процесс, стоящий между приложением и PostgreSQL. Все воркеры и инстансы подключаются к PgBouncer, а он держит ограниченный пул реальных соединений к базе. Это единственный способ, который работает поверх нескольких процессов и машин.
С PgBouncer в режиме transaction критично отключить persistent connections на стороне Django — иначе Django будет держать соединение, а PgBouncer в режиме транзакций рассчитывает раздавать его между клиентами между транзакциями.
- режим
transaction— соединение возвращается в пул после каждой транзакции; даёт лучшую утилизацию, но ломает то, что живёт дольше транзакции (серверные prepared statements,SETна сессию, advisory-локи по сессии); - с этим режимом ставьте
CONN_MAX_AGE = 0и отключайте серверные prepared statements psycopg через соответствующую опцию драйвера; - режим
session— соединение занято клиентом на всю сессию; совместим почти со всем, но экономит меньше.
Когда что выбирать
- Sync-приложение, воркеров до нескольких десятков, один инстанс: хватит
CONN_MAX_AGE. Пул не нужен. - Async views или gevent/eventlet-воркеры: без пула на уровне процесса вы мгновенно исчерпаете
max_connections. Django 5.1+ — встроенныйpool; более старые версии — PgBouncer. - Много инстансов / горизонтальное масштабирование: только PgBouncer, потому что суммарное число соединений от всех подов надо ограничивать в одной точке.
Что ломается первым и как это увидеть
Раньше всего рвётся не приложение, а база: клиент упирается в max_connections, новые соединения отбиваются, запросы падают с OperationalError. Смотреть надо не на CPU, а на счётчик активных соединений.
Проверить текущее число соединений и их состояние:
-- сколько соединений открыто и в каком состоянии
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = 'appdb'
GROUP BY state;
-- текущий лимит
SHOW max_connections;Граничные случаи, о которых забывают:
- Idle in transaction. Соединение держит открытую транзакцию и не отпускает — при высоком
CONN_MAX_AGEили неаккуратном коде такие соединения копятся и съедают лимит, ничего не делая. Ищите строки соstate = 'idle in transaction'. - Долгие запросы под пулом. Пул с малым
max_sizeи одним медленным запросом на соединение приводит к тому, что остальные корутины ждут очереди в пуле — задержка растёт, хотя база свободна. - PgBouncer transaction mode + advisory locks по сессии. Лок, взятый в одной транзакции, может «переехать» на другое соединение и не сняться так, как вы ждёте. Используйте транзакционные локи или режим session.
Что делать дальше
Начните с замера: снимите число активных соединений в пике реального трафика через pg_stat_activity и сравните с max_connections. Если запаса нет — определите по таблице выше, в какой сценарий вы попадаете, и выбирайте инструмент по разделу «когда что выбирать». Держите под наблюдением два показателя в проде: число соединений в состоянии active и число idle in transaction — второй растёт при проблемах в коде, а не в конфиге. Перед включением пула или PgBouncer сверьте совместимость режима с тем, что ваше приложение делает с сессией: prepared statements, SET-переменные и advisory-локи.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django documentation — Databases: Persistent connections и Connection pool, psycopg_pool documentation — Connection pools
Частые вопросы
У меня Django 5.0, встроенного пула нет — что делать?
CONN_MAX_AGE — это и есть пул соединений?
Включил pool, но получаю ошибку про CONN_MAX_AGE — почему?
Грозит ли PgBouncer в transaction mode проблемами с моим кодом?
Как понять, что я упёрся именно в лимит соединений, а не в медленную базу?
Сколько соединений безопасно держать к PostgreSQL?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.