В асинхронній моделі виконання Node.js мережеве введення-виведення (I/O) не блокує головний потік виконання (Event Loop). Проте реляційні системи керування базами даних, зокрема PostgreSQL, спираються на архітектуру виділених серверних процесів: на кожне вхідне клієнтське TCP-з'єднання PostgreSQL породжує окремий робочий процес операційної системи (postgres: client backend). Встановлення нового з'єднання потребує проходження повного циклу ініціалізації: 3-етапне рукостискання 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) в ядрі ОС та лавиноподібне зростання часу очікування в чергах.
Для обчислення максимальної кількості активних з'єднань до вузла 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 і спричиняє деградацію системи.
Пул з'єднань функціонує як скінченний автомат керування чергою ресурсів FIFO. Додаток орендує вільне з'єднання з пулу (acquire), виконує параметризований SQL-запит і повертає сокет назад (release). Якщо всі з'єднання зайняті, вхідні запити стають у внутрішню чергу очікування, обмежену суворим таймаутом.
[ Node.js Event Loop ]
|
v
+--------------------+ Пул вичерпано
| pool.connect() | --------------------------------+
+--------------------+ |
| v
v Вільне з'єднання [ Черга FIFO ]
+--------------------+ (connectionTimeoutMillis)
| pg.Client отримано | |
+--------------------+ Таймаут вичерпано?
| |
+--------------------+-----------------------+
| |
v v (Викидає Error: timeout)
[ Виконання SQL-запиту ] [ Fast Fail ]
(statement_timeout)
|
v
+--------------------+
| client.release() | ---> Сокет повернуто у пул
+--------------------+Для захисту пулу та СУБД від каскадних відмов (Cascading Failures) перед рівнем доступу до даних інтегрується Circuit Breaker (автомат захисного вимкнення):
+-------------------------------------------+
| |
v | Поріг успішних спроб
+--------------+ Перевищено поріг помилок +------------------+
| CLOSED | ------------------------------> | OPEN |
| (Нормальний) | | (Швидкий збій) |
+--------------+ +------------------+
^ |
| | Період остигання
| Канареєчний успіх v
+----------------------------------------- +------------------+
| HALF-OPEN |
| (Пробна спроба) |
+------------------+
|
| Канареєчний збій
+---------> (Повернення в OPEN)connectionTimeoutMillis: 1000) та виконання запиту (statement_timeout: 2000) із автоматом Circuit Breaker запобігає зависанню тисяч мікрозадач в Event Loop під час розривів зв'язку між дата-центрами.max) розраховується з урахуванням ємності PgBouncer, запобігаючи вичерпанню max_connections на магістрі PostgreSQL.acquire/release у функціях вищого порядку за допомогою блоків try...finally, що гарантує повернення сокета в пул навіть у разі помилок всередині транзакції.Для забезпечення максимальної відмовостійкості в Node.js архітектура складається з трьох рівнів:
pg.Pool із примусовими таймаутами на рівні сокета, драйвера та СУБД.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) => {
// Помилки сокетів у простої (наприклад, аварійне закриття з'єднання фаєрволом)
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 секунд охолодження перед переходом у 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
; КРИТИЧНО: режим транзакційного пулінгу для сервісів 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
; Оптимізація пам'яті буферів сокетів
pkt_buf = 4096
listen_backlog = 512Розробник отримує клієнта через const client = await pool.connect(), виконує запит, і в разі виникнення помилки парсингу даних або винятку до виклику 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 блокуються в черзі пулу. За кілька десятків секунд процес Node.js вичерпує пам'ять купи і падає через помилку Out Of Memory (OOM). Обов'язково конфігуруйте жорсткі таймаути як для очікування в черзі пулу, так і для тривалості самого запиту в СУБД.
Встановлення значення max: 20 у конфігурації мікросервісу, розгорнутого у 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().
Перевірка доступності Circuit Breaker та отримання активного клієнта з пулу сокетів
Відправка бінарного пакета запиту в PostgreSQL із контролем statement_timeout
Повернення сокета у список idle-з'єднань усередині блоку finally з контролем часу оренди
Реєстрація каскаду мережевих збоїв та переведення автомата в стан OPEN для скидання трафіку
Пробні канареєчні запити в стані HALF-OPEN та плавне відновлення штатного функціонування