Saltar al contenido

Pooling de conexiones para aplicaciones de alto rendimiento

Configura y optimiza los pools de conexiones para Node.js de alto rendimiento: dimensionado, health checks, ciclo de vida y qué agota un pool.

5 min de lectura
Diagrama que muestra varias instancias de una aplicación compartiendo un pool de conexiones que gestiona las conexiones a un clúster de base de datos

Cada consulta a la base de datos requiere una conexión. Crear una nueva conexión TCP para cada consulta implica un handshake TLS, un round-trip de autenticación y la negociación del protocolo: fácilmente 50-100 ms de sobrecarga antes del primer byte de datos. A 1.000 consultas por segundo, esa sobrecarga por sí sola satura tu aplicación.

El pooling de conexiones resuelve esto manteniendo un conjunto de conexiones preestablecidas que las consultas toman prestadas y devuelven. Pero un pool mal configurado es peor que no tener pool: demasiado pequeño ahoga la aplicación, demasiado grande desborda la base de datos, y las conexiones fugadas degradan el sistema en silencio hasta que colapsa.

Por qué importa el pooling de conexiones

Sin pooling, cada petición crea y destruye una conexión a la base de datos. La sobrecarga se acumula rápidamente bajo carga.

tstypescript
// ❌ New connection per query
import { Client } from "pg";
 
async function getUser(id: string) {
  const client = new Client({
    connectionString: process.env.DATABASE_URL,
  });
  await client.connect(); // ~50-100ms overhead
  const result = await client.query(
    "SELECT * FROM users WHERE id = $1",
    [id]
  );
  await client.end(); // Connection destroyed
  return result.rows[0];
}
// At 500 req/s: 500 connections created and
// destroyed per second, each with handshake overhead
tstypescript
// ✅ Shared connection pool
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});
 
async function getUser(id: string) {
  const result = await pool.query(
    "SELECT * FROM users WHERE id = $1",
    [id]
  );
  return result.rows[0];
  // Connection returned to pool automatically
}
// At 500 req/s: 20 connections handle all traffic,
// pre-established with zero per-request overhead

Dimensionamiento del pool: la configuración crítica

El error más común es fijar el tamaño del pool igual al número de instancias de la aplicación o de CPUs. El tamaño del pool debería basarse en la capacidad de la base de datos y en las características de las consultas.

tstypescript
// Pool sizing formula:
// optimal_pool_size = (core_count * 2) + disk_spindles
// For SSDs: roughly core_count * 2 + 1
//
// PostgreSQL with 4 cores:
// (4 * 2) + 1 = 9 connections per database
// Not per application instance — TOTAL across
// all instances
 
interface PoolConfig {
  max: number;
  min: number;
  idleTimeoutMillis: number;
  connectionTimeoutMillis: number;
  maxUses: number;
  allowExitOnIdle: boolean;
}
 
function createOptimizedPool(
  instanceCount: number,
  dbCores: number
): PoolConfig {
  // Total optimal connections for this database
  const totalOptimal = dbCores * 2 + 1;
 
  // Divide across application instances
  const perInstance = Math.max(
    2,
    Math.floor(totalOptimal / instanceCount)
  );
 
  return {
    max: perInstance,
    min: Math.max(1, Math.floor(perInstance / 4)),
    idleTimeoutMillis: 30_000,
    connectionTimeoutMillis: 5_000,
    // Recycle connections after N uses to prevent
    // memory leaks in long-running connections
    maxUses: 7500,
    allowExitOnIdle: true,
  };
}
 
// 3 app instances, database with 4 cores
// Total optimal: 9, per instance: 3
const config = createOptimizedPool(3, 4);
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ...config,
});

Health checks de las conexiones

Las conexiones obsoletas en el pool causan fallos silenciosos. Puede que la base de datos se haya reiniciado, que haya ocurrido una partición de red o que la conexión haya alcanzado un timeout del lado del servidor.

tstypescript
import { Pool, PoolClient } from "pg";
 
class ResilientPool {
  private pool: Pool;
 
  constructor(connectionString: string, max: number) {
    this.pool = new Pool({
      connectionString,
      max,
      idleTimeoutMillis: 30_000,
      connectionTimeoutMillis: 5_000,
    });
 
    // Handle pool-level errors
    this.pool.on("error", (err: Error) => {
      console.error(
        "Unexpected pool error:",
        err.message
      );
    });
 
    // Log when connections are created/removed
    this.pool.on("connect", () => {
      console.debug(
        `Pool connection created. ` +
          `Total: ${this.pool.totalCount}, ` +
          `Idle: ${this.pool.idleCount}`
      );
    });
 
    this.pool.on("remove", () => {
      console.debug(
        `Pool connection removed. ` +
          `Total: ${this.pool.totalCount}`
      );
    });
  }
 
  async getConnection(): Promise<PoolClient> {
    const client = await this.pool.connect();
 
    // Validate connection before returning
    try {
      await client.query("SELECT 1");
    } catch {
      client.release(true); // Destroy bad connection
      // Pool will create a fresh replacement
      return this.pool.connect();
    }
 
    return client;
  }
 
  async query<T>(
    text: string,
    params?: unknown[]
  ): Promise<T[]> {
    const client = await this.getConnection();
    try {
      const result = await client.query(text, params);
      return result.rows;
    } finally {
      client.release();
    }
  }
 
  getStats() {
    return {
      total: this.pool.totalCount,
      idle: this.pool.idleCount,
      waiting: this.pool.waitingCount,
    };
  }
 
  async shutdown(): Promise<void> {
    await this.pool.end();
  }
}

Uso del pool consciente de las transacciones

El patrón de pool más peligroso es fugar conexiones dentro de transacciones. Un release() olvidado en un camino de error elimina permanentemente una conexión del pool.

tstypescript
// ❌ Connection leak on error
async function transferFunds(
  from: string,
  to: string,
  amount: number
) {
  const client = await pool.connect();
  await client.query("BEGIN");
 
  await client.query(
    "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
    [amount, from]
  );
 
  // If this throws, client is never released!
  await client.query(
    "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
    [amount, to]
  );
 
  await client.query("COMMIT");
  client.release();
}
tstypescript
// ✅ Safe transaction wrapper
async function withTransaction<T>(
  pool: Pool,
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release(); // Always releases
  }
}
 
// Usage — impossible to leak
async function transferFunds(
  from: string,
  to: string,
  amount: number
) {
  return withTransaction(pool, async (client) => {
    const { rows } = await client.query(
      "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
      [from]
    );
 
    if (rows[0].balance < amount) {
      throw new Error("Insufficient funds");
      // ROLLBACK and release happen automatically
    }
 
    await client.query(
      "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
      [amount, from]
    );
    await client.query(
      "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
      [amount, to]
    );
 
    return { from, to, amount, status: "completed" };
  });
}

Monitorización de la salud del pool

El agotamiento del pool no provoca un fallo inmediato: se degrada gradualmente a medida que las peticiones se encolan esperando una conexión. Monitoriza las métricas del pool para detectar los problemas antes de que se propaguen.

tstypescript
import { Pool } from "pg";
 
class MonitoredPool {
  private pool: Pool;
  private metrics = {
    queriesExecuted: 0,
    queryErrors: 0,
    connectionWaits: 0,
    maxWaitingObserved: 0,
  };
 
  constructor(config: Record<string, unknown>) {
    this.pool = new Pool(config);
    this.startMetricsCollection();
  }
 
  private startMetricsCollection() {
    setInterval(() => {
      const stats = {
        total: this.pool.totalCount,
        idle: this.pool.idleCount,
        waiting: this.pool.waitingCount,
        ...this.metrics,
      };
 
      // Track high-water mark
      if (
        stats.waiting > this.metrics.maxWaitingObserved
      ) {
        this.metrics.maxWaitingObserved = stats.waiting;
      }
 
      // Alert on pool pressure
      if (stats.waiting > 0) {
        this.metrics.connectionWaits++;
        console.warn(
          `⚠️ Pool pressure: ` +
            `${stats.waiting} queries waiting, ` +
            `${stats.idle}/${stats.total} idle`
        );
      }
 
      if (stats.idle === 0 && stats.waiting > 5) {
        console.error(
          `🚨 Pool exhaustion risk: ` +
            `0 idle connections, ` +
            `${stats.waiting} waiting`
        );
      }
    }, 5000);
  }
 
  async query<T>(
    text: string,
    params?: unknown[]
  ): Promise<T[]> {
    const start = performance.now();
 
    try {
      const result = await this.pool.query(
        text,
        params
      );
      this.metrics.queriesExecuted++;
 
      const duration = performance.now() - start;
      if (duration > 1000) {
        console.warn(
          `Slow query (${Math.round(duration)}ms): ` +
            `${text.slice(0, 100)}`
        );
      }
 
      return result.rows;
    } catch (error) {
      this.metrics.queryErrors++;
      throw error;
    }
  }
 
  getHealthStatus(): {
    healthy: boolean;
    details: string;
  } {
    const waiting = this.pool.waitingCount;
    const total = this.pool.totalCount;
    const idle = this.pool.idleCount;
 
    if (waiting > total) {
      return {
        healthy: false,
        details:
          `Pool exhausted: ${waiting} waiting, ` +
          `${idle}/${total} idle`,
      };
    }
 
    return {
      healthy: true,
      details:
        `${idle}/${total} idle, ` +
        `${waiting} waiting`,
    };
  }
}

Conclusiones clave

El tamaño del pool debería calcularse a partir de la capacidad de la base de datos (cores * 2 + effective_spindles) dividida entre las instancias de la aplicación: demasiado alto desborda la base de datos, demasiado bajo ahoga la aplicación, y el total entre todas las instancias importa más que el conteo de cualquier instancia individual. Usa siempre un patrón de wrapper transaccional con try/catch/finally que garantice que client.release() se ejecuta independientemente del éxito o del fallo, porque una única conexión fugada en un camino de error reduce silenciosamente la capacidad del pool hasta que la aplicación se degrada bajo carga. Monitoriza las métricas del pool de forma continua: registra waitingCount, idleCount y totalCount como series temporales, y alerta cuando las consultas en espera superen cero, porque la presión sobre el pool es un indicador temprano de problemas de capacidad que se propagan en timeouts. Valida las conexiones antes de usarlas en caminos críticos —las conexiones pueden quedar obsoletas por reinicios de la base de datos, particiones de red o timeouts de inactividad del lado del servidor— y libera las conexiones inválidas con release(true) para que el pool las destruya y cree reemplazos nuevos.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX