В асинхронной модели Node.js сетевой ввод-вывод не блокирует основной поток выполнения (Event Loop). Однако операции с реляционными СУБД, такими как PostgreSQL, опираются на модель выделенных серверных процессов: на каждое клиентское TCP-соединение PostgreSQL порождает отдельный рабочий процесс (postgres: client backend). Инициализация нового соединения требует прохождения полного цикла: TCP 3-way handshake, согласование TLS, криптографическая аутентификация (SCRAM-SHA-256 или MD5), выделение памяти под локальные структуры (work_mem, буферы сортировки) и парсинг параметров сессии. Суммарная задержка создания одного соединения в сетевой среде составляет от 25 до 120 мс, а накладные расходы по оперативной памяти на стороне СУБД достигают 5–10 МБ на каждый бэкенд-процесс.
В высоконагруженных системах создание соединений по требованию («on-demand») гарантированно приводит к исчерпанию дескрипторов и резкому падению пропускной способности. Решением является Connection Pooling — пул предварительно открытых и готовых к работе соединений, управляемый драйвером (pg / node-postgres) или внешним пулером (PgBouncer, Odyssey).
Распространенное заблуждение разработчиков заключается в предположении: «чем больше параллельных соединений с БД открыто, тем выше пропускная способность системы». На физическом уровне дисковая подсистема (I/O) и процессорные ядра сервера баз данных имеют фиксированную емкость. Превышение оптимального порога параллельных запросов вызывает жесткую конкуренцию за блокировки страниц в Shared Buffers, частую смену контекста (Context Switching) в ядре операционной системы и лавинообразный рост времени отклика (Queue Wait Time).
Для вычисления максимального количества активных соединений к узлу PostgreSQL применяется формула пула PostgreSQL/HikariCP:
connections = ((core_count * 2) + effective_spindle_count)где:
core_count — количество физических процессорных ядер сервера базы данных.effective_spindle_count — эффективное число дисковых шпинделей. Для современных массивов NVMe SSD параметр принимается равным от 1 до 3 (в зависимости от параллелизма очередей контроллера).Например, для 16-ядерного сервера с NVMe-накопителями оптимальный общий лимит соединений со стороны всех приложений составляет:
(16 * 2) + 2 = 34 соединенияЕсли микросервисный кластер состоит из 10 реплик Node.js, максимальный размер пула (max) для каждого инстанса не должен превышать:
34 / 10 ≈ 3 соединения на инстансПревышение этого лимита без внешнего транзакционного прокси (PgBouncer) приводит к перегрузке планировщика ядра PostgreSQL и деградации производительности.
Пул соединений функционирует как конечный автомат управления очередью ресурсов. Приложение запрашивает клиента из пула (acquire), выполняет параметризованный SQL-запрос и возвращает клиента обратно (release). Если все соединения заняты, запрос помещается во внутреннюю очередь ожидания пула, ограниченную строгим тайм-аутом.
[ Node.js Event Loop ]
|
v
+--------------------+ Pool Exhausted
| pool.connect() | --------------------------------+
+--------------------+ |
| v
v Available Connection [ FIFO Wait Queue ]
+--------------------+ (connectionTimeoutMillis)
| pg.Client acquired | |
+--------------------+ Timeout Exceeded?
| |
+--------------------+-----------------------+
| |
v v (Throws Error: timeout)
[ Execute SQL Query ] [ Fast Fail ]
(statement_timeout)
|
v
+--------------------+
| client.release() | ---> Client returned to Idle List
+--------------------+Для защиты пула и самой СУБД от лавины сбоев (Cascading Failures) перед уровнем доступа к данным интегрируется Circuit Breaker (автомат защитного отключения):
+-------------------------------------------+
| |
v | Success Threshold
+--------------+ Failure Threshold Met +------------------+
| CLOSED | ------------------------------> | OPEN |
| (Normal Ops) | | (Fast Fail Call) |
+--------------+ +------------------+
^ |
| | Cooldown Expired
| Canary Success v
+----------------------------------------- +------------------+
| HALF-OPEN |
| (Canary Probe) |
+------------------+
|
| Canary Fails
+---------> (Reset to OPEN)Проверка доступности Circuit Breaker и извлечение активного клиента из пула сокетов
Отправка бинарного протокола запроса в PostgreSQL с серверным контролем statement_timeout
Возврат сокета в список idle-соединений внутри блока finally с проверкой длительности удержания
Фиксация серии сбоев сетевого ввода-вывода и перевод автомата в режим OPEN для отсечения трафика
Тестовая проверка доступности в состоянии HALF-OPEN и плавное восстановление штатной работы пула
Почему размер пула (max) в каждом экземпляре микросервиса Node.js должен быть небольшим (2-5 соединений), а не 50-100?
connectionTimeoutMillis: 1000 и statement_timeout: 2000 в сочетании с Circuit Breaker предотвращает накопление сотен подвисших микрозадач в Event Loop при сетевом расщеплении между ЦОД.max пула рассчитывается с учетом емкости централизованного PgBouncer, исключая превышение max_connections на мастере PostgreSQL.acquire/release через обертки высшего порядка с блоками try...finally, гарантирующими возврат сокета в пул даже при фатальных сбоях внутри транзакции.Для обеспечения максимальной отказоустойчивости в Node.js архитектура состоит из трех уровней:
pg.Pool с предельными тайм-аутами на уровне TCP, пула и ядра СУБД.opossum Circuit Breaker с поддержкой graceful shutdown (плавного закрытия соединений).import { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow } from "pg";
import CircuitBreaker from "opossum";
import { EventEmitter } from "node:events";
export interface ResilientDatabaseConfig {
connectionString: string;
maxConnections: number;
idleTimeoutMillis: number;
connectionTimeoutMillis: number;
statementTimeoutMillis: number;
queryTimeoutMillis: number;
}
export class ResilientDatabaseService extends EventEmitter {
private pool: Pool;
private breaker: CircuitBreaker<[string, unknown[]?], QueryResult<QueryResultRow>>;
private isShuttingDown = false;
constructor(private readonly config: ResilientDatabaseConfig) {
super();
const poolConfig: PoolConfig = {
connectionString: config.connectionString,
max: config.maxConnections,
idleTimeoutMillis: config.idleTimeoutMillis,
connectionTimeoutMillis: config.connectionTimeoutMillis,
// Принудительная установка параметров сессии на стороне PostgreSQL
statement_timeout: config.statementTimeoutMillis,
query_timeout: config.queryTimeoutMillis,
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
};
this.pool = new Pool(poolConfig);
this.registerPoolTelemetry();
this.breaker = this.initializeCircuitBreaker();
}
private registerPoolTelemetry(): void {
this.pool.on("error", (err: Error, _client: PoolClient) => {
// Ошибки простоя соединений в пуле (например, разрыв TCP сетевым шлюзом)
this.emit("telemetry", { level: "error", event: "IDLE_CLIENT_ERROR", message: err.message });
});
this.pool.on("connect", () => {
this.emit("telemetry", { level: "debug", event: "CLIENT_CONNECTED" });
});
this.pool.on("acquire", () => {
this.emit("telemetry", { level: "debug", event: "CLIENT_ACQUIRED" });
});
this.pool.on("release", () => {
this.emit("telemetry", { level: "debug", event: "CLIENT_RELEASED" });
});
}
private initializeCircuitBreaker(): CircuitBreaker<[string, unknown[]?], QueryResult<QueryResultRow>> {
const executeAction = async (sql: string, params?: unknown[]): Promise<QueryResult<QueryResultRow>> => {
if (this.isShuttingDown) {
throw new Error("Service is shutting down: refusing new queries");
}
// Захват соединения с отслеживанием времени жизни аренды
const client = await this.pool.connect();
const acquireTimestamp = Date.now();
try {
const result = await client.query(sql, params);
return result;
} finally {
const leaseDurationMs = Date.now() - acquireTimestamp;
client.release();
if (leaseDurationMs > this.config.statementTimeoutMillis) {
this.emit("telemetry", {
level: "warn",
event: "LONG_HELD_CONNECTION_DETECTED",
durationMs: leaseDurationMs,
});
}
}
};
const breakerOptions: CircuitBreaker.Options = {
timeout: this.config.queryTimeoutMillis + 500, // Запас времени перед срабатыванием тайм-аута брекера
errorThresholdPercentage: 50, // Процент ошибок для перехода в OPEN
resetTimeout: 10000, // 10 секунд нахождения в состоянии OPEN до перехода в HALF-OPEN
rollingCountTimeout: 10000, // Размер скользящего окна метрик (10 сек)
rollingCountBuckets: 10,
volumeThreshold: 5, // Минимальное число запросов в окне для активации расчетов
name: "postgres-query-breaker",
};
const breaker = new CircuitBreaker(executeAction, breakerOptions);
breaker.on("open", () => {
this.emit("telemetry", { level: "warn", event: "CIRCUIT_BREAKER_OPEN" });
});
breaker.on("halfOpen", () => {
this.emit("telemetry", { level: "info", event: "CIRCUIT_BREAKER_HALF_OPEN" });
});
breaker.on("close", () => {
this.emit("telemetry", { level: "info", event: "CIRCUIT_BREAKER_CLOSED" });
});
breaker.fallback(() => {
throw new Error("Database service degraded: Circuit Breaker rejected the request");
});
return breaker;
}
public async query<T extends QueryResultRow = QueryResultRow>(
sql: string,
params?: unknown[]
): Promise<QueryResult<T>> {
const result = await this.breaker.fire(sql, params);
return result as QueryResult<T>;
}
/**
* Безопасное выполнение транзакций с авто-откатом и освобождением сокета
*/
public async transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
if (this.isShuttingDown) {
throw new Error("Service is shutting down: refusing new transactions");
}
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await callback(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
/**
* Корректный дренаж пула соединений при завершении процесса
*/
public async gracefulDrain(drainTimeoutMs = 5000): Promise<void> {
this.isShuttingDown = true;
this.emit("telemetry", { level: "info", event: "DRAINING_POOL_START" });
// Ожидание завершения активных соединений или тайм-аут
const drainPromise = this.pool.end();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Pool drain timeout exceeded")), drainTimeoutMs)
);
try {
await Promise.race([drainPromise, timeoutPromise]);
this.emit("telemetry", { level: "info", event: "DRAINING_POOL_SUCCESS" });
} catch (error) {
this.emit("telemetry", { level: "error", event: "DRAINING_POOL_TIMEOUT", error });
throw error;
}
}
public getMetrics() {
return {
totalCount: this.pool.totalCount,
idleCount: this.pool.idleCount,
waitingCount: this.pool.waitingCount,
circuitState: this.breaker.opened ? "OPEN" : this.breaker.halfOpen ? "HALF_OPEN" : "CLOSED",
};
}
}Проверка текущего состояния цепи. Если цепь OPEN, запрос немедленно отклоняется с ошибкой без обращения к драйверу.
Запрос сокета из свободных (idle). Если пул исчерпан, ожидание в FIFO очереди до connectionTimeoutMillis (1000 мс).
Передача параметров запроса по сокету. На стороне СУБД запускается таймер statement_timeout.
Обязательный возврат клиента в пул через client.release(). Фиксация успешного ответа в скользящем окне Circuit Breaker.
При падении соединения или превышении тайм-аута сокет отбраковывается драйвером, а брекер инкрементирует счетчик сбоев.
[databases]
; Маршрутизация на локальный мастер PostgreSQL с ограничением соединений
production_db = host=127.0.0.1 port=5432 dbname=production_db pool_size=30 max_db_connections=40
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; КРИТИЧЕСКИ ВАЖНО: режим transaction pool для Node.js сервисов
pool_mode = transaction
; Пределы подключений со стороны клиентов (Node.js подов)
max_client_conn = 5000
default_pool_size = 25
min_pool_size = 10
reserve_pool_size = 5
reserve_pool_timeout = 5.0
; Тайм-ауты сетевого взаимодействия
server_idle_timeout = 600.0
server_connect_timeout = 3.0
server_login_retry = 1.0
client_idle_timeout = 120.0
client_login_timeout = 10.0
query_timeout = 10.0
query_wait_timeout = 5.0
; Оптимизация памяти TCP-сокетов
pkt_buf = 4096
listen_backlog = 512Что произойдет, если в цепочке pool.connect() забыть вызвать client.release() в блоке finally при возникновении исключения?
Разработчик запрашивает клиента через const client = await pool.connect(), выполняет запрос, и при возникновении ошибки парсинга JSON или бизнес-исключения до вызова client.release() код завершается аварийно. Клиент остается в статусе «занят» навсегда. Через 10–50 таких ошибок пул соединений исчерпывается (waitingCount растет), и сервис намертво зависает.
// АНТИПАТТЕРН: ошибка приведет к вечной утечке сокета
const client = await pool.connect();
const data = await client.query("SELECT * FROM sensitive_data");
processData(data.rows); // Выбросит исключение!
client.release(); // Никогда не выполнится
// ПРАВИЛЬНО: строгая изоляция через try...finally
const client = await pool.connect();
try {
const data = await client.query("SELECT * FROM sensitive_data");
processData(data.rows);
} finally {
client.release(); // Гарантированный возврат в пул
}По умолчанию в драйвере pg свойство connectionTimeoutMillis равно 0 (бесконечное ожидание). Если база данных зависла или исчерпала слоты, все входящие HTTP-запросы в Node.js зависают в очереди пула, удерживая сокеты входящих HTTP-соединений. В течение 30 секунд у процесса Node.js заканчивается доступная память, и он падает по OOM (Out Of Memory). Обязательно задавайте жесткие тайм-ауты как ожидания в очереди пула, так и самого SQL-запроса на сервере.
Установка max: 20 в конфигурационном файле Node.js сервиса, развернутого в 50 подах Kubernetes. Суммарный объем соединений достигает 1000. При всплеске нагрузки база данных получает 1000 конкурентных транзакций, время отклика вырастает в 20 раз из-за дискового троттлинга и борьбы за spinlock-блокировки в памяти СУБД. Решение: пул каждого инстанса должен быть минимальным (2–4 соединения), а суммарный объем соединений должен контролироваться PgBouncer.
Вызов pool.query('BEGIN'), затем pool.query('UPDATE ...') и pool.query('COMMIT'). Каждый вызов pool.query() автоматически берет случайного свободного клиента из пула и сразу же возвращает его обратно. В результате BEGIN выполняется на одном клиенте, UPDATE — на совершенно другом (вне транзакции), а COMMIT — на третьем. Любая транзакция обязана выполняться строго через одного клиента, полученного через pool.connect().