Приложение на Node.js спокойно работает в обычном режиме, а потом приходит наплыв трафика, и запросы начинают зависать. Со стороны выглядит так, будто захлёбывается база, хотя сама база при этом чувствует себя отлично. На деле виноват пул соединений, который исчерпал свободные подключения и поставил все входящие запросы в очередь. Самое обидное, что значения по умолчанию у популярного драйвера выглядят разумно, но в продакшене ведут себя совсем не так, как ожидаешь. Разберём, почему дефолты обманчивы, как их правильно настроить и как поймать утечку, которая тихо съедает ёмкость пула.

Зачем вообще нужен пул и что он экономит

Без пула каждый запрос открывает новое соединение, проходит рукопожатие по сети, аутентифицируется, выполняет запрос и закрывается. Это пустые накладные расходы ещё до того, как сам запрос начнёт работать. Складывается стоимость из нескольких частей: трёхэтапное рукопожатие по сети, шифрованное согласование при защищённом подключении, серверная аутентификация. В сумме набегает порядка двадцати-пятидесяти миллисекунд на соединение, а в пределах одного дата-центра меньше, через регионы заметно больше.

Под нагрузкой в тысячу запросов в секунду создание нового соединения на каждый запрос добавляет десятки секунд совокупной задержки на каждую секунду трафика. Пул решает это, держа набор заранее установленных и готовых к работе соединений. Но пул это не настройка из разряда поставил и забыл. Неверно сконфигурированный пул вызывает тихое истощение, каскадные таймауты и очереди запросов, которые выглядят ровно как проблема с базой, хотя сама база в порядке.

Почему серверный лимит соединений это жёсткая стена

Важно понимать ограничение на стороне PostgreSQL. База форкает отдельный процесс операционной системы на каждое соединение, и это отличает её от баз с потоковой моделью. Каждое соединение держит память на сервере, порядка пяти-десяти мегабайт рабочей памяти. Жёсткий глобальный предел числа соединений по умолчанию равен сотне.

PostgreSQL не рассчитан на тысячи одновременных соединений. Он спроектирован под десятки активных запросов с эффективным вводом-выводом и в этом великолепен. Инстинкт при нехватке соединений поднять предел работает ровно до момента, когда перестаёт работать: больше соединений означает больше давления на память, больше переключений контекста и больше борьбы за блокировки. Правильное решение это не открывать соединения, которые не нужны.

Какие дефолты драйвера обманчивы и почему

Теперь к самой сути. У популярного драйвера несколько ключевых параметров пула, и значения по умолчанию у части из них опасны в продакшене.

import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.PGHOST,
  database: process.env.PGDATABASE,
  user: process.env.PGUSER,
  password: process.env.PGPASSWORD,
  max: 20,                         // максимум соединений в пуле
  min: 2,                          // минимум, чтобы избежать холодного старта
  idleTimeoutMillis: 30_000,       // закрывать простаивающее соединение через 30с
  connectionTimeoutMillis: 5_000,  // быстро падать, если пул исчерпан
  maxUses: 7500,                   // переоткрывать соединение каждые 7500 запросов
  statement_timeout: 10_000,       // обрывать зависшие запросы
});

pool.on('error', (err) => {
  console.error('Ошибка простаивающего клиента', err);
});

Главная ловушка это таймаут ожидания соединения. По умолчанию он равен нулю, что означает ждать вечно. В продакшене это смертельно: когда пул исчерпан, запрос зависает бесконечно вместо того, чтобы быстро упасть и дать клиенту повторить. Значение в несколько секунд превращает зависший навсегда запрос в честную быструю ошибку.

Вторая обманчивая настройка это таймаут простоя. По умолчанию он равен десяти секундам, и это довольно агрессивно: соединения закрываются слишком рано, и при всплесках трафика их приходится открывать заново. Для большинства приложений разумнее тридцать секунд, а при рваной нагрузке и больше. Тут есть отдельный нюанс: установка таймаута простоя в ноль не отключает закрытие, а в ряде конфигураций приводит к тому, что пулинга нет вовсе, каждый запрос получает свежесозданное соединение, которое тут же выбрасывается. Ноль здесь это не разумный выбор.

Третья полезная настройка это предел числа использований соединения, появившийся в восьмой версии драйвера. Серверные процессы PostgreSQL могут медленно подтекать по памяти за тысячи запросов, и переоткрытие соединения после заданного числа запросов держит память стабильной. Значение порядка пяти-десяти тысяч хорошо подходит для долгоживущих серверных процессов.

Как правильно посчитать размер пула

Вопрос на засыпку из практики: пул на двадцать соединений, на что именно двадцать? Размер пула нельзя брать с потолка. Базовое правило отталкивается от ядер сервера базы: для машины на четыре ядра с твердотельными накопителями выходит около девяти соединений, для шестнадцати ядер около тридцати трёх. Меньше, чем многие ожидают, и это нормально, потому что PostgreSQL быстрее обслуживает небольшое число активных запросов, чем толпу конкурирующих.

Но это лишь половина расчёта, потому что серверный лимит делится между всеми сервисами и всеми экземплярами приложения. Реалистичное правило учитывает число экземпляров и запас на миграции и админские инструменты.

// floor(серверный_лимит * 0.8 / число_экземпляров_сервиса)
// при лимите 100, трёх экземплярах и запасе 20%:
// floor(100 * 0.8 / 3) = 26
const poolMax = Math.floor(100 * 0.8 / 3);  // 26

Здесь кроется коварство масштабирования. Если платформа агрессивно плодит экземпляры, размер пула надо считать от пикового, а не среднего числа экземпляров. Десять подов по двадцать соединений это уже двести соединений к базе, и серверный лимit в сотню превышается мгновенно с ошибкой о слишком большом числе соединений.

Почему утечка соединения хуже утечки памяти

Самая частая причина истощения пула это не нагрузка, а утечка соединений, когда код забывает вернуть взятое соединение обратно. Критическое правило: всегда возвращать соединение в блоке гарантированного выполнения. Утёкшее соединение навсегда вычёркивается из пула, и накопив их достаточно, вы получаете исчерпанный пул при простаивающей базе.

export async function getUser(id) {
  const client = await pool.connect();
  try {
    const { rows } = await client.query(
      'SELECT id, name FROM users WHERE id = $1', [id]
    );
    return rows[0] ?? null;
  } finally {
    client.release();   // возврат строго в finally, даже при ошибке
  }
}

Если забыть возврат, пул не утекает по памяти, он утекает по ёмкости, и это хуже. Отдельное правило: соединение, взятое из пула, принадлежит одному запросу в один момент времени, его нельзя делить между параллельными асинхронными операциями. Для одиночного запроса удобнее метод самого пула, который сам берёт свободного клиента и сам его возвращает, избавляя от ручного управления.

Как наблюдать за пулом и поймать проблему заранее

Пул умеет рассказывать о своём состоянии, и за этими числами стоит присматривать как за метриками первого класса наравне с процессором и памятью. Три показателя говорят почти обо всём: сколько соединений всего, сколько простаивает и сколько запросов ждёт в очереди.

setInterval(() => {
  console.log('Пул:', {
    total:   pool.totalCount,
    idle:    pool.idleCount,
    waiting: pool.waitingCount,
  });
}, 10_000);

Если число ожидающих растёт, а простаивающих стабильно держится на нуле, ищите запросы, которые падают с ошибкой без возврата соединения, или незакрытые транзакции. Очередь ожидания, которая устойчиво выше нуля, это пожар, который ещё не начал гореть. Помимо этого критично всегда вешать обработчик на событие ошибки пула: необработанная ошибка простаивающего клиента способна уронить весь процесс.

Почему в бессерверной среде и кластере пул ведёт себя иначе

Отдельная боль это бессерверные среды и горизонтальное масштабирование. Бессерверные платформы создают новое соединение при холодном старте каждого вызова функции, масштабирование по конкурентности порождает сотни экземпляров функций, каждый со своим пулом, а замороженные контейнеры держат соединения открытыми и простаивающими, занимая слоты базы. Всплеск трафика создаёт множество тёплых экземпляров, и база видит наводнение соединений. Поэтому пул на десять соединений в такой среде способен утопить PostgreSQL за минуты.

Правило для бессерверной среды простое: никогда не давать функциям подключаться к PostgreSQL напрямую. Между ними ставят внешний пулер вроде PgBouncer или встроенные пулеры облачных баз. То же касается кластера из множества процессов и подов: когда каждый держит свой пул, серверный лимит выбирается стремительно.

Где выручает внешний пулер и как он меняет расчёты

PgBouncer садится между приложением и базой и мультиплексирует множество клиентских подключений на небольшой фиксированный набор реальных серверных соединений. Сто клиентских подключений превращаются в двадцать реальных, и приложение этого не замечает. Решающий момент в режиме пулинга: транзакционный режим освобождает соединение после каждой транзакции, а не держит его на всю сессию, и именно он критичен для бессерверной среды и автомасштабирования.

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
server_idle_timeout = 600

Когда внешний пулер впереди, можно опустить серверный лимит базы до реалистичного значения, посчитанного по формуле от размера пула пулера плюс несколько админских соединений. А освободившуюся память отдать под общие буферы и рабочую память, что напрямую улучшает скорость запросов. С пулером перед базой пул на стороне Node.js можно делать смелее, ведь реальные соединения держит пулер, а приложение видит больший виртуальный пул.

Есть и тонкость согласования таймаутов. Если таймаут простоя у пулера или балансировщика ниже, чем у пула приложения, возникают ошибки неожиданного разрыва соединения. Поэтому таймаут простоя пула выставляют чуть ниже, чем у инфраструктуры впереди него.

Какой стратегии держаться

Сводя воедино, истощение пула почти всегда сводится к одной из трёх причин: дефолты, которые обманывают, неверный размер или утечка соединений. Несколько правил закрывают почти весь риск. Всегда выставлять таймаут ожидания соединения, чтобы запрос падал быстро, а не висел вечно. Считать размер пула от ядер базы и числа экземпляров приложения, отталкиваясь от пикового числа, а не среднего. Всегда возвращать соединение в блоке гарантированного выполнения. Вешать обработчик на событие ошибки пула. Наблюдать за числом ожидающих, простаивающих и всех соединений как за боевыми метриками. И при множестве экземпляров или бессерверной среде ставить внешний пулер.

Дефолтные настройки драйвера это разумная отправная точка для локальной разработки, но не для боя. Тот, кто оставляет таймаут ожидания нулевым и размер пула с потолка, рано или поздно встречает зависшие запросы в пик и долго винит в этом базу. А тот, кто посчитал размер от железа, закрыл утечки и поставил пулер перед базой, получает приложение, которое держит удар нагрузки и остаётся отзывчивым ровно тогда, когда это важнее всего.