Работа с базой из Node.js: как сделать правильно с первого раза
Пул соединений, параметризованные запросы, транзакции и graceful shutdown — четыре вещи, которые новичок обычно узнаёт из инцидента на проде. Разбираем на PostgreSQL и библиотеке pg 8.x.

Приложение на Node.js под нагрузкой начинает отдавать ошибки too many clients already или Connection terminated unexpectedly. Причина почти всегда одна: с базой работают неправильно с самого первого запроса. Не открыли пул, а плодят соединения на каждый HTTP-запрос; склеивают SQL строками и получают инъекцию; забыли откатить транзакцию при ошибке и держат блокировку. Ниже — четыре решения, каждое из которых стоит принять до того, как код уйдёт в прод, а не после первого падения.
Примеры на PostgreSQL 16 и драйвере pg версии 8.x. Идеи переносятся на MySQL (mysql2) с поправкой на синтаксис плейсхолдеров: у pg это $1, $2, у mysql2 — ?.
Одно соединение против пула
Самая частая ошибка новичка — использовать Client там, где нужен Pool. Client — это одно физическое соединение. Если открывать его на каждый запрос, вы платите за TCP-хендшейк и аутентификацию каждый раз, а под параллельной нагрузкой упираетесь в лимит соединений PostgreSQL (по умолчанию max_connections = 100, часть из которых зарезервирована).
Пул держит фиксированное число готовых соединений и выдаёт их по требованию. Создаётся один раз на весь процесс.
// db.js — модуль подключения, pg 8.x
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.PGHOST,
port: Number(process.env.PGPORT) || 5432,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD, // из окружения, не в коде
max: 10, // максимум соединений в пуле
idleTimeoutMillis: 30000, // закрыть простаивающее соединение через 30 с
connectionTimeoutMillis: 5000, // ждать свободное соединение не дольше 5 с
});
// лог ошибок соединений, которые всплывают в фоне
pool.on('error', (err) => {
console.error('Неожиданная ошибка простаивающего клиента', err);
});
module.exports = { pool };Дальше в коде запрос выполняется через pool.query — пул сам берёт свободное соединение и возвращает его обратно:
const { pool } = require('./db');
async function getUserById(id) {
const { rows } = await pool.query(
'SELECT id, email FROM users WHERE id = $1',
[id]
);
return rows[0];
}Проверка, что пул работает: под нагрузкой число соединений к базе не растёт бесконечно. Посмотрите в PostgreSQL: SELECT count(*) FROM pg_stat_activity WHERE datname = current_database(); — оно должно держаться в пределах вашего max.
Как выбрать max
Больше — не значит быстрее. Если max у пула умножить на число процессов Node (например, 4 воркера PM2 × 10 = 40) плюс другие клиенты, сумма не должна превышать max_connections базы. При превышении база начнёт отказывать в новых соединениях. Разумная отправная точка для одного процесса — 10–20, дальше смотрите на реальную конкуренцию запросов.
Пул создаётся один раз на процесс и живёт всё время работы приложения. Если вы видите new Pool() внутри обработчика запроса — это утечка, которая обрушит базу под нагрузкой.Параметризованные запросы вместо конкатенации
Склейка строк — прямой путь к SQL-инъекции. Выглядит так:
// ТАК ДЕЛАТЬ НЕЛЬЗЯ
const email = req.query.email;
const { rows } = await pool.query(
`SELECT * FROM users WHERE email = '${email}'`
);Если в email прилетит ' OR '1'='1, запрос вернёт всю таблицу. Правильный вариант — плейсхолдеры. Драйвер сам экранирует значения и передаёт их отдельно от текста запроса:
const { rows } = await pool.query(
'SELECT * FROM users WHERE email = $1',
[req.query.email]
);Важный нюанс: плейсхолдер подставляет только значение, но не имя таблицы, колонки или ключевое слово вроде ASC/DESC. Если нужно динамически выбирать колонку сортировки, значение из запроса нельзя вставлять в текст напрямую — сверяйте его с белым списком:
const allowed = { name: 'name', created: 'created_at' };
const sortColumn = allowed[req.query.sort] || 'created_at';
// sortColumn теперь гарантированно одна из известных строк
const { rows } = await pool.query(
`SELECT id, name FROM products ORDER BY ${sortColumn} DESC LIMIT $1`,
[50]
);Транзакции: клиент из пула, а не pool.query
Транзакция — это последовательность запросов, которая должна выполниться целиком или не выполниться вовсе. Классический пример — списать деньги у одного и зачислить другому. Здесь pool.query не подходит: каждый вызов может взять разное соединение, а BEGIN и COMMIT должны идти по одному и тому же.
Берите отдельного клиента через pool.connect() и обязательно возвращайте его в пул в finally:
async function transfer(fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId]
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK'); // откат при любой ошибке
throw err;
} finally {
client.release(); // ВСЕГДА вернуть соединение в пул
}
}Забыть client.release() — значит навсегда изъять соединение из пула. Несколько таких утечек, и пул опустеет: новые запросы будут висеть до connectionTimeoutMillis, а потом падать.
Подходы к работе с драйвером: что выбрать
Голый pg — не единственный вариант. Выбор зависит от размера проекта и того, насколько вам нужен контроль над SQL.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Драйвер pg | Полный контроль над SQL, минимум магии, прозрачная производительность | Пишете весь SQL руками, транзакции и маппинг — на вас |
| Query-builder (Knex) | SQL программно, миграции из коробки, защита от инъекций | Ещё один слой абстракции, свои особенности синтаксиса |
| ORM (Prisma, Sequelize) | Типизация, связи, миграции, меньше рутины | Скрытые запросы, проблема N+1, сложнее оптимизировать |
Для небольшого сервиса и людей, которые уверенно пишут SQL, pg — честный выбор без лишних слоёв. Для команды и растущей схемы query-builder или ORM экономят время, но требуют понимать, какой SQL они генерируют.
Graceful shutdown: закрывайте пул при остановке
Когда контейнер получает SIGTERM, процесс убивают. Если пул не закрыт, соединения обрываются на стороне базы жёстко, а незавершённые транзакции откатываются по таймауту. Закройте пул явно перед выходом:
async function shutdown() {
console.log('Останавливаемся, закрываем пул...');
await pool.end(); // дождётся завершения активных запросов
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);Проверка: в логах базы при остановке сервиса нет предупреждений о резко закрытых соединениях, а pool.end() отрабатывает без ошибок.
Что ломается на практике
- Пул на каждый запрос.
new Pool()внутри обработчика вместо модуля-синглтона. Соединения не переиспользуются, база захлёбывается. - Потерянный клиент транзакции. Нет
release()вfinally— пул медленно вычерпывается до нуля. - Long-running транзакция. Внутри
BEGIN...COMMITвисит вызов внешнего API. Соединение и блокировки строк держатся всё время ожидания сети. - Не пойман
pool.on('error'). Соединение падает в фоне, процесс Node крашится с необработанным исключением. - Пароль в коде. Строка подключения с реальным паролем попадает в git. Держите секреты в переменных окружения.
Что настроить дальше
Базовая работа с базой закрыта — переходите к эксплуатационным вопросам. Настройте миграции схемы (Knex или отдельно node-pg-migrate), чтобы структура таблиц была под версионным контролем, а не менялась руками. Добавьте метрику pool.totalCount и pool.idleCount в мониторинг — рост занятых соединений при неизменной нагрузке сигналит об утечке. И включите в PostgreSQL log_min_duration_statement, чтобы ловить медленные запросы до того, как они станут инцидентом. Пулинг на стороне базы (PgBouncer) — отдельная тема, к ней стоит вернуться, когда одного пула на процесс перестанет хватать.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: node-postgres — официальная документация, PostgreSQL 16 — Resource Consumption (max_connections)
Частые вопросы
У меня pg версии 7, а не 8 — код подойдёт?
Почему appeared ошибка too many clients already?
Нужен ли пул, если приложение обрабатывает мало запросов?
Что будет с транзакцией, если процесс упадёт посреди неё?
Можно ли использовать async/await с pg или нужны колбэки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.