lenec ru

← все посты

Graceful shutdown в Node.js: корректное завершение при deploy и в Docker

13K

Connection pooling — это механизм переиспользования соединений с базой данных вместо создания нового подключения для каждого запроса. В Node.js-приложениях, работающих с PostgreSQL, правильная настройка пула соединений критична для производительности: создание TCP-соединения, SSL-handshake и аутентификация занимают 20–50 мс, что при высокой нагрузке превращается в узкое горлышко.

Зачем нужен connection pool

Без пула каждый HTTP-запрос открывает новое соединение с PostgreSQL, выполняет запрос и закрывает его. При 100 RPS это 100 новых TCP-соединений в секунду — PostgreSQL тратит ресурсы на fork процессов (в режиме process-per-connection), а сетевые накладные расходы съедают до 30% времени ответа.

Connection pool решает проблему:

  • Переиспользование: соединения остаются открытыми и выдаются из пула по требованию
  • Ограничение нагрузки: максимальный размер пула защищает PostgreSQL от перегрузки
  • Latency: устраняется overhead на установку соединения (20–50 мс → <1 мс)

Типичный прирост производительности — 3–5× на read-heavy нагрузке, до 10× на микрозапросах типа SELECT 1.

pg pool vs pgBouncer: архитектурные различия

В экосистеме PostgreSQL два основных подхода к пулингу:

pg pool (встроенный в node-postgres)

Библиотека pg включает встроенный пул на уровне приложения. Каждый процесс Node.js держит собственный набор соединений:

const { Pool } = require('pg');

const pool = new Pool({
  host: 'localhost',
  port: 5432,
  database: 'myapp',
  user: 'api_user',
  password: process.env.DB_PASSWORD,
  max: 20,                    // макс. соединений в пуле
  idleTimeoutMillis: 30000,   // закрыть idle-соединение через 30с
  connectionTimeoutMillis: 2000, // таймаут на получение соединения
});

// Использование
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);

Плюсы: нулевая инфраструктура, prepared statements работают из коробки, простая отладка.
Минусы: при горизонтальном масштабировании (N инстансов приложения × M соединений) легко упереться в лимит max_connections PostgreSQL (обычно 100–200).

pgBouncer (внешний connection pooler)

Standalone-прокси между приложением и PostgreSQL. Поддерживает три режима:

  • Session pooling: соединение закрепляется за клиентом на всю сессию (как pg pool, но централизованно)
  • Transaction pooling: соединение возвращается в пул после COMMIT/ROLLBACK — максимальная эффективность, но ломает prepared statements и session-переменные
  • Statement pooling: соединение возвращается после каждого запроса (редко используется)
# pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5

Приложение подключается к pgBouncer (порт 6432), который мультиплексирует запросы на 25 реальных соединений с PostgreSQL. При 10 инстансах приложения вместо 200 соединений (10 × 20) получаем 25 — PostgreSQL дышит свободно.

Когда использовать pgBouncer: микросервисная архитектура, serverless (AWS Lambda), более 5 инстансов приложения. Когда достаточно pg pool: монолит, 1–3 инстанса, активное использование prepared statements.

Настройка размера пула: формула и реальность

Классическая формула из HikariCP (Java connection pool):

pool_size = ((core_count × 2) + effective_spindle_count)

Для SSD effective_spindle_count = 1. На сервере с 4 ядрами: (4 × 2) + 1 = 9 соединений. Логика: пока один запрос ждёт I/O, другой использует CPU.

Практические корректировки:

  • OLTP (короткие запросы <10 мс): формула работает, начните с 10–15 соединений
  • Аналитика (запросы 100+ мс): увеличьте до 20–30, чтобы компенсировать долгие блокировки
  • Смешанная нагрузка: разделите пулы — отдельный для OLTP (размер 10) и для отчётов (размер 5)

Пример разделения пулов:

const fastPool = new Pool({ max: 10, statement_timeout: 5000 });
const slowPool = new Pool({ max: 5, statement_timeout: 60000 });

// Быстрые запросы
app.get('/api/user/:id', async (req, res) => {
  const result = await fastPool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
  res.json(result.rows[0]);
});

// Тяжёлая аналитика
app.get('/api/reports/sales', async (req, res) => {
  const result = await slowPool.query('SELECT date, SUM(amount) FROM orders GROUP BY date');
  res.json(result.rows);
});

Антипаттерн: ставить max: 100 «на всякий случай». Избыточные соединения увеличивают contention на блокировках PostgreSQL и memory overhead (каждое соединение — 5–10 МБ RAM).

Мониторинг: pg_stat_activity и метрики пула

Мониторинг на стороне PostgreSQL

Запрос для проверки активных соединений:

SELECT 
  state,
  COUNT(*) as connections,
  MAX(EXTRACT(EPOCH FROM (now() - state_change))) as max_duration_sec
FROM pg_stat_activity
WHERE datname = 'myapp'
GROUP BY state;

Здоровые показатели:

  • active: 5–20% от max_connections
  • idle: стабильное число, равное сумме размеров пулов всех инстансов
  • idle in transaction: должно быть 0 (иначе утечка транзакций)
  • max_duration_sec для active: <1 сек для OLTP, <60 сек для аналитики

Метрики pg pool в Node.js

setInterval(() => {
  console.log({
    total: pool.totalCount,      // всего соединений (idle + active)
    idle: pool.idleCount,        // свободные соединения
    waiting: pool.waitingCount   // запросы в очереди
  });
}, 10000);

Алерты:

  • waiting > 0 дольше 5 секунд — пул исчерпан, увеличьте max или оптимизируйте запросы
  • idle = 0 постоянно — пул слишком мал
  • idle = max постоянно — пул избыточен, уменьшите для экономии ресурсов

Мониторинг pgBouncer

-- Подключиться к pgbouncer admin console
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer

SHOW POOLS;
SHOW STATS;

Ключевые метрики: cl_active (клиентские соединения), sv_active (серверные соединения), maxwait (макс. время ожидания в очереди).

Типичные ошибки: pool exhaustion и connection leaks

1. Pool exhaustion (исчерпание пула)

Симптомы: ошибка TimeoutError: Timeout acquiring client from pool, latency растёт до нескольких секунд.

Причины:

  • Медленные запросы блокируют соединения (запрос на 5 сек × 20 RPS = нужно 100 соединений)
  • Забытые транзакции (BEGIN без COMMIT/ROLLBACK)
  • Недостаточный размер пула для пиковой нагрузки

Решение: добавьте statement_timeout и idle_in_transaction_session_timeout в PostgreSQL:

ALTER DATABASE myapp SET statement_timeout = '10s';
ALTER DATABASE myapp SET idle_in_transaction_session_timeout = '60s';

2. Connection leaks (утечки соединений)

Антипаттерн:

const client = await pool.connect();
const result = await client.query('SELECT * FROM users');
// Забыли client.release() — соединение навсегда занято!
return result.rows;

Правильно:

const client = await pool.connect();
try {
  const result = await client.query('SELECT * FROM users');
  return result.rows;
} finally {
  client.release(); // ВСЕГДА в finally
}

Ещё лучше — используйте pool.query() напрямую (автоматический release):

const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);

3. Неправильный pool mode в pgBouncer

Использование transaction mode с prepared statements:

// Это сломается в transaction mode!
await pool.query('PREPARE get_user AS SELECT * FROM users WHERE id = $1');
await pool.query('EXECUTE get_user(123)');

Решение: либо переключите pgBouncer в session mode, либо используйте параметризованные запросы вместо PREPARE.

Чеклист для production

  • Размер пула: начните с формулы (cores × 2) + 1, корректируйте по метрикам
  • Таймауты: connectionTimeoutMillis: 2000, statement_timeout: 10s
  • Мониторинг: алерты на waiting > 0 и idle in transaction > 0
  • Graceful shutdown: await pool.end() перед завершением процесса
  • pgBouncer для >5 инстансов приложения или serverless
  • Разделение пулов для OLTP и аналитики

Connection pooling — не серебряная пуля, но правильная настройка даёт кратный прирост производительности и стабильности под нагрузкой. Главное — измерять метрики и не гадать на кофейной гуще.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.