Когда приложение открывает соединение с PostgreSQL, база не просто принимает TCP-пакет — она запускает отдельный процесс операционной системы. Каждый такой процесс занимает около 10 МБ памяти и требует переключения контекста при каждом запросе. Если открыть 200 соединений, база создаст 200 процессов — это заметная нагрузка даже на мощном сервере.
Пул соединений решает эту проблему: он держит фиксированное число открытых соединений и раздаёт их запросам по мере надобности.
Почему «больше соединений» не значит «быстрее»
Интуитивно кажется: больше соединений — больше параллельной работы — выше пропускная способность. На практике это не так.
PostgreSQL обрабатывает запросы параллельно, но узкое место — не количество соединений, а количество ядер процессора. Переключение между сотнями процессов стоит дорого, а конкуренция за блокировки растёт квадратично с числом одновременных работников.
Исследователь Брент Вулдридж сформулировал формулу оптимального размера пула:
connections = (число ядер × 2) + число дисковых устройств
Для современного сервера с SSD (одно дисковое устройство) и, например, 4 ядрами:
connections = (4 × 2) + 1 = 9
На практике рабочий диапазон — 10–20 соединений на инстанс приложения. Пул из 20 соединений на 8-ядерном сервере даёт больший итоговый пропуск, чем пул из 100.
Бюджет max_connections
PostgreSQL имеет параметр max_connections (по умолчанию 100) — это общий лимит на все соединения со всеми базами. Если у вас 10 инстансов приложения по 20 соединений, итого 200 — уже выше дефолта. Нужно либо увеличить max_connections до 300–500, либо поставить PgBouncer.
Ключевые параметры пула
Четыре параметра важны вне зависимости от языка и библиотеки.
max = min-idle (держать пул всегда полным)
Если установить минимум 5 и максимум 20, пул будет подниматься с 5 до 20 в момент всплеска нагрузки. Эти несколько секунд «разогрева» добавляют задержку как раз тогда, когда нагрузка уже высокая. Проще: держать постоянное число соединений равным максимуму.
connection-timeout: 3s (лучше упасть быстро)
Если все соединения заняты, запрос встаёт в очередь. 3 секунды ожидания — хороший порог: если пул не смог выдать соединение за это время, что-то идёт не так. Быстрый отказ лучше, чем тихое зависание.
max-lifetime: 30 мин (обновлять соединения)
Соединение со временем накапливает состояние на стороне сервера: кешированные запросы, изменённые параметры сессии. Через 30 минут пул закроет соединение и откроет новое. Это значение должно быть меньше таймаутов в балансировщике нагрузки — иначе пул будет держать соединения, которые балансировщик уже считает мёртвыми.
leak-detection-threshold: 60s (обнаруживать утечки)
Если соединение не вернулось в пул за минуту, драйвер запишет в лог трассировку стека. Это признак одной из трёх вещей: забыли закрыть соединение, внутри транзакции вызывается медленный HTTP-запрос, транзакция зависла. Не отключайте этот параметр — это бесплатный мониторинг.
Конфигурация по языку
Spring Boot (application.yml):
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
auto-commit: false
Или явно через Java:
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(20);
config.setMinimumIdle(20);
config.setConnectionTimeout(3_000);
config.setMaxLifetime(1_800_000);
config.setLeakDetectionThreshold(60_000);
config.setAutoCommit(false);
DataSource ds = new HikariDataSource(config);
cfg, _ := pgxpool.ParseConfig("postgres://app:secret@localhost:5432/mydb")
cfg.MaxConns = 20
cfg.MinConns = 20
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 10 * time.Minute
cfg.HealthCheckPeriod = 30 * time.Second
// connection-timeout задаётся на уровне контекста запроса:
// ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
pool, _ := pgxpool.NewWithConfig(context.Background(), cfg)
import { Pool } from "pg";
const pool = new Pool({
max: 20,
min: 20,
idleTimeoutMillis: 600_000,
connectionTimeoutMillis: 3_000,
});
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
conninfo="host=localhost port=5432 dbname=mydb user=app password=secret",
min_size=20,
max_size=20,
timeout=3.0,
max_lifetime=1800.0,
max_idle=600.0,
open=True,
)
Мониторинг пула
Пул публикует метрики, которые стоит отслеживать:
- active — сколько соединений занято прямо сейчас.
- idle — сколько свободно.
- pending — сколько потоков ждут соединения. Если это число стабильно больше нуля — пул слишком мал или транзакции слишком долгие.
- timeout — счётчик случаев, когда соединение не выдалось за отведённое время.
Настройте оповещение: если pending > 0 держится минуту — нужно разобраться, в чём причина. Если timeout > 0 — это либо утечка соединений, либо нехватка пула.
HikariCP экспортирует метрики через Micrometer (Spring Actuator). В Go используют pgxpool.Stat(), в Node — pool.totalCount / pool.idleCount / pool.waitingCount, в Python — pool.get_stats().
Когда нужен PgBouncer
Пул на уровне приложения хорошо работает для одного сервиса с несколькими инстансами. Но если у вас:
- десятки инстансов одного сервиса,
- много разных сервисов на одном кластере PostgreSQL,
- функции без постоянного состояния (serverless, короткоживущие процессы),
— суммарное число соединений от всех инстансов начинает давить на max_connections. Здесь помогает PgBouncer: он стоит между приложением и PostgreSQL и мультиплексирует тысячи клиентских соединений в десятки реальных.
Режимы работы PgBouncer
| Режим | Когда соединение возвращается в пул | Ограничения |
|---|---|---|
session | После отключения клиента | Нет |
transaction | После каждой транзакции | Нет SET без LOCAL, нет LISTEN/NOTIFY, проблемы с server-side prepared statements |
statement | После каждого SQL-запроса | Нет транзакций из нескольких запросов |
transaction — обычный выбор. Он даёт максимальную утилизацию при минимуме ограничений.
Prepared statements и transaction mode
В режиме transaction сервер PostgreSQL не сохраняет prepared statements между транзакциями — соединение после каждой транзакции уходит другому клиенту. Нужно отключить server-side prepared statements на уровне драйвера.
spring:
datasource:
hikari:
data-source-properties:
prepareThreshold: 0
cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
// node-postgres по умолчанию не использует server-side prepared statements
// при вызове pool.query() — дополнительных действий не нужно.
pool = ConnectionPool(
conninfo="...",
kwargs={"prepare_threshold": 0},
)
Альтернатива — PgBouncer версии 1.21 и выше, который умеет кешировать prepared statements сам.
Также: команды SET без SET LOCAL теряются после COMMIT. Для LISTEN/NOTIFY нужен отдельный пул в режиме session или другой механизм (очередь сообщений).
Пример конфигурации PgBouncer
[databases]
mydb = host=postgres-master port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
default_pool_size = 20
max_client_conn = 1000
reserve_pool_size = 5
Типичное соотношение: приложение держит пул 50 соединений к PgBouncer, PgBouncer держит 20 реальных соединений к PostgreSQL. Пятьдесят потоков приложения могут «думать» параллельно, к базе идёт максимум 20.
Read-реплика
Если у вас есть реплика для чтения — для неё нужен отдельный пул. Не стоит направлять на реплику запросы, которые только что записали данные на мастер: репликация асинхронна, обычно это миллисекунды, но под нагрузкой может быть и несколько секунд.
В Spring используют AbstractRoutingDataSource: транзакция с readOnly = true автоматически направляется на реплику.
@Configuration
public class DataSourceConfig {
@Bean @Primary
public DataSource routingDataSource(DataSource master, DataSource replica) {
var routing = new TransactionRoutingDataSource();
routing.setTargetDataSources(Map.of(
DataSourceType.READ_WRITE, master,
DataSourceType.READ_ONLY, replica
));
routing.setDefaultTargetDataSource(master);
return routing;
}
}
type DB struct {
master *pgxpool.Pool
replica *pgxpool.Pool
}
func (db *DB) Pool(readOnly bool) *pgxpool.Pool {
if readOnly {
return db.replica
}
return db.master
}
const master = new Pool({ host: "pg-master", ...config });
const replica = new Pool({ host: "pg-replica", ...config });
export function getPool(readOnly: boolean): Pool {
return readOnly ? replica : master;
}
master = ConnectionPool(conninfo="host=pg-master ...", min_size=20, max_size=20)
replica = ConnectionPool(conninfo="host=pg-replica ...", min_size=20, max_size=20)
def get_pool(read_only: bool) -> ConnectionPool:
return replica if read_only else master
Частые ошибки
Слишком большой пул. Пул из 200 соединений на 4-ядерном сервере работает хуже пула из 20 — из-за накладных расходов на переключение контекста. Ориентир: 10–20 соединений на инстанс.
Несколько пулов на одну базу. Если разные части одного приложения создают свои пулы к одной базе — суммарное число соединений умножается. Один пул на приложение.
Долгая операция внутри транзакции. HTTP-запрос к внешнему сервису, обработка файла, долгий цикл — всё это внутри открытой транзакции удерживает соединение. Тяжёлые операции выносят за пределы транзакции.
Read-реплика для сценария «только что записал — сразу читаю». Из-за задержки репликации новая запись может не появиться на реплике немедленно. Для таких сценариев — только мастер.
Коротко
- PostgreSQL запускает процесс ОС на каждое соединение (~10 МБ + накладные расходы переключения контекста).
- Оптимальный размер пула по формуле Wooldridge:
(ядра × 2) + устройства. Для большинства сервисов это 10–20 соединений. - Держите
max = min-idle: пул всегда полный, без разогрева на пиках. connection-timeout: 3s— быстрый отказ лучше тихого зависания.max-lifetime: 30 мин— обновляйте соединения; значение меньше таймаута балансировщика.leak-detection-threshold: 60s— не отключайте, это бесплатный мониторинг утечек.- PgBouncer нужен при десятках инстансов, многих сервисах или serverless-воркерах.
- В режиме
transactionотключите server-side prepared statements на уровне драйвера. - Read-реплика — отдельный пул; не использовать для сценариев «записал → сразу читаю».
Что почитать дальше
- Транзакции в PostgreSQL — как длинные транзакции удваивают нагрузку на пул.
- Уровни изоляции —
readOnlyи маршрутизация на реплику. - Блокировки —
lock_timeoutи долгие транзакции.