← назад к разделу

Когда приложение открывает соединение с 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 и долгие транзакции.