Graceful shutdown в Node.js: корректное завершение при deploy и в Docker
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_connectionsidle: стабильное число, равное сумме размеров пулов всех инстансов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 — не серебряная пуля, но правильная настройка даёт кратный прирост производительности и стабильности под нагрузкой. Главное — измерять метрики и не гадать на кофейной гуще.