База данных из Node.js: чек-лист перед продакшеном
Пул на 10 соединений, отсутствие таймаутов и один незакрытый транзакционный клиент кладут сервис под первой же нагрузкой. Что проверить в слое доступа к БД до релиза.

Приложение на Node.js под нагрузкой в 300 одновременных запросов начинает отдавать 504, хотя база почти простаивает. Причина почти всегда одна и та же: пул соединений исчерпан, запросы стоят в очереди, а таймаута нет — клиент ждёт вечно. Ниже чек-лист по слою доступа к базе, который стоит пройти до продакшена. Ориентируюсь на node-postgres (pg) 8.x и mysql2 3.x — самые частые драйверы; для ORM (Prisma, Sequelize, Knex) те же принципы, только настройки называются иначе.
Пул соединений: то, что ломается первым
Открывать новое TCP-соединение с базой на каждый запрос — дорого: рукопожатие, аутентификация, иногда TLS. Поэтому драйверы держат пул. Проблема не в самом пуле, а в его размере и в утечках.
Дефолтный размер пула у pg — 10 соединений (параметр max). Это значит: одиннадцатый одновременный запрос ждёт, пока освободится любой из десяти. Если запрос медленный или соединение не вернулось в пул из-за забытого release(), очередь растёт, и приложение виснет при живой базе.
Размер пула считается от базы, а не от приложения. У PostgreSQL есть общий лимит max_connections (по умолчанию 100). Если у вас 4 инстанса приложения по 25 соединений в пуле — это уже 100, и на служебные подключения, миграции и мониторинг не останется ничего.
Размер пула — это не «сколько потянет приложение», а «сколько соединений вы готовы отдать одному инстансу из общего лимита базы». Считайте сверху вниз, от max_connections, а не снизу вверх.
Как задать пул и не забыть про таймауты
// node-postgres (pg) 8.x
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // размер пула на этот инстанс
idleTimeoutMillis: 30000, // закрыть простаивающее соединение через 30 с
connectionTimeoutMillis: 5000, // не ждать соединение из пула дольше 5 с
});
// обязательно: пул эмитит error по «мёртвым» соединениям
pool.on('error', (err) => {
console.error('Неожиданная ошибка простаивающего клиента', err);
});
Без connectionTimeoutMillis запрос при исчерпанном пуле висит бесконечно — это и есть источник тех самых зависших 504. Точные имена параметров сверяйте с README драйвера вашей версии, у mysql2 они называются иначе (connectionLimit, waitForConnections).
Транзакции: где теряются соединения
Самая частая утечка — транзакция, взявшая клиента из пула через pool.connect() и не вернувшая его в ветке с ошибкой. Клиент навсегда выпадает из пула, пул медленно вымирает.
// Транзакция без утечки: release() в finally
async function transferFunds(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(); // вернуть клиента в пул в любом случае
}
}
Правило простое: pool.query() для одиночных запросов (сам берёт и возвращает клиента), pool.connect() — только когда нужна транзакция, и тогда release() строго в finally.
SQL-инъекции: параметры, а не конкатенация
Строка вида `SELECT * FROM users WHERE email = '${email}'` — открытая дверь для инъекции. Драйверы принимают параметры отдельно от текста запроса, и это единственный надёжный способ.
- pg: плейсхолдеры
$1, $2, значения массивом вторым аргументом. - mysql2: плейсхолдеры
?, значения массивом; для идентификаторов (имена таблиц) —??.
Имя таблицы или колонки нельзя передать плейсхолдером-значением — параметризуются только значения. Если имя приходит извне, сверяйте его с белым списком в коде, а не подставляйте в строку.
Обязательный минимум перед релизом
- Пул настроен явно. Заданы
max,idleTimeoutMillis,connectionTimeoutMillis. Суммарныйmaxпо всем инстансам не превышает лимит базы за вычетом резерва. - На каждый запрос — таймаут. Помимо таймаута на получение соединения, задайте
statement_timeoutна стороне PostgreSQL, чтобы один тяжёлый запрос не держал соединение вечно. - Все запросы параметризованы. Поиском по коду проверьте, что нет шаблонных строк с интерполяцией внутри SQL.
- Транзакции возвращают клиента. Каждый
pool.connect()имеет парныйrelease()вfinally. - Секреты не в коде. Строка подключения и пароль — из переменных окружения или секрет-менеджера, не из репозитория.
- TLS до базы. Если база в отдельной сети или у облачного провайдера —
sslвключён и сертификат проверяется, а неrejectUnauthorized: false. - Миграции отделены от кода приложения. Схема накатывается отдельным шагом деплоя, а не при старте инстансов — иначе несколько инстансов будут гонять миграции параллельно.
- Graceful shutdown. По
SIGTERMприложение дожидается активных запросов и вызываетpool.end().
Корректное завершение
// Закрываем пул при остановке процесса
process.on('SIGTERM', async () => {
console.log('Получен SIGTERM, закрываю пул');
await pool.end(); // дождётся возврата активных клиентов
process.exit(0);
});
Без этого при рестарте под оркестратором часть запросов оборвётся на середине, а соединения останутся висеть на стороне базы до своего таймаута.
Что мерить, а не угадывать
| Показатель | Откуда взять | Тревожный признак |
|---|---|---|
| Занятые соединения пула | pool.totalCount, pool.idleCount, pool.waitingCount у pg | waitingCount > 0 под нагрузкой |
| Активные соединения к базе | pg_stat_activity в PostgreSQL | рост числа idle in transaction |
| Медленные запросы | лог log_min_duration_statement | запросы дольше 100–500 мс в горячем пути |
| Ошибки соединения | событие pool.on('error') | любой всплеск |
waitingCount стабильно больше нуля — это очередь на пул: либо мал max, либо запросы слишком долгие. idle in transaction — это как раз забытые COMMIT/ROLLBACK, тот же класс проблем, что и утечка клиента.
Что делать дальше
После базового чек-листа посмотрите в сторону внешнего пулера соединений — PgBouncer в режиме transaction, если инстансов приложения много и суммарный пул упирается в max_connections. Он держит соединения к базе и раздаёт их приложениям, снимая нагрузку с лимита. Отдельная тема — ретраи и circuit breaker при недоступности базы: здесь мы их не разворачивали. Первое, что стоит настроить сразу после релиза, — алерт на waitingCount и на число idle in transaction: обе метрики ловят проблему до того, как она превратится в зависшие ответы.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: node-postgres — Pooling (официальная документация), PostgreSQL Documentation — Connection Settings
Частые вопросы
Какой размер пула ставить?
А если я использую Prisma или Sequelize, а не голый драйвер?
Что делать, если под нагрузкой всё зависает, но база не загружена?
Грозит ли что-то, если забыть release() в транзакции?
Обязательно ли выносить миграции из старта приложения?
Нужен ли PgBouncer сразу?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.