Под нагрузкой приложение вдруг начинает падать с ошибкой FATAL: sorry, too many clients already. База жива, запросы лёгкие, а соединения кончились. Это упор в число подключений PostgreSQL — типичная стена, в которую проект въезжает при росте трафика или числа инстансов. Разберём, почему соединения в Postgres дорогие и как помогает пул.
Почему соединения дорогие
PostgreSQL на каждое клиентское соединение запускает отдельный процесс операционной системы (не поток). Каждый такой процесс занимает память, а их общее число ограничено параметром max_connections — по умолчанию около 100. Установка соединения тоже не бесплатна: форк процесса, аутентификация, инициализация.
Пока соединений десятки — это незаметно. Проблема появляется, когда их начинают открывать много и одновременно.
Откуда берётся «too many clients»
Число реальных соединений складывается перемножением. Допустим, у приложения свой пул на 20 соединений. Запущено 4 веб-воркера gunicorn — уже 80. Добавили второй инстанс приложения — 160. Плюс фоновые воркеры со своими подключениями. И всё это упирается в max_connections = 100.
Особенно остро это в serverless и при автоскейлинге: каждый новый экземпляр открывает свои соединения, и их число растёт неконтролируемо. Просто поднять max_connections — плохое решение: каждое соединение ест память, и сотни процессов сами по себе замедляют сервер.
Что делает пул соединений
Пул соединений — посредник между приложением и базой. Он держит небольшой постоянный набор реальных соединений к PostgreSQL и переиспользует их, раздавая приложению по запросу. Приложение «думает», что у него много соединений, а к базе их идёт мало и они не пересоздаются на каждый запрос.
Самый распространённый внешний пул для PostgreSQL — PgBouncer. Он лёгкий, ставится перед базой, и приложение подключается к нему вместо неё.
Режимы пулинга PgBouncer
У PgBouncer три режима, и разница между ними принципиальна:
- session — соединение к базе закрепляется за клиентом на всё время его сессии. Безопасно, совместимо со всем, но экономия минимальна.
- transaction — соединение выдаётся на время одной транзакции и возвращается в пул сразу после неё. Самый эффективный режим: десятки клиентов делят единицы соединений.
- statement — соединение возвращается после каждого запроса. Самый агрессивный, с жёсткими ограничениями.
На практике берут transaction pooling — но у него есть цена.
Подводный камень transaction pooling
В режиме transaction соединение между транзакциями переходит к другому клиенту, поэтому всё, что живёт на соединении, ломается:
- prepared statements — драйверы вроде
asyncpgиpsycopgготовят запросы на соединении; в transaction-режиме это даёт ошибки. Обычно лечится отключением кэша prepared statements в драйвере. SETна сессию, advisory locks,LISTEN/NOTIFY, временные таблицы — всё это привязано к соединению и в transaction-режиме работать не будет.
Поэтому переход на transaction pooling — не «просто включить», а проверить, что приложение не полагается на состояние соединения.
Пул в приложении — это не то же самое
У ORM и драйверов есть свой пул (например, в SQLAlchemy). Он живёт внутри одного процесса приложения и переиспользует соединения только этого процесса. Когда инстансов и воркеров много, их пулы суммируются и упираются в max_connections — здесь и нужен общий внешний пул вроде PgBouncer перед базой. Один не отменяет другой: внутренний пул экономит на пересоздании соединений в процессе, внешний — ограничивает их общее число к базе.
Как подключить на практике
PgBouncer описывается коротким конфигом: какие базы проксировать, режим пула и его размеры.
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
Приложение может открыть до max_client_conn соединений к самому PgBouncer, а тот держит к базе не больше default_pool_size на каждую пару «база — пользователь». В приложении при этом меняется только строка подключения: вместо порта PostgreSQL (5432) указывается порт PgBouncer (по умолчанию 6432). Код трогать не нужно.
Частые грабли
- Подняли
max_connectionsвместо пула. Сотни процессов съедают память и замедляют сервер — проблему отложили, а не решили. - transaction pooling без проверки. Prepared statements и
SET-настройки начинают давать ошибки. - Долгие транзакции. Транзакция, висящая в ожидании, держит соединение из пула и не отдаёт его — пул быстро исчерпывается.
- Слишком большой пул. Если размер пула близок к
max_connections, смысл пулинга теряется.
Когда это нужно
- Несколько инстансов или много воркеров приложения суммарно открывают больше соединений, чем тянет база.
- Появляются ошибки
too many clientsпод нагрузкой. - Serverless или автоскейлинг с непредсказуемым числом экземпляров.
Для одного небольшого приложения с внутренним пулом внешний пул избыточен — он нужен, когда соединений к базе становится действительно много.
Грамотная работа с соединениями — часть эксплуатации базы наравне с бэкапами и переносом без простоя. На Hostim managed Postgres поднимается с разумными настройками по умолчанию, а пул соединений подключается перед базой без отдельной установки и сопровождения PgBouncer — приложение получает стабильное число соединений независимо от того, сколько у него инстансов и воркеров.