Saltar al contenido

Pooling de conexiones para servicios Node.js de alto rendimiento

Análisis a fondo del pooling de conexiones en Node.js: dimensionado, health checks, failover y las configuraciones erróneas que tumban producción.

7 min de lectura
Diagrama de un pool de conexiones distribuyendo conexiones de base de datos entre hilos de trabajo

Por qué toda aplicación Node.js termina chocando contra el muro de las conexiones

Tu aplicación funciona bien en desarrollo con cinco usuarios concurrentes. En staging con cincuenta, las consultas se vuelven lentas. En producción con quinientos, la base de datos empieza a rechazar conexiones. El mensaje de error siempre es alguna variante de "too many connections" o "connection pool exhausted".

Esto ocurre porque las conexiones a la base de datos son costosas. Cada conexión de PostgreSQL consume aproximadamente 10MB de memoria, requiere un handshake TCP más la negociación TLS, y provoca la creación de un proceso en el servidor. Sin pooling, cada consulta abre una conexión nueva, la usa una vez y la descarta. Bajo carga, este patrón colapsa de forma catastrófica.

El pooling de conexiones resuelve esto manteniendo un conjunto de conexiones persistentes que las consultas comparten. Pero un pool con una configuración incorrecta a veces es peor que no tener pool en absoluto. Esta guía cubre la mecánica.

Dimensionamiento del pool: las matemáticas que la mayoría de los equipos se salta

El error más grande es definir el tamaño del pool por intuición. "Usemos 20 conexiones" no es una estrategia, es una apuesta. El tamaño del pool depende de tus patrones de consulta, de la capacidad de tu servidor de base de datos y de cuántas instancias de la aplicación comparten esa base de datos.

tstypescript
// ❌ Bad: Arbitrary pool size with no relationship to workload
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 100, // Way too many — most databases can't handle this
});
tstypescript
// ✅ Good: Calculated pool size based on actual constraints
import { Pool, PoolConfig } from "pg";
 
function calculatePoolSize(): number {
  const maxDbConnections = 100; // PostgreSQL max_connections setting
  const reservedConnections = 5; // For admin, migrations, monitoring
  const appInstances = parseInt(process.env.APP_INSTANCES || "4", 10);
  const availableConnections = maxDbConnections - reservedConnections;
 
  return Math.floor(availableConnections / appInstances);
}
 
const poolConfig: PoolConfig = {
  connectionString: process.env.DATABASE_URL,
  max: calculatePoolSize(),
  min: 2,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
  allowExitOnIdle: false,
};
 
const pool = new Pool(poolConfig);

La fórmula es sencilla: resta las conexiones reservadas de max_connections y divide entre el número de instancias de la aplicación. Si tu base de datos permite 100 conexiones, reservas 5. Cuatro instancias de la aplicación reciben cada una un pool de 23. Subir más significa que una instancia puede dejar sin conexiones a las demás.

Para bases de datos limitadas por CPU, el número óptimo de conexiones activas es aproximadamente (2 × núcleos de CPU) + discos. Un servidor de base de datos de 4 núcleos con SSDs rinde mejor con unas 10 conexiones activas, sin importar cuántas contenga tu pool.

Health checks y validación de conexiones

Un pool lleno de conexiones muertas es peor que un pool vacío. Las conexiones mueren en silencio: fallos de red, reinicios de la base de datos y timeouts del balanceador de carga cortan conexiones sin notificar al cliente. Sin validación, tu siguiente consulta falla sobre una conexión rota.

tstypescript
import { Pool, PoolClient } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});
 
// Validate connections before use
async function getValidClient(): Promise<PoolClient> {
  const client = await pool.connect();
 
  try {
    await client.query("SELECT 1");
    return client;
  } catch {
    client.release(true); // true = destroy this connection
    throw new Error("Database connection validation failed");
  }
}
 
// Connection pool event monitoring
pool.on("error", (err: Error) => {
  console.error("Unexpected pool error:", err.message);
});
 
pool.on("connect", () => {
  console.debug("New connection established");
});
 
pool.on("remove", () => {
  console.debug("Connection removed from pool");
});
 
// Periodic health check
async function healthCheck(): Promise<{ healthy: boolean; stats: object }> {
  try {
    const start = Date.now();
    await pool.query("SELECT 1");
    const latency = Date.now() - start;
 
    return {
      healthy: true,
      stats: {
        totalConnections: pool.totalCount,
        idleConnections: pool.idleCount,
        waitingRequests: pool.waitingCount,
        latencyMs: latency,
      },
    };
  } catch (err) {
    return {
      healthy: false,
      stats: { error: (err as Error).message },
    };
  }
}

La métrica pool.waitingCount es tu señal de alerta temprana. Si las consultas se están encolando esperando conexiones, tu pool es demasiado pequeño para la carga actual. Monitorea esto en producción y genera una alerta cuando se mantenga por encima de cero durante más de unos pocos segundos.

Patrones de consulta que matan los pools de conexiones

Algunos patrones de código retienen conexiones mucho más tiempo del necesario, reduciendo de facto tu pool. Las transacciones largas, los releases olvidados y las consultas N+1 son los culpables habituales.

tstypescript
// ❌ Bad: Holding a connection during external API call
async function processOrder(orderId: string): Promise<void> {
  const client = await pool.connect();
  try {
    const order = await client.query(
      "SELECT * FROM orders WHERE id = $1",
      [orderId]
    );
 
    // This HTTP call takes 2-5 seconds — connection is held the entire time!
    const shippingRate = await fetch(
      `https://shipping-api.example.com/rates?weight=${order.rows[0].weight}`
    ).then((r) => r.json());
 
    await client.query(
      "UPDATE orders SET shipping_rate = $1 WHERE id = $2",
      [shippingRate.rate, orderId]
    );
  } finally {
    client.release();
  }
}
tstypescript
// ✅ Good: Release connection before external calls
async function processOrder(orderId: string): Promise<void> {
  const order = await pool.query(
    "SELECT * FROM orders WHERE id = $1",
    [orderId]
  );
 
  // Connection already returned to pool
  const shippingRate = await fetch(
    `https://shipping-api.example.com/rates?weight=${order.rows[0].weight}`
  ).then((r) => r.json());
 
  await pool.query(
    "UPDATE orders SET shipping_rate = $1 WHERE id = $2",
    [shippingRate.rate, orderId]
  );
}

Usa pool.query() para sentencias individuales: adquiere y libera una conexión automáticamente. Usa pool.connect() solo cuando necesites semántica de transacciones con BEGIN/COMMIT a lo largo de varias sentencias.

Gestión de transacciones y ciclo de vida de las conexiones

Las transacciones exigen una única conexión durante toda su duración. Aquí es donde la presión sobre el pool de conexiones se vuelve crítica: cada transacción abierta bloquea una conexión del pool.

tstypescript
type TransactionCallback<T> = (client: PoolClient) => Promise<T>;
 
async function withTransaction<T>(
  callback: TransactionCallback<T>
): Promise<T> {
  const client = await 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();
  }
}
 
// Usage: Transaction holds connection only for DB operations
async function transferFunds(
  fromId: string,
  toId: string,
  amount: number
): Promise<void> {
  await withTransaction(async (client) => {
    const from = await client.query(
      "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
      [fromId]
    );
 
    if (from.rows[0].balance < amount) {
      throw new Error("Insufficient funds");
    }
 
    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]
    );
  });
}

El wrapper withTransaction garantiza que las conexiones siempre se liberan y que las transacciones siempre terminan con COMMIT o con ROLLBACK. Sin este patrón, una excepción lanzada a mitad de una transacción puede dejar la transacción abierta y la conexión fugada.

Poolers de conexiones externos: PgBouncer y más allá

Para aplicaciones a gran escala, un pooler de conexiones externo se sitúa entre tu aplicación y la base de datos. PgBouncer es el más común para PostgreSQL. Multiplexa cientos de conexiones de aplicación sobre un puñado de conexiones reales a la base de datos.

iniini
; pgbouncer.ini — transaction-level pooling
[databases]
myapp = host=db.internal port=5432 dbname=myapp
 
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
 
; Transaction pooling: connection returned after each transaction
pool_mode = transaction
 
; Pool sizing
default_pool_size = 20
max_client_conn = 1000
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
 
; Timeouts
server_idle_timeout = 300
client_idle_timeout = 600
query_timeout = 30
tstypescript
// Application connects to PgBouncer, not directly to PostgreSQL
const pool = new Pool({
  host: "pgbouncer.internal",
  port: 6432,
  database: "myapp",
  max: 50, // Can be higher since PgBouncer manages the real connections
  // IMPORTANT: Disable prepared statements with transaction pooling
  statement_timeout: 30000,
});
 
// PgBouncer transaction pooling breaks prepared statements
// Use this wrapper to disable them
async function query(text: string, values?: unknown[]) {
  return pool.query({ text, values, rowMode: "array" });
}

El pooling a nivel de transacción en PgBouncer significa que una conexión real a la base de datos se asigna solo durante la duración de una transacción. Entre transacciones, la conexión queda disponible para otros clientes. Esto permite que 1000 conexiones de aplicación compartan 20 conexiones de base de datos.

La advertencia crítica: el pooling por transacción rompe las funcionalidades de PostgreSQL que dependen del estado de la sesión: prepared statements, comandos SET, LISTEN/NOTIFY y advisory locks. Si los necesitas, usa pooling por sesión o gestiónalos a nivel de aplicación.

Monitorización y alertas para pools de conexiones

Los problemas del pool de conexiones se manifiestan como picos de latencia, no como errores, hasta que el pool se agota por completo. La monitorización proactiva detecta los problemas antes de que los usuarios los noten.

tstypescript
import { Pool } from "pg";
 
interface PoolMetrics {
  totalConnections: number;
  activeConnections: number;
  idleConnections: number;
  waitingClients: number;
  maxConnections: number;
  utilizationPercent: number;
}
 
function collectPoolMetrics(pool: Pool): PoolMetrics {
  const total = pool.totalCount;
  const idle = pool.idleCount;
  const waiting = pool.waitingCount;
  const max = (pool as unknown as { options: { max: number } }).options.max;
 
  return {
    totalConnections: total,
    activeConnections: total - idle,
    idleConnections: idle,
    waitingClients: waiting,
    maxConnections: max,
    utilizationPercent: ((total - idle) / max) * 100,
  };
}
 
// Alert thresholds
function evaluatePoolHealth(metrics: PoolMetrics): string[] {
  const alerts: string[] = [];
 
  if (metrics.utilizationPercent > 80) {
    alerts.push("Pool utilization above 80% — consider scaling");
  }
 
  if (metrics.waitingClients > 0) {
    alerts.push(`${metrics.waitingClients} queries waiting for connections`);
  }
 
  if (metrics.idleConnections === 0 && metrics.totalConnections === metrics.maxConnections) {
    alerts.push("Pool fully saturated — all connections in use");
  }
 
  return alerts;
}

Las tres métricas que importan: porcentaje de utilización, número de clientes en espera y tiempo de adquisición de conexiones. Si la utilización supera consistentemente el 70%, necesitas más conexiones o menos instancias de la aplicación compartiendo el pool. Si hay clientes esperando, ya estás degradando la experiencia del usuario.

Conclusiones clave

El pooling de conexiones es una de esas preocupaciones de infraestructura que son invisibles hasta que fallan. El tamaño del pool no es un número mágico: se deriva de la capacidad de la base de datos, del número de instancias y de los patrones de consulta. Valida las conexiones antes de usarlas, libéralas inmediatamente después de usarlas y nunca retengas una conexión mientras esperas a servicios externos.

Los poolers externos como PgBouncer desbloquean una multiplexación masiva de conexiones, pero vienen con restricciones de compatibilidad. Entiende qué se rompe con el pooling por transacción antes de desplegarlo.

El indicador más fiable de la salud del pool es el número de clientes en espera. Si las consultas se encolan esperando conexiones, todo lo que está aguas abajo —tiempos de respuesta, throughput, experiencia de usuario— ya se está degradando. Monitóralo, genera alertas sobre él y tómatelo tan en serio como las alertas de CPU o memoria.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX